From 8c6c0bcfdeb9f93e73cbe5599acc4e80ae0ebb0e Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 1 Aug 2026 11:37:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(douyu):=20=E7=B2=BE=E8=8B=B1=E5=AE=9D?= =?UTF-8?q?=E5=85=B8=E6=94=AF=E6=8C=81=E5=A4=9A=E6=89=B9=E6=AC=A1=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E4=B8=8E=E5=A4=9A=E4=BA=8C=E7=BB=B4=E7=A0=81=E5=B9=B6?= =?UTF-8?q?=E8=A1=8C=E6=89=AB=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useWebSocketLogs 改为多连接(Map),新增 connectBatch/closeBatch/closeAll,旧 connect/close 向后兼容 - DouyuTasksPage runningBatchId 单值改为 Set 集合,移除 batchBusy 全局锁,右键菜单按账号维度禁用 - 二维码弹窗支持多账号标签切换并行扫码,恢复三弹窗互斥 - connectBatch 已有连接时不清日志,修复 onerror 不更新 connected - 修复自动弹出循环覆盖 active 标签的 bug --- web/frontend/src/hooks/useWebSocketLogs.ts | 95 ++++++++----- web/frontend/src/pages/DouyuTasksPage.tsx | 153 ++++++++++++++------- 2 files changed, 162 insertions(+), 86 deletions(-) diff --git a/web/frontend/src/hooks/useWebSocketLogs.ts b/web/frontend/src/hooks/useWebSocketLogs.ts index 4065dff..40d2668 100644 --- a/web/frontend/src/hooks/useWebSocketLogs.ts +++ b/web/frontend/src/hooks/useWebSocketLogs.ts @@ -13,6 +13,7 @@ interface ConnectOptions { } const MAX_LOGS = 1000; +const DEFAULT_KEY = '__default__'; function toWebSocketUrl(pathOrUrl: string): string { if (pathOrUrl.startsWith('ws://') || pathOrUrl.startsWith('wss://')) { @@ -26,43 +27,59 @@ function toWebSocketUrl(pathOrUrl: string): string { export function useWebSocketLogs() { const [logs, setLogs] = useState([]); const [connected, setConnected] = useState(false); - const wsRef = useRef(null); - const callbacksRef = useRef({}); - const suppressCloseRef = useRef(false); + const wsMapRef = useRef>(new Map()); + const callbacksMapRef = useRef>(new Map()); + const suppressCloseRef = useRef>(new Set()); + + const syncConnected = useCallback(() => { + setConnected(wsMapRef.current.size > 0); + }, []); const clearLogs = useCallback(() => { setLogs([]); }, []); - const close = useCallback((notify = false) => { - if (!wsRef.current) { - setConnected(false); - return; - } - suppressCloseRef.current = !notify; - wsRef.current.close(); - wsRef.current = null; - setConnected(false); - }, []); + const closeBatch = useCallback((key: string, notify = false) => { + const ws = wsMapRef.current.get(key); + if (!ws) return; + if (!notify) suppressCloseRef.current.add(key); + ws.close(); + wsMapRef.current.delete(key); + syncConnected(); + }, [syncConnected]); - const connect = useCallback((pathOrUrl: string, options: ConnectOptions = {}) => { - close(false); - suppressCloseRef.current = false; - callbacksRef.current = options; - if (options.clear ?? true) { + // 向后兼容:单连接场景关闭默认通道。 + const close = useCallback((notify = false) => { + closeBatch(DEFAULT_KEY, notify); + }, [closeBatch]); + + const closeAll = useCallback((notify = false) => { + Array.from(wsMapRef.current.keys()).forEach((key) => closeBatch(key, notify)); + }, [closeBatch]); + + const connectBatch = useCallback((key: string, pathOrUrl: string, options: ConnectOptions = {}) => { + // 关闭同 key 旧连接,避免重复 + if (wsMapRef.current.has(key)) { + closeBatch(key, false); + } + suppressCloseRef.current.delete(key); + callbacksMapRef.current.set(key, options); + // 已有其他批次连接时不清空日志,避免冲掉正在进行的批次日志 + const shouldClear = (options.clear ?? true) && wsMapRef.current.size === 0; + if (shouldClear) { setLogs([]); } const ws = new WebSocket(toWebSocketUrl(pathOrUrl)); - wsRef.current = ws; - setConnected(true); + wsMapRef.current.set(key, ws); + syncConnected(); ws.onmessage = (event) => { try { const msg = JSON.parse(event.data) as RealtimeLog; if (msg.level === 'heartbeat') return; if (msg.level === 'result') { - callbacksRef.current.onResult?.(); + callbacksMapRef.current.get(key)?.onResult?.(); return; } setLogs((prev) => [...prev, msg].slice(-MAX_LOGS)); @@ -72,27 +89,32 @@ export function useWebSocketLogs() { }; ws.onclose = () => { - const isCurrent = wsRef.current === ws; - const shouldNotify = isCurrent && !suppressCloseRef.current; - if (isCurrent) { - wsRef.current = null; - setConnected(false); - suppressCloseRef.current = false; - } - if (shouldNotify) { - callbacksRef.current.onClose?.(); + // 只处理自己这个实例的关闭,避免覆盖重连后的新连接 + if (wsMapRef.current.get(key) !== ws) return; + wsMapRef.current.delete(key); + syncConnected(); + const cb = callbacksMapRef.current.get(key); + if (!suppressCloseRef.current.has(key)) { + cb?.onClose?.(); } + callbacksMapRef.current.delete(key); + suppressCloseRef.current.delete(key); }; ws.onerror = () => { - setConnected(false); - callbacksRef.current.onError?.(); + if (wsMapRef.current.get(key) === ws) setConnected(false); + callbacksMapRef.current.get(key)?.onError?.(); }; - }, [close]); + }, [closeBatch, syncConnected]); + + // 向后兼容:connect(pathOrUrl, options) 等价于 connectBatch(DEFAULT_KEY, ...) + const connect = useCallback((pathOrUrl: string, options: ConnectOptions = {}) => { + connectBatch(DEFAULT_KEY, pathOrUrl, options); + }, [connectBatch]); useEffect(() => () => { - close(false); - }, [close]); + closeAll(false); + }, [closeAll]); return { logs, @@ -100,5 +122,8 @@ export function useWebSocketLogs() { clearLogs, connect, close, + connectBatch, + closeBatch, + closeAll, }; } diff --git a/web/frontend/src/pages/DouyuTasksPage.tsx b/web/frontend/src/pages/DouyuTasksPage.tsx index 693c81b..65681fb 100644 --- a/web/frontend/src/pages/DouyuTasksPage.tsx +++ b/web/frontend/src/pages/DouyuTasksPage.tsx @@ -245,7 +245,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) const [taskTypes, setTaskTypes] = useState>({}); const [config, setConfig] = useState(null); const [loading, setLoading] = useState(false); - const [runningBatchId, setRunningBatchId] = useState(''); + const [runningBatchIds, setRunningBatchIds] = useState>(new Set()); const [configOpen, setConfigOpen] = useState(false); const [concurrency, setConcurrency] = useState(3); const [goldAmount, setGoldAmount] = useState(() => savedPositiveInteger(DOUYU_GOLD_AMOUNT_STORAGE_KEY)); @@ -263,8 +263,9 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) const [importSelectedIds, setImportSelectedIds] = useState([]); const [importLoading, setImportLoading] = useState(false); - // 绑定/支付弹窗以 task 为唯一数据源 - const [qrTask, setQrTask] = useState(null); + // 绑定/支付弹窗以 task 为唯一数据源;二维码弹窗支持多账号并行(Tabs 切换) + const [qrTaskIds, setQrTaskIds] = useState([]); + const [activeQrTaskId, setActiveQrTaskId] = useState(null); const [esportsBindTask, setEsportsBindTask] = useState(null); const [payTask, setPayTask] = useState(null); const autoOpenedQrTaskIds = useRef>(new Set()); @@ -302,6 +303,12 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) [activeTaskTypes, tasks], ); + // qrTask 由 activeQrTaskId 从最新 tasks 中派生,切换 Tab 即切换关注账号 + const qrTask = useMemo( + () => (activeQrTaskId == null ? null : tasks.find((t) => t.id === activeQrTaskId) || null), + [tasks, activeQrTaskId], + ); + // 换绑时间:优先最近一次查询/拦截任务结果,回退账号落库字段 const latestChangeWaitByAccount = useMemo(() => { const map = new Map ['pending', 'running', 'planned'].includes(task.status))) { // 批次已结束时清理 busy 标记,避免 onResult 丢失导致永久锁住 - setRunningBatchId((current) => (current ? '' : current)); + setRunningBatchIds(new Set()); } } catch { // 轮询失败不打扰操作 @@ -426,42 +433,73 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) [visibleTasks], ); + // 正在执行任务的账号集合,用于按账号维度禁用,避免同一账号并发跑不同类型任务 + const runningAccountIds = useMemo(() => { + const set = new Set(); + for (const task of visibleTasks) { + if (['pending', 'running', 'planned'].includes(task.status)) { + set.add(task.account_id); + } + } + return set; + }, [visibleTasks]); + + const selectedHasRunning = selectedIds.some((id) => runningAccountIds.has(id)); + // 有活跃任务或 WS 连接时 3 秒刷 tasks;空闲 15 秒轻量刷新 useEffect(() => { - const intervalMs = hasActiveTasks || logs.connected || runningBatchId ? 3000 : 15000; + const intervalMs = hasActiveTasks || logs.connected || runningBatchIds.size > 0 ? 3000 : 15000; const timer = setInterval(loadTasks, intervalMs); return () => clearInterval(timer); - }, [hasActiveTasks, loadTasks, logs.connected, runningBatchId]); + }, [hasActiveTasks, loadTasks, logs.connected, runningBatchIds]); - const openQrTask = useCallback((task: DouyuTaskItem) => { + const openQrTask = useCallback((task: DouyuTaskItem, activate = true) => { autoOpenedQrTaskIds.current.add(task.id); + setQrTaskIds((prev) => (prev.includes(task.id) ? prev : [...prev, task.id])); + if (activate) setActiveQrTaskId(task.id); + // 绑定二维码与支付/电竞面板互斥,避免两个 Modal 叠加混淆 setEsportsBindTask(null); setPayTask(null); - setQrTask(task); + }, []); + + const closeQrTask = useCallback((taskId: number) => { + setQrTaskIds((prev) => { + const next = prev.filter((id) => id !== taskId); + setActiveQrTaskId((cur) => (cur === taskId ? (next.length > 0 ? next[next.length - 1] : null) : cur)); + return next; + }); + }, []); + + const closeAllQrTasks = useCallback(() => { + setQrTaskIds([]); + setActiveQrTaskId(null); }, []); const openEsportsBindTask = useCallback((task: DouyuTaskItem) => { autoOpenedEsportsBindTaskIds.current.add(task.id); - setQrTask(null); + closeAllQrTasks(); setPayTask(null); setEsportsBindTask(task); - }, []); + }, [closeAllQrTasks]); const openPayTask = useCallback((task: DouyuTaskItem) => { autoOpenedPayTaskIds.current.add(task.id); - setQrTask(null); + closeAllQrTasks(); setEsportsBindTask(null); setPayTask(task); - }, []); + }, [closeAllQrTasks]); - // 自动弹出最新绑定二维码(running 有 url 即可) + // 自动弹出最新绑定二维码(running 有 url 即可);多账号并行,不互斥 useEffect(() => { - if (!autoOpenQrReady.current || qrTask) return; - const nextQrTask = visibleTasks + if (!autoOpenQrReady.current) return; + const candidates = visibleTasks .filter((task) => hasBindQrcode(task) && !autoOpenedQrTaskIds.current.has(task.id)) - .sort((a, b) => b.id - a.id)[0]; - if (nextQrTask) openQrTask(nextQrTask); - }, [openQrTask, qrTask, visibleTasks]); + .sort((a, b) => b.id - a.id); + if (candidates.length === 0) return; + // 全部加入标签栏但不抢焦点;仅在当前无 active 标签时激活最新(id 最大)的 + for (const candidate of candidates) openQrTask(candidate, false); + setActiveQrTaskId((cur) => cur ?? candidates[0].id); + }, [openQrTask, visibleTasks]); // 电竞手册先查状态再打开独立绑定面板,不把切换角色二维码当作主流程。 useEffect(() => { @@ -485,12 +523,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) if (nextPayTask) openPayTask(nextPayTask); }, [openPayTask, payTask, visibleTasks]); - // 弹窗跟随最新 task 状态(后端 progress 写入后 UI 自动更新) - useEffect(() => { - if (!qrTask) return; - const latest = tasks.find((task) => task.id === qrTask.id); - if (latest && latest !== qrTask) setQrTask(latest); - }, [qrTask, tasks]); + // qrTask 已由 useMemo 从 tasks 派生,切换 Tab / 后端 progress 写入后自动更新,无需手动跟随 useEffect(() => { if (!esportsBindTask) return; @@ -509,7 +542,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) if (latest !== payTask) setPayTask(latest); }, [payTask, tasks]); - const batchBusy = Boolean(runningBatchId || logs.connected || hasActiveTasks); + // 多批次并发:不再用全局 busy 标志阻塞 UI,刷新频率直接用各子条件判断 const startTask = async ( taskType: string, @@ -521,19 +554,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) message.warning('请先选择账号'); return; } - // 绑定确认/查询允许在其他账号仍轮询时继续,避免卡在多账号批次里 - const allowDuringBatch = [ - 'confirm_bind', - 'confirm_esports_bind', - 'query_game_name', - 'query_esports_game_name', - 'prepare_esports_bind', - 'get_esports_bind_qr', - ].includes(taskType); - if (batchBusy && !allowDuringBatch) { - message.warning('当前已有批次在运行,请先停止或等待结束'); - return; - } + // 多批次并发:不再拦截,允许在已有批次运行时继续创建新批次 if (['exchange_goods', 'exchange_esports_goods'].includes(taskType) && !selectedGoodsId) { message.warning('请先选择兑换商品'); return; @@ -551,11 +572,15 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) concurrency, payload, }); - setRunningBatchId(result.batch_id); - logs.connect(`/api/douyu/ws/${result.batch_id}`, { + setRunningBatchIds((prev) => new Set(prev).add(result.batch_id)); + logs.connectBatch(result.batch_id, `/api/douyu/ws/${result.batch_id}`, { clear: true, onResult: () => { - setRunningBatchId(''); + setRunningBatchIds((prev) => { + const next = new Set(prev); + next.delete(result.batch_id); + return next; + }); void loadData(); }, }); @@ -567,9 +592,9 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) }; const stopBatch = async () => { - if (!runningBatchId) return; + if (runningBatchIds.size === 0) return; try { - await douyuApi.stopBatch(runningBatchId); + await Promise.all(Array.from(runningBatchIds).map((id) => douyuApi.stopBatch(id))); message.success('已停止'); } catch (error) { message.error(getErrorMessage(error)); @@ -805,7 +830,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) return; } const accountId = qrTask.account_id; - setQrTask(null); + const taskId = qrTask.id; + closeQrTask(taskId); void startTask('confirm_bind', [accountId]); }; @@ -1070,9 +1096,10 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) const selectedText = selectedIds.length > 0 ? `已选 ${selectedIds.length} 个账号` : '请先勾选账号'; const actionByKey = useMemo(() => new Map(quickActions.map((item) => [item.key, item])), [quickActions]); const actionDisabled = (taskType: string) => { - if (batchBusy || selectedIds.length === 0) return true; + if (selectedIds.length === 0) return true; if (['exchange_goods', 'exchange_esports_goods'].includes(taskType)) return !selectedGoodsId; - return false; + // 选中账号中有正在执行的任务时禁用,避免同账号并发不同类型任务(并发写状态字段) + return selectedHasRunning; }; const renderActionButton = (taskType: string, type: 'default' | 'primary' = 'default') => { const item = actionByKey.get(taskType); @@ -1184,7 +1211,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) {canConfig && } - {runningBatchId && } + {runningBatchIds.size > 0 && } @@ -1456,7 +1483,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) key={item.key} block size="small" type="text" icon={item.icon} onClick={() => runContextAction(item.key)} - disabled={batchBusy} + disabled={contextMenu.accountIds.some((id) => runningAccountIds.has(id))} style={{ justifyContent: 'flex-start' }} > {taskTypes[item.key] || item.key} @@ -1657,15 +1684,39 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) title={ - {qrBindReady ? '已识别新角色' : qrStatusText} + + {qrBindReady ? '已识别新角色' : qrStatusText} + {qrTaskIds.length > 1 && ` (${qrTaskIds.length})`} + } - open={!!qrTask} - onCancel={() => setQrTask(null)} + open={qrTaskIds.length > 0} + onCancel={() => closeAllQrTasks()} footer={null} centered width={420} > + {qrTaskIds.length > 1 && ( + + {qrTaskIds.map((id) => { + const t = tasks.find((x) => x.id === id); + if (!t) return null; + const name = t.account_nickname || t.account_username || t.account_uid || `#${t.account_id}`; + return ( + { e.preventDefault(); closeQrTask(id); }} + onClick={() => setActiveQrTaskId(id)} + > + {name} + + ); + })} + + )} {qrTask && (
{qrAccountName && (