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