feat(douyu): 精英宝典支持多批次并发与多二维码并行扫码

- useWebSocketLogs 改为多连接(Map),新增 connectBatch/closeBatch/closeAll,旧 connect/close 向后兼容
- DouyuTasksPage runningBatchId 单值改为 Set 集合,移除 batchBusy 全局锁,右键菜单按账号维度禁用
- 二维码弹窗支持多账号标签切换并行扫码,恢复三弹窗互斥
- connectBatch 已有连接时不清日志,修复 onerror 不更新 connected
- 修复自动弹出循环覆盖 active 标签的 bug
This commit is contained in:
yml2213
2026-08-01 11:37:53 +08:00
parent dbf78b825c
commit 8c6c0bcfde
2 changed files with 162 additions and 86 deletions
+60 -35
View File
@@ -13,6 +13,7 @@ interface ConnectOptions {
} }
const MAX_LOGS = 1000; const MAX_LOGS = 1000;
const DEFAULT_KEY = '__default__';
function toWebSocketUrl(pathOrUrl: string): string { function toWebSocketUrl(pathOrUrl: string): string {
if (pathOrUrl.startsWith('ws://') || pathOrUrl.startsWith('wss://')) { if (pathOrUrl.startsWith('ws://') || pathOrUrl.startsWith('wss://')) {
@@ -26,43 +27,59 @@ function toWebSocketUrl(pathOrUrl: string): string {
export function useWebSocketLogs() { export function useWebSocketLogs() {
const [logs, setLogs] = useState<RealtimeLog[]>([]); const [logs, setLogs] = useState<RealtimeLog[]>([]);
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null); const wsMapRef = useRef<Map<string, WebSocket>>(new Map());
const callbacksRef = useRef<ConnectOptions>({}); const callbacksMapRef = useRef<Map<string, ConnectOptions>>(new Map());
const suppressCloseRef = useRef(false); const suppressCloseRef = useRef<Set<string>>(new Set());
const syncConnected = useCallback(() => {
setConnected(wsMapRef.current.size > 0);
}, []);
const clearLogs = useCallback(() => { const clearLogs = useCallback(() => {
setLogs([]); setLogs([]);
}, []); }, []);
const close = useCallback((notify = false) => { const closeBatch = useCallback((key: string, notify = false) => {
if (!wsRef.current) { const ws = wsMapRef.current.get(key);
setConnected(false); if (!ws) return;
return; if (!notify) suppressCloseRef.current.add(key);
} ws.close();
suppressCloseRef.current = !notify; wsMapRef.current.delete(key);
wsRef.current.close(); syncConnected();
wsRef.current = null; }, [syncConnected]);
setConnected(false);
}, []);
const connect = useCallback((pathOrUrl: string, options: ConnectOptions = {}) => { // 向后兼容:单连接场景关闭默认通道。
close(false); const close = useCallback((notify = false) => {
suppressCloseRef.current = false; closeBatch(DEFAULT_KEY, notify);
callbacksRef.current = options; }, [closeBatch]);
if (options.clear ?? true) {
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([]); setLogs([]);
} }
const ws = new WebSocket(toWebSocketUrl(pathOrUrl)); const ws = new WebSocket(toWebSocketUrl(pathOrUrl));
wsRef.current = ws; wsMapRef.current.set(key, ws);
setConnected(true); syncConnected();
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
const msg = JSON.parse(event.data) as RealtimeLog; const msg = JSON.parse(event.data) as RealtimeLog;
if (msg.level === 'heartbeat') return; if (msg.level === 'heartbeat') return;
if (msg.level === 'result') { if (msg.level === 'result') {
callbacksRef.current.onResult?.(); callbacksMapRef.current.get(key)?.onResult?.();
return; return;
} }
setLogs((prev) => [...prev, msg].slice(-MAX_LOGS)); setLogs((prev) => [...prev, msg].slice(-MAX_LOGS));
@@ -72,27 +89,32 @@ export function useWebSocketLogs() {
}; };
ws.onclose = () => { ws.onclose = () => {
const isCurrent = wsRef.current === ws; // 只处理自己这个实例的关闭,避免覆盖重连后的新连接
const shouldNotify = isCurrent && !suppressCloseRef.current; if (wsMapRef.current.get(key) !== ws) return;
if (isCurrent) { wsMapRef.current.delete(key);
wsRef.current = null; syncConnected();
setConnected(false); const cb = callbacksMapRef.current.get(key);
suppressCloseRef.current = false; if (!suppressCloseRef.current.has(key)) {
} cb?.onClose?.();
if (shouldNotify) {
callbacksRef.current.onClose?.();
} }
callbacksMapRef.current.delete(key);
suppressCloseRef.current.delete(key);
}; };
ws.onerror = () => { ws.onerror = () => {
setConnected(false); if (wsMapRef.current.get(key) === ws) setConnected(false);
callbacksRef.current.onError?.(); 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(() => () => { useEffect(() => () => {
close(false); closeAll(false);
}, [close]); }, [closeAll]);
return { return {
logs, logs,
@@ -100,5 +122,8 @@ export function useWebSocketLogs() {
clearLogs, clearLogs,
connect, connect,
close, close,
connectBatch,
closeBatch,
closeAll,
}; };
} }
+102 -51
View File
@@ -245,7 +245,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
const [taskTypes, setTaskTypes] = useState<Record<string, string>>({}); const [taskTypes, setTaskTypes] = useState<Record<string, string>>({});
const [config, setConfig] = useState<DouyuConfig | null>(null); const [config, setConfig] = useState<DouyuConfig | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [runningBatchId, setRunningBatchId] = useState(''); const [runningBatchIds, setRunningBatchIds] = useState<Set<string>>(new Set());
const [configOpen, setConfigOpen] = useState(false); const [configOpen, setConfigOpen] = useState(false);
const [concurrency, setConcurrency] = useState(3); const [concurrency, setConcurrency] = useState(3);
const [goldAmount, setGoldAmount] = useState(() => savedPositiveInteger(DOUYU_GOLD_AMOUNT_STORAGE_KEY)); 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<number[]>([]); const [importSelectedIds, setImportSelectedIds] = useState<number[]>([]);
const [importLoading, setImportLoading] = useState(false); const [importLoading, setImportLoading] = useState(false);
// 绑定/支付弹窗以 task 为唯一数据源 // 绑定/支付弹窗以 task 为唯一数据源;二维码弹窗支持多账号并行(Tabs 切换)
const [qrTask, setQrTask] = useState<DouyuTaskItem | null>(null); const [qrTaskIds, setQrTaskIds] = useState<number[]>([]);
const [activeQrTaskId, setActiveQrTaskId] = useState<number | null>(null);
const [esportsBindTask, setEsportsBindTask] = useState<DouyuTaskItem | null>(null); const [esportsBindTask, setEsportsBindTask] = useState<DouyuTaskItem | null>(null);
const [payTask, setPayTask] = useState<DouyuTaskItem | null>(null); const [payTask, setPayTask] = useState<DouyuTaskItem | null>(null);
const autoOpenedQrTaskIds = useRef<Set<number>>(new Set()); const autoOpenedQrTaskIds = useRef<Set<number>>(new Set());
@@ -302,6 +303,12 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
[activeTaskTypes, tasks], [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 latestChangeWaitByAccount = useMemo(() => {
const map = new Map<number, { const map = new Map<number, {
@@ -378,7 +385,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
setTasks(data); setTasks(data);
if (!data.some((task) => ['pending', 'running', 'planned'].includes(task.status))) { if (!data.some((task) => ['pending', 'running', 'planned'].includes(task.status))) {
// 批次已结束时清理 busy 标记,避免 onResult 丢失导致永久锁住 // 批次已结束时清理 busy 标记,避免 onResult 丢失导致永久锁住
setRunningBatchId((current) => (current ? '' : current)); setRunningBatchIds(new Set());
} }
} catch { } catch {
// 轮询失败不打扰操作 // 轮询失败不打扰操作
@@ -426,42 +433,73 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
[visibleTasks], [visibleTasks],
); );
// 正在执行任务的账号集合,用于按账号维度禁用,避免同一账号并发跑不同类型任务
const runningAccountIds = useMemo(() => {
const set = new Set<number>();
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 秒轻量刷新 // 有活跃任务或 WS 连接时 3 秒刷 tasks;空闲 15 秒轻量刷新
useEffect(() => { useEffect(() => {
const intervalMs = hasActiveTasks || logs.connected || runningBatchId ? 3000 : 15000; const intervalMs = hasActiveTasks || logs.connected || runningBatchIds.size > 0 ? 3000 : 15000;
const timer = setInterval(loadTasks, intervalMs); const timer = setInterval(loadTasks, intervalMs);
return () => clearInterval(timer); 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); autoOpenedQrTaskIds.current.add(task.id);
setQrTaskIds((prev) => (prev.includes(task.id) ? prev : [...prev, task.id]));
if (activate) setActiveQrTaskId(task.id);
// 绑定二维码与支付/电竞面板互斥,避免两个 Modal 叠加混淆
setEsportsBindTask(null); setEsportsBindTask(null);
setPayTask(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) => { const openEsportsBindTask = useCallback((task: DouyuTaskItem) => {
autoOpenedEsportsBindTaskIds.current.add(task.id); autoOpenedEsportsBindTaskIds.current.add(task.id);
setQrTask(null); closeAllQrTasks();
setPayTask(null); setPayTask(null);
setEsportsBindTask(task); setEsportsBindTask(task);
}, []); }, [closeAllQrTasks]);
const openPayTask = useCallback((task: DouyuTaskItem) => { const openPayTask = useCallback((task: DouyuTaskItem) => {
autoOpenedPayTaskIds.current.add(task.id); autoOpenedPayTaskIds.current.add(task.id);
setQrTask(null); closeAllQrTasks();
setEsportsBindTask(null); setEsportsBindTask(null);
setPayTask(task); setPayTask(task);
}, []); }, [closeAllQrTasks]);
// 自动弹出最新绑定二维码(running 有 url 即可) // 自动弹出最新绑定二维码(running 有 url 即可);多账号并行,不互斥
useEffect(() => { useEffect(() => {
if (!autoOpenQrReady.current || qrTask) return; if (!autoOpenQrReady.current) return;
const nextQrTask = visibleTasks const candidates = visibleTasks
.filter((task) => hasBindQrcode(task) && !autoOpenedQrTaskIds.current.has(task.id)) .filter((task) => hasBindQrcode(task) && !autoOpenedQrTaskIds.current.has(task.id))
.sort((a, b) => b.id - a.id)[0]; .sort((a, b) => b.id - a.id);
if (nextQrTask) openQrTask(nextQrTask); if (candidates.length === 0) return;
}, [openQrTask, qrTask, visibleTasks]); // 全部加入标签栏但不抢焦点;仅在当前无 active 标签时激活最新(id 最大)的
for (const candidate of candidates) openQrTask(candidate, false);
setActiveQrTaskId((cur) => cur ?? candidates[0].id);
}, [openQrTask, visibleTasks]);
// 电竞手册先查状态再打开独立绑定面板,不把切换角色二维码当作主流程。 // 电竞手册先查状态再打开独立绑定面板,不把切换角色二维码当作主流程。
useEffect(() => { useEffect(() => {
@@ -485,12 +523,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
if (nextPayTask) openPayTask(nextPayTask); if (nextPayTask) openPayTask(nextPayTask);
}, [openPayTask, payTask, visibleTasks]); }, [openPayTask, payTask, visibleTasks]);
// 弹窗跟随最新 task 状态(后端 progress 写入后 UI 自动更新) // qrTask 已由 useMemo 从 tasks 派生,切换 Tab / 后端 progress 写入后自动更新,无需手动跟随
useEffect(() => {
if (!qrTask) return;
const latest = tasks.find((task) => task.id === qrTask.id);
if (latest && latest !== qrTask) setQrTask(latest);
}, [qrTask, tasks]);
useEffect(() => { useEffect(() => {
if (!esportsBindTask) return; if (!esportsBindTask) return;
@@ -509,7 +542,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
if (latest !== payTask) setPayTask(latest); if (latest !== payTask) setPayTask(latest);
}, [payTask, tasks]); }, [payTask, tasks]);
const batchBusy = Boolean(runningBatchId || logs.connected || hasActiveTasks); // 多批次并发:不再用全局 busy 标志阻塞 UI,刷新频率直接用各子条件判断
const startTask = async ( const startTask = async (
taskType: string, taskType: string,
@@ -521,19 +554,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
message.warning('请先选择账号'); message.warning('请先选择账号');
return; 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) { if (['exchange_goods', 'exchange_esports_goods'].includes(taskType) && !selectedGoodsId) {
message.warning('请先选择兑换商品'); message.warning('请先选择兑换商品');
return; return;
@@ -551,11 +572,15 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
concurrency, concurrency,
payload, payload,
}); });
setRunningBatchId(result.batch_id); setRunningBatchIds((prev) => new Set(prev).add(result.batch_id));
logs.connect(`/api/douyu/ws/${result.batch_id}`, { logs.connectBatch(result.batch_id, `/api/douyu/ws/${result.batch_id}`, {
clear: true, clear: true,
onResult: () => { onResult: () => {
setRunningBatchId(''); setRunningBatchIds((prev) => {
const next = new Set(prev);
next.delete(result.batch_id);
return next;
});
void loadData(); void loadData();
}, },
}); });
@@ -567,9 +592,9 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
}; };
const stopBatch = async () => { const stopBatch = async () => {
if (!runningBatchId) return; if (runningBatchIds.size === 0) return;
try { try {
await douyuApi.stopBatch(runningBatchId); await Promise.all(Array.from(runningBatchIds).map((id) => douyuApi.stopBatch(id)));
message.success('已停止'); message.success('已停止');
} catch (error) { } catch (error) {
message.error(getErrorMessage(error)); message.error(getErrorMessage(error));
@@ -805,7 +830,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
return; return;
} }
const accountId = qrTask.account_id; const accountId = qrTask.account_id;
setQrTask(null); const taskId = qrTask.id;
closeQrTask(taskId);
void startTask('confirm_bind', [accountId]); void startTask('confirm_bind', [accountId]);
}; };
@@ -1070,9 +1096,10 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
const selectedText = selectedIds.length > 0 ? `已选 ${selectedIds.length} 个账号` : '请先勾选账号'; const selectedText = selectedIds.length > 0 ? `已选 ${selectedIds.length} 个账号` : '请先勾选账号';
const actionByKey = useMemo(() => new Map(quickActions.map((item) => [item.key, item])), [quickActions]); const actionByKey = useMemo(() => new Map(quickActions.map((item) => [item.key, item])), [quickActions]);
const actionDisabled = (taskType: string) => { 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; if (['exchange_goods', 'exchange_esports_goods'].includes(taskType)) return !selectedGoodsId;
return false; // 选中账号中有正在执行的任务时禁用,避免同账号并发不同类型任务(并发写状态字段)
return selectedHasRunning;
}; };
const renderActionButton = (taskType: string, type: 'default' | 'primary' = 'default') => { const renderActionButton = (taskType: string, type: 'default' | 'primary' = 'default') => {
const item = actionByKey.get(taskType); const item = actionByKey.get(taskType);
@@ -1184,7 +1211,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
<Space> <Space>
<Button icon={<ReloadOutlined />} onClick={loadData} loading={loading}></Button> <Button icon={<ReloadOutlined />} onClick={loadData} loading={loading}></Button>
{canConfig && <Button icon={<SettingOutlined />} onClick={() => setConfigOpen(true)}></Button>} {canConfig && <Button icon={<SettingOutlined />} onClick={() => setConfigOpen(true)}></Button>}
{runningBatchId && <Button danger icon={<StopOutlined />} onClick={stopBatch}></Button>} {runningBatchIds.size > 0 && <Button danger icon={<StopOutlined />} onClick={stopBatch}></Button>}
</Space> </Space>
</Space> </Space>
@@ -1456,7 +1483,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
key={item.key} block size="small" type="text" key={item.key} block size="small" type="text"
icon={item.icon} icon={item.icon}
onClick={() => runContextAction(item.key)} onClick={() => runContextAction(item.key)}
disabled={batchBusy} disabled={contextMenu.accountIds.some((id) => runningAccountIds.has(id))}
style={{ justifyContent: 'flex-start' }} style={{ justifyContent: 'flex-start' }}
> >
{taskTypes[item.key] || item.key} {taskTypes[item.key] || item.key}
@@ -1657,15 +1684,39 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
title={ title={
<Space> <Space>
<QrcodeOutlined /> <QrcodeOutlined />
<span>{qrBindReady ? '已识别新角色' : qrStatusText}</span> <span>
{qrBindReady ? '已识别新角色' : qrStatusText}
{qrTaskIds.length > 1 && ` (${qrTaskIds.length})`}
</span>
</Space> </Space>
} }
open={!!qrTask} open={qrTaskIds.length > 0}
onCancel={() => setQrTask(null)} onCancel={() => closeAllQrTasks()}
footer={null} footer={null}
centered centered
width={420} width={420}
> >
{qrTaskIds.length > 1 && (
<Space size={4} wrap style={{ marginBottom: 12, justifyContent: 'center' }}>
{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 (
<Tag
key={id}
color={id === activeQrTaskId ? 'blue' : 'default'}
style={{ cursor: 'pointer' }}
closable
onClose={(e) => { e.preventDefault(); closeQrTask(id); }}
onClick={() => setActiveQrTaskId(id)}
>
{name}
</Tag>
);
})}
</Space>
)}
{qrTask && ( {qrTask && (
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
{qrAccountName && ( {qrAccountName && (