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:
2026-09-23 18:47:15 +08:00
co-authored by Claude Code
parent 574216645c
commit 023dc1cb5d
5 changed files with 137 additions and 73 deletions
+109
View File
@@ -0,0 +1,109 @@
import {
createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode,
} from 'react'
import { Button, Card, Spinner, toast } from '@heroui/react'
import { hubBotAction } from './api/client'
import { watchBotReconnect } from './lib/botAction'
/** 停止/重启 bot 的全局相位(本文件是 Provider;纯等待逻辑在 lib/botAction.ts)
*
* 为什么必须是全局的:重启入口有两个 —— 顶栏账号菜单(任何页面都能点)和
* 设置页的「进程控制」。以前两边各写一套:设置页只有**本页**的 fixed 覆盖层,
* 顶栏只弹个 toast。于是从插件页/日志页点「重启 bot」时界面照常可点,请求全
* 打在已经退出的旧进程上。现在相位收进这一个 provider,覆盖层由 AppLayout
* 统一渲染,任何页面触发都挡得住。
*/
export type BotPhase = null | 'stop' | 'restart'
interface BotActionState {
/** 非 null 表示正处于「已停止」/「等待回连」,全局覆盖层生效 */
phase: BotPhase
/** 成功回连的累计次数:页面据此在回连后重新拉数据 */
readyCount: number
/** 请求停止/重启。进入等待相位;失败原样抛出(由调用方 toast) */
run: (action: 'stop' | 'restart') => Promise<void>
/** 停止后手动收起覆盖层(重启不用,回连或超时会自动收) */
dismiss: () => void
}
const Ctx = createContext<BotActionState>({
phase: null,
readyCount: 0,
run: async () => { throw new Error('BotActionProvider 未挂载') },
dismiss: () => {},
})
export function BotActionProvider({ children }: { children: ReactNode }) {
const [phase, setPhase] = useState<BotPhase>(null)
const [readyCount, setReadyCount] = useState(0)
const reconnectRef = useRef<(() => void) | null>(null)
// 登出/布局卸载时别把等待回连的 SSE 与定时器留在外面
useEffect(() => () => { reconnectRef.current?.() }, [])
const run = useCallback(async (action: 'stop' | 'restart') => {
// 失败直接抛给调用方,这里不改相位(免得把界面锁在一个没发生的操作上)
await hubBotAction(action)
if (action === 'stop') {
// 停了就不会再回连,覆盖层留到用户点「知道了」
setPhase('stop')
return
}
setPhase('restart')
reconnectRef.current?.()
reconnectRef.current = watchBotReconnect({
onReady: () => {
reconnectRef.current = null
setPhase(null)
setReadyCount(n => n + 1)
toast.success('bot 已重启并回连')
},
onTimeout: () => {
reconnectRef.current = null
setPhase(null)
toast.danger('等待回连超时,请检查 bot 是否已启动')
},
})
}, [])
const dismiss = useCallback(() => setPhase(null), [])
return (
<Ctx.Provider value={{ phase, readyCount, run, dismiss }}>{children}</Ctx.Provider>
)
}
export const useBotAction = () => useContext(Ctx)
/** 全局覆盖层:铺满视口(含侧栏与顶栏),等待期间挡掉一切点击。
*
* 刻意用裸 div 而不是 Modal —— Modal 支持 Esc 与点遮罩关闭,而「正在重启」
* 期间放行操作恰恰是这里要防的事。
*/
export function BotActionOverlay() {
const { phase, dismiss } = useBotAction()
if (phase === null) return null
return (
<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">
{phase === 'stop' ? (
<>
<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={dismiss}>知道了</Button>
</>
) : (
<>
<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>
)
}
+10 -18
View File
@@ -8,10 +8,11 @@ import {
ArrowRotateLeft,
} from '@gravity-ui/icons'
import { useAuth } from '../auth/auth'
import { hubBotAction, hubPlugins } from '../api/client'
import { hubPlugins } from '../api/client'
import { ErrorBoundary } from '../components/ErrorBoundary'
import { useConnection } from '../connection'
import { BOT_ACTION_TEXT, watchBotReconnect } from '../lib/botAction'
import { BOT_ACTION_TEXT } from '../lib/botAction'
import { BotActionOverlay, useBotAction } from '../botAction'
interface NavItem { id: string; label: string; description: string; icon: any }
@@ -221,6 +222,9 @@ export function AppLayout() {
</div>
</main>
</div>
{/* 停止/重启 bot 的全局遮挡:放在 Outlet 之外,任何页面触发都盖得住 */}
<BotActionOverlay />
</div>
)
}
@@ -229,33 +233,21 @@ export function AppLayout() {
/** 顶栏账号菜单:头像 + 用户名点开下拉,重启/关闭 bot 与退出登录都收进去。
*
* 重启/关闭都是不可逆的破坏性操作(关掉之后得去服务器手动拉起),
* 所以先弹确认框再发请求;重启还会复用 `watchBotReconnect` 等回连。
* 所以先弹确认框再发请求;等待回连与全局覆盖层交给 `BotActionProvider`,
* 这样从**任何页面**触发都不会出现「看着没事、点哪都在打已退出的进程」。
*/
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 { run } = useBotAction()
const confirm = async () => {
const action = pending
if (!action) return
setBusy(true)
try {
await hubBotAction(action)
await run(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) }
+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>
)
}
+2 -1
View File
@@ -7,6 +7,7 @@ import { AppLayout } from './layout/AppLayout'
import { ErrorBoundary } from './components/ErrorBoundary'
import { ConnectionProvider } from './connection'
import { LogProvider } from './logs'
import { BotActionProvider } from './botAction'
import { pluginModules } from './plugins'
import HomePage from './pages/home'
import PluginsPage from './pages/plugins'
@@ -22,7 +23,7 @@ export function AppRoutes() {
<Routes>
<Route path="/" element={<Navigate to="/hub" replace />} />
<Route path="/hub/login" element={<LoginPage />} />
<Route path="/hub" element={<ConnectionProvider><LogProvider><AppLayout /></LogProvider></ConnectionProvider>}>
<Route path="/hub" element={<ConnectionProvider><LogProvider><BotActionProvider><AppLayout /></BotActionProvider></LogProvider></ConnectionProvider>}>
<Route index element={<HomePage />} />
<Route path="plugins" element={<PluginsPage />} />
<Route path="settings" element={<SettingsPage />} />
+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/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"}
{"root":["./src/app.tsx","./src/botaction.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"}