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 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<RealtimeLog[]>([]);
const [connected, setConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const callbacksRef = useRef<ConnectOptions>({});
const suppressCloseRef = useRef(false);
const wsMapRef = useRef<Map<string, WebSocket>>(new Map());
const callbacksMapRef = useRef<Map<string, ConnectOptions>>(new Map());
const suppressCloseRef = useRef<Set<string>>(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,
};
}
+102 -51
View File
@@ -245,7 +245,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
const [taskTypes, setTaskTypes] = useState<Record<string, string>>({});
const [config, setConfig] = useState<DouyuConfig | null>(null);
const [loading, setLoading] = useState(false);
const [runningBatchId, setRunningBatchId] = useState('');
const [runningBatchIds, setRunningBatchIds] = useState<Set<string>>(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<number[]>([]);
const [importLoading, setImportLoading] = useState(false);
// 绑定/支付弹窗以 task 为唯一数据源
const [qrTask, setQrTask] = useState<DouyuTaskItem | null>(null);
// 绑定/支付弹窗以 task 为唯一数据源;二维码弹窗支持多账号并行(Tabs 切换)
const [qrTaskIds, setQrTaskIds] = useState<number[]>([]);
const [activeQrTaskId, setActiveQrTaskId] = useState<number | null>(null);
const [esportsBindTask, setEsportsBindTask] = useState<DouyuTaskItem | null>(null);
const [payTask, setPayTask] = useState<DouyuTaskItem | null>(null);
const autoOpenedQrTaskIds = useRef<Set<number>>(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<number, {
@@ -378,7 +385,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
setTasks(data);
if (!data.some((task) => ['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<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 秒轻量刷新
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 })
<Space>
<Button icon={<ReloadOutlined />} onClick={loadData} loading={loading}></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>
@@ -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={
<Space>
<QrcodeOutlined />
<span>{qrBindReady ? '已识别新角色' : qrStatusText}</span>
<span>
{qrBindReady ? '已识别新角色' : qrStatusText}
{qrTaskIds.length > 1 && ` (${qrTaskIds.length})`}
</span>
</Space>
}
open={!!qrTask}
onCancel={() => setQrTask(null)}
open={qrTaskIds.length > 0}
onCancel={() => closeAllQrTasks()}
footer={null}
centered
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 && (
<div style={{ textAlign: 'center' }}>
{qrAccountName && (