feat(douyu): 精英宝典支持多批次并发与多二维码并行扫码
- useWebSocketLogs 改为多连接(Map),新增 connectBatch/closeBatch/closeAll,旧 connect/close 向后兼容 - DouyuTasksPage runningBatchId 单值改为 Set 集合,移除 batchBusy 全局锁,右键菜单按账号维度禁用 - 二维码弹窗支持多账号标签切换并行扫码,恢复三弹窗互斥 - connectBatch 已有连接时不清日志,修复 onerror 不更新 connected - 修复自动弹出循环覆盖 active 标签的 bug
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user