From 023dc1cb5de35adc5cdcb64b999ad21c9a86d520 Mon Sep 17 00:00:00 2001 From: sans Date: Wed, 23 Sep 2026 18:47:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E5=81=9C=E6=AD=A2/=E9=87=8D?= =?UTF-8?q?=E5=90=AF=20bot=20=E7=9A=84=E7=AD=89=E5=BE=85=E7=9B=B8=E4=BD=8D?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E5=8C=96=20/=20=E8=A6=86=E7=9B=96=E5=B1=82?= =?UTF-8?q?=E6=94=B6=E8=BF=9B=20BotActionProvider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重启入口有两个 —— 顶栏账号菜单(任何页面可点)和设置页「进程控制」。以前两边 各写一套:设置页只有本页的 fixed 覆盖层,顶栏只弹个 toast。于是从插件页/日志页 点「重启 bot」时界面照常可点,请求全打在已经退出的旧进程上。 - 新增 src/botAction.tsx:Provider 持 phase(null / stop / restart)与 readyCount, 覆盖层由 AppLayout 在 之外统一渲染,任何页面触发都盖得住; 纯等待逻辑仍在 lib/botAction.ts,两边共用一份 - 设置页删掉本地的 restarting 态与两份覆盖层,只负责发起 + 用 phase 禁用按钮; 改为消费 readyCount 在回连后重新拉数据(停机期间配置是旧的) - 未挂载 Provider 时 run() 直接抛错,避免静默无覆盖层 Co-Authored-By: Claude Code --- hexi/web/src/botAction.tsx | 109 ++++++++++++++++++++++++++ hexi/web/src/layout/AppLayout.tsx | 28 +++---- hexi/web/src/pages/settings/index.tsx | 68 ++++------------ hexi/web/src/router.tsx | 3 +- hexi/web/tsconfig.tsbuildinfo | 2 +- 5 files changed, 137 insertions(+), 73 deletions(-) create mode 100644 hexi/web/src/botAction.tsx diff --git a/hexi/web/src/botAction.tsx b/hexi/web/src/botAction.tsx new file mode 100644 index 0000000..3f0d06a --- /dev/null +++ b/hexi/web/src/botAction.tsx @@ -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 + /** 停止后手动收起覆盖层(重启不用,回连或超时会自动收) */ + dismiss: () => void +} + +const Ctx = createContext({ + phase: null, + readyCount: 0, + run: async () => { throw new Error('BotActionProvider 未挂载') }, + dismiss: () => {}, +}) + +export function BotActionProvider({ children }: { children: ReactNode }) { + const [phase, setPhase] = useState(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 ( + {children} + ) +} + +export const useBotAction = () => useContext(Ctx) + +/** 全局覆盖层:铺满视口(含侧栏与顶栏),等待期间挡掉一切点击。 + * + * 刻意用裸 div 而不是 Modal —— Modal 支持 Esc 与点遮罩关闭,而「正在重启」 + * 期间放行操作恰恰是这里要防的事。 + */ +export function BotActionOverlay() { + const { phase, dismiss } = useBotAction() + if (phase === null) return null + return ( +
+ + + {phase === 'stop' ? ( + <> +
正在停止 bot…
+

Web 与 QQ 连接已断开,请在服务器重新启动 bot。

+ + + ) : ( + <> +
正在重启 bot,等待回连…
+

期间已禁用所有操作,恢复后自动继续。

+
+ + )} +
+
+
+ ) +} diff --git a/hexi/web/src/layout/AppLayout.tsx b/hexi/web/src/layout/AppLayout.tsx index 5e0fe39..852ce0a 100644 --- a/hexi/web/src/layout/AppLayout.tsx +++ b/hexi/web/src/layout/AppLayout.tsx @@ -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() { + + {/* 停止/重启 bot 的全局遮挡:放在 Outlet 之外,任何页面触发都盖得住 */} + ) } @@ -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) } diff --git a/hexi/web/src/pages/settings/index.tsx b/hexi/web/src/pages/settings/index.tsx index 546bb98..4ccad4f 100644 --- a/hexi/web/src/pages/settings/index.tsx +++ b/hexi/web/src/pages/settings/index.tsx @@ -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) 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(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() { setOldPassword(e.target.value)} /> setNewPassword(e.target.value)} placeholder="至少 6 位" /> setConfirm(e.target.value)} /> - +

修改后旧登录态失效,当前会话保持在线。

@@ -224,7 +205,7 @@ export default function SettingsPage() {
- + {!cfgLoaded && 配置未加载} {botCfg?.source === 'env' && 当前来源:.env}
@@ -236,8 +217,8 @@ export default function SettingsPage() {

停止 / 重启当前 bot 进程。停止会断开 Web 与 QQ 连接;重启会先拉起新进程再退出旧进程,日志继续写入 _bot_run.log。重启过程中界面会禁用操作并等待回连。

- - + +
@@ -262,25 +243,6 @@ export default function SettingsPage() { - - {restarting === 'stop' && ( -
- -
正在停止 bot…
-

Web 与 QQ 连接已断开,请在服务器重新启动 bot。

- -
-
- )} - {restarting === 'restart' && ( -
- -
正在重启 bot,等待回连…
-

期间已禁用所有操作,恢复后自动继续。

-
-
-
- )} ) } diff --git a/hexi/web/src/router.tsx b/hexi/web/src/router.tsx index a757e2c..fa733ae 100644 --- a/hexi/web/src/router.tsx +++ b/hexi/web/src/router.tsx @@ -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() { } /> } /> - }> + }> } /> } /> } /> diff --git a/hexi/web/tsconfig.tsbuildinfo b/hexi/web/tsconfig.tsbuildinfo index d9b48ab..78b718d 100644 --- a/hexi/web/tsconfig.tsbuildinfo +++ b/hexi/web/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file