From 2fda47c631de34f57ccfbb95428dce48c8af325d Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 5 Aug 2026 10:03:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BB=BB=E5=8A=A1=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=80=A7=E8=83=BD=E5=92=8C=E6=9C=AC=E5=9C=B0=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E4=BB=A3=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dev.sh | 20 ++++- web/backend/main.py | 7 ++ .../20260805_0015_task_status_indexes.py | 47 +++++++++++ web/backend/routers/douyu.py | 79 ++++++++++++++++++- web/backend/routers/huya.py | 27 +++++++ web/backend/routers/login.py | 36 ++++++++- web/frontend/src/api/huya.ts | 2 + web/frontend/src/api/login.ts | 3 +- web/frontend/src/api/types.ts | 7 ++ web/frontend/src/pages/DashboardPage.tsx | 26 +++--- web/frontend/src/pages/DouyuTasksPage.tsx | 5 ++ web/frontend/src/pages/HuyaTasksPage.tsx | 5 ++ web/frontend/src/pages/LoginTasksPage.tsx | 17 +++- 13 files changed, 252 insertions(+), 29 deletions(-) create mode 100644 web/backend/migrations/versions/20260805_0015_task_status_indexes.py diff --git a/dev.sh b/dev.sh index 0e1bc5a..fc447df 100755 --- a/dev.sh +++ b/dev.sh @@ -79,6 +79,20 @@ detect_compose() { fi } +detect_backend_proxy_host() { + if [ "$BACKEND_HOST" != "0.0.0.0" ] && [ "$BACKEND_HOST" != "::" ]; then + echo "$BACKEND_HOST" + return + fi + # 绑定 0.0.0.0 时,Vite 代理不要固定连 127.0.0.1;某些本地工具会抢占 loopback 端口。 + local ip + ip="$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || true)" + if [ -z "$ip" ] && command -v hostname >/dev/null 2>&1; then + ip="$(hostname -I 2>/dev/null | awk '{print $1}' || true)" + fi + echo "${ip:-127.0.0.1}" +} + if [ -n "${DATABASE_URL:-}" ] && [[ "$DATABASE_URL" != mysql+* ]]; then echo "dev.sh 只支持 MySQL,请移除 SQLite DATABASE_URL: $DATABASE_URL" exit 1 @@ -99,6 +113,8 @@ if [ -z "$COMPOSE" ]; then echo "缺少 docker compose" exit 1 fi +BACKEND_PROXY_HOST="${BACKEND_PROXY_HOST:-$(detect_backend_proxy_host)}" +BACKEND_PROXY_TARGET="${VITE_BACKEND_TARGET:-http://${BACKEND_PROXY_HOST}:${BACKEND_PORT}}" mkdir -p data logs @@ -128,7 +144,7 @@ echo " 本地调试模式" echo "==============================" echo " 后端: http://${BACKEND_HOST}:${BACKEND_PORT}" echo " 前端: http://localhost:${FRONTEND_PORT}" -echo " API 代理: http://127.0.0.1:${BACKEND_PORT}" +echo " API 代理: ${BACKEND_PROXY_TARGET}" echo " MySQL: ${DB_HOST}:${DB_PORT}/${DB_NAME}" echo " 退出: Ctrl+C" echo "==============================" @@ -157,7 +173,7 @@ BACKEND_PID=$! ( cd web/frontend - VITE_BACKEND_TARGET="http://127.0.0.1:${BACKEND_PORT}" \ + VITE_BACKEND_TARGET="$BACKEND_PROXY_TARGET" \ npm run dev -- --host "$FRONTEND_HOST" --port "$FRONTEND_PORT" ) & FRONTEND_PID=$! diff --git a/web/backend/main.py b/web/backend/main.py index e9b90a6..53bb64e 100644 --- a/web/backend/main.py +++ b/web/backend/main.py @@ -6,6 +6,7 @@ import uvicorn from contextlib import asynccontextmanager from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse from starlette.middleware.base import BaseHTTPMiddleware @@ -66,6 +67,9 @@ app.add_middleware( allow_headers=["*"], ) +# 压缩 JSON 和静态资源响应,避免任务列表轮询反复传输大体积文本。 +app.add_middleware(GZipMiddleware, minimum_size=1024) + # 安全响应头中间件 class SecurityHeadersMiddleware(BaseHTTPMiddleware): @@ -74,6 +78,9 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + if request.url.path.startswith("/assets/"): + # Vite 产物文件名带 hash,可以长期缓存。 + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" return response diff --git a/web/backend/migrations/versions/20260805_0015_task_status_indexes.py b/web/backend/migrations/versions/20260805_0015_task_status_indexes.py new file mode 100644 index 0000000..4e81c52 --- /dev/null +++ b/web/backend/migrations/versions/20260805_0015_task_status_indexes.py @@ -0,0 +1,47 @@ +"""补充任务状态清理索引 + +Revision ID: 20260805_0015 +Revises: 20260728_0014 +Create Date: 2026-08-05 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260805_0015" +down_revision: Union[str, None] = "20260728_0014" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +INDEXES = [ + ("ix_douyu_tasks_status_id", "douyu_tasks", ["status", "id"]), + ("ix_huya_tasks_status_id", "huya_tasks", ["status", "id"]), +] + + +def _has_table(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _indexes(bind, table_name: str) -> set[str]: + if not _has_table(bind, table_name): + return set() + return {index["name"] for index in sa.inspect(bind).get_indexes(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + for name, table_name, columns in INDEXES: + if name not in _indexes(bind, table_name): + op.create_index(name, table_name, columns) + + +def downgrade() -> None: + bind = op.get_bind() + for name, table_name, _ in reversed(INDEXES): + if name in _indexes(bind, table_name): + op.drop_index(name, table_name=table_name) diff --git a/web/backend/routers/douyu.py b/web/backend/routers/douyu.py index 63aae54..639b099 100644 --- a/web/backend/routers/douyu.py +++ b/web/backend/routers/douyu.py @@ -118,7 +118,73 @@ def _account_out(account: Account) -> DouyuTaskAccountOut: ) -def _task_out(task: DouyuTask) -> DouyuTaskOut: +def _slim_goods(goods: object) -> object: + """保留兑换图片和列表展示需要的商品小字段。""" + if not isinstance(goods, dict): + return None + return { + key: value + for key, value in goods.items() + if key in {"commodityId", "commodity_id", "commodityName", "name", "webPic", "pic", "score", "status"} + } + + +def _slim_limited_goods(goods: object) -> list[dict[str, object]]: + """限兑列表只返回前几个商品名,避免任务列表携带完整原始数组。""" + if not isinstance(goods, list): + return [] + result: list[dict[str, object]] = [] + for item in goods[:5]: + if not isinstance(item, dict): + continue + result.append({ + key: value + for key, value in item.items() + if key in {"commodityId", "commodity_id", "commodityName", "name"} + }) + return result + + +def _sanitize_task_result(result: dict | None, task_type: str, *, include_detail: bool = False) -> dict | None: + """列表接口剥离原始快照/大数组;详情接口保留完整 result。""" + if not isinstance(result, dict): + return result + if include_detail: + return result + + data = dict(result) + for key in ( + "raw", + "bind_info", + "before_bind_info", + "bind_candidates", + "cooldown_bind_info", + "after_bind_info", + "activity_bind_snapshot", + "points_query", + "exchange_balance_query", + "query_act_aliases", + "records", + ): + data.pop(key, None) + + goods = data.get("goods") + if isinstance(goods, list): + data.pop("goods", None) + elif isinstance(goods, dict): + data["goods"] = _slim_goods(goods) + + if "limited_goods" in data: + data["limited_goods"] = _slim_limited_goods(data.get("limited_goods")) + + if task_type not in {"get_bind_qr", "prepare_esports_bind", "get_esports_bind_qr"}: + data.pop("url", None) + if task_type not in {"create_elite_qr", "create_esports_qr", "create_gold_qr"}: + data.pop("pay_url", None) + return data + + +def _task_out(task: DouyuTask, *, include_detail: bool = False) -> DouyuTaskOut: account = task.account return DouyuTaskOut( id=task.id, @@ -130,7 +196,11 @@ def _task_out(task: DouyuTask) -> DouyuTaskOut: task_type=task.task_type, status=task.status or "", message=task.message or "", - result=task.result if isinstance(task.result, dict) else None, + result=_sanitize_task_result( + task.result if isinstance(task.result, dict) else None, + task.task_type or "", + include_detail=include_detail, + ), created_by=task.created_by, created_at=task.created_at, finished_at=task.finished_at, @@ -299,6 +369,7 @@ async def create_task_batch( @router.get("/tasks", response_model=list[DouyuTaskOut]) def list_tasks( batch_id: str | None = None, + include_detail: bool = Query(False, description="是否返回完整任务结果(默认否,轮询请保持 false)"), db: Session = Depends(get_db), current: User = Depends(require_permission("douyu:task")), ): @@ -313,7 +384,7 @@ def list_tasks( if batch_id: query = query.filter(DouyuTask.batch_id == batch_id) rows = query.order_by(DouyuTask.id.desc()).limit(300).all() - return [_task_out(task) for task in rows] + return [_task_out(task, include_detail=include_detail) for task in rows] @router.get("/tasks/{task_id}", response_model=DouyuTaskOut) @@ -326,7 +397,7 @@ def get_task( task = _visible_tasks_query(db, current).filter(DouyuTask.id == task_id).first() if not task: raise HTTPException(status_code=404, detail="任务不存在") - return _task_out(task) + return _task_out(task, include_detail=True) @router.post("/stop/{batch_id}") diff --git a/web/backend/routers/huya.py b/web/backend/routers/huya.py index 66d0c31..9a86481 100644 --- a/web/backend/routers/huya.py +++ b/web/backend/routers/huya.py @@ -208,6 +208,24 @@ def _visible_huya_tasks_query(db: Session, current: User): raise HTTPException(status_code=403, detail="无权查看虎牙任务") +def _huya_task_summary(query): + """按状态汇总虎牙任务,避免概览页拉完整任务列表。""" + rows = ( + query.enable_eagerloads(False) + .order_by(None) + .with_entities(HuyaTask.status, func.count(HuyaTask.id)) + .group_by(HuyaTask.status) + .all() + ) + status_counts = {status or "": count for status, count in rows} + return { + "total": sum(status_counts.values()), + "success": status_counts.get("success", 0), + "failed": sum(status_counts.get(status, 0) for status in ("failed", "error")), + "status_counts": status_counts, + } + + def _require_huya_task_account_access(db: Session, current: User, account_ids: list[int]) -> None: """确保任务只会提交到当前用户可操作的虎牙账号。""" requested_ids = set(account_ids) @@ -1379,6 +1397,15 @@ def list_tasks( return [_task_out(task, include_images=include_images) for task in tasks] +@router.get("/tasks/summary") +def tasks_summary( + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:task")), +): + """虎牙任务统计。""" + return _huya_task_summary(_visible_huya_tasks_query(db, current)) + + @router.get("/tasks/{task_id}", response_model=HuyaTaskOut) def get_task( task_id: int, diff --git a/web/backend/routers/login.py b/web/backend/routers/login.py index 9e390ab..ab276e6 100644 --- a/web/backend/routers/login.py +++ b/web/backend/routers/login.py @@ -4,7 +4,8 @@ import asyncio import threading from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect -from sqlalchemy.orm import Session +from sqlalchemy import func +from sqlalchemy.orm import Session, defer from ..database import get_db, SessionLocal from ..models import User, Account, LoginTask, ProxyConfig as ProxyConfigModel @@ -16,6 +17,23 @@ from ..services.login_service import LoginBatchRunner, batch_registry router = APIRouter(prefix="/api/login", tags=["登录任务"]) +def _login_task_summary(query): + """按状态汇总登录任务,避免概览页拉完整任务列表。""" + rows = ( + query.order_by(None) + .with_entities(LoginTask.status, func.count(LoginTask.id)) + .group_by(LoginTask.status) + .all() + ) + status_counts = {status or "": count for status, count in rows} + return { + "total": sum(status_counts.values()), + "success": status_counts.get("success", 0), + "failed": sum(status_counts.get(status, 0) for status in ("failed", "error")), + "status_counts": status_counts, + } + + @router.post("/batch") async def create_batch( req: LoginBatchRequest, @@ -84,7 +102,7 @@ def list_tasks( current: User = Depends(get_current_user), ): """查看登录任务列表。""" - query = db.query(LoginTask).join(Account, LoginTask.account_id == Account.id) + query = db.query(LoginTask).options(defer(LoginTask.cookie)).join(Account, LoginTask.account_id == Account.id) # 客服只能看自己账号的任务 if not user_has_permission(current, "login:view_all"): @@ -106,12 +124,24 @@ def list_tasks( result.append(LoginTaskOut( id=t.id, batch_id=t.batch_id, account_id=t.account_id, account_username=accounts_map.get(t.account_id, ""), - status=t.status, cookie=t.cookie or "", message=t.message or "", + status=t.status, cookie="", message=t.message or "", created_by=t.created_by, created_at=t.created_at, finished_at=t.finished_at, )) return result +@router.get("/tasks/summary") +def tasks_summary( + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """登录任务统计。""" + query = db.query(LoginTask).join(Account, LoginTask.account_id == Account.id) + if not user_has_permission(current, "login:view_all"): + query = query.filter(Account.assigned_to == current.id) + return _login_task_summary(query) + + @router.delete("/tasks/{task_id}") def delete_task( task_id: int, diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index beb057a..6debee3 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -31,6 +31,7 @@ import type { MessageResponse, PageParams, PaginatedResponse, + TaskSummary, } from './types'; export const huyaApi = { @@ -108,6 +109,7 @@ export const huyaApi = { // 列表轮询默认不带 base64 小程序码,避免每次 1MB+ 流量。 params: batchId ? { batch_id: batchId, include_images: false } : { include_images: false }, }), + tasksSummary: () => api.get('/huya/tasks/summary'), getTask: (taskId: number) => api.get(`/huya/tasks/${taskId}`), }; diff --git a/web/frontend/src/api/login.ts b/web/frontend/src/api/login.ts index 017d936..b36d03e 100644 --- a/web/frontend/src/api/login.ts +++ b/web/frontend/src/api/login.ts @@ -1,5 +1,5 @@ import api from './client'; -import type { BatchLoginResult, LoginTaskItem, MessageDeletedResponse, MessageResponse } from './types'; +import type { BatchLoginResult, LoginTaskItem, MessageDeletedResponse, MessageResponse, TaskSummary } from './types'; interface CreateBatchParams { account_ids: number[]; @@ -15,6 +15,7 @@ export const loginApi = { api.post('/login/batch', params), listTasks: (batch_id?: string) => api.get('/login/tasks', { params: batch_id ? { batch_id } : {} }), + tasksSummary: () => api.get('/login/tasks/summary'), stop: (batch_id: string) => api.post(`/login/stop/${batch_id}`), deleteTask: (id: number) => api.delete(`/login/tasks/${id}`), deleteTasks: (ids: number[]) => api.delete(`/login/tasks`, { params: { task_ids: ids.join(',') } }), diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 2dd060d..bb55320 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -50,6 +50,13 @@ export interface BasicSummary { tag_count?: number; } +export interface TaskSummary { + total: number; + success: number; + failed: number; + status_counts: Record; +} + // ==================== Auth ==================== export interface LoginResult { diff --git a/web/frontend/src/pages/DashboardPage.tsx b/web/frontend/src/pages/DashboardPage.tsx index 11d0636..ed34092 100644 --- a/web/frontend/src/pages/DashboardPage.tsx +++ b/web/frontend/src/pages/DashboardPage.tsx @@ -5,8 +5,6 @@ import { cookieApi, huyaApi, loginApi, - type HuyaTaskItem, - type LoginTaskItem, } from '../api/modules'; import { usePermissions } from '../hooks/usePermissions'; @@ -47,10 +45,6 @@ const EMPTY_STATS: DashboardStats = { }, }; -function countFailed(items: T[]) { - return items.filter((item) => ['failed', 'error'].includes(item.status)).length; -} - function StatCard({ title, value, @@ -98,10 +92,10 @@ export default function DashboardPage() { huyaRechargeGoodsResult, ] = await Promise.allSettled([ canViewDouyuAccounts ? accountApi.summary() : Promise.resolve(null), - canViewDouyuTasks ? loginApi.listTasks() : Promise.resolve([]), + canViewDouyuTasks ? loginApi.tasksSummary() : Promise.resolve(null), canViewCookies ? cookieApi.summary() : Promise.resolve(null), canViewHuyaAccounts ? huyaApi.accountsSummary() : Promise.resolve(null), - canViewHuyaTasks ? huyaApi.listTasks() : Promise.resolve([]), + canViewHuyaTasks ? huyaApi.tasksSummary() : Promise.resolve(null), canViewHuyaTasks ? huyaApi.listGoods() : Promise.resolve([]), canViewHuyaTasks ? huyaApi.listRechargeGoods() : Promise.resolve([]), ]); @@ -109,26 +103,26 @@ export default function DashboardPage() { if (ignore) return; const douyuAccounts = douyuAccountsResult.status === 'fulfilled' ? douyuAccountsResult.value : null; - const douyuTasks = douyuTasksResult.status === 'fulfilled' ? douyuTasksResult.value : []; + const douyuTasks = douyuTasksResult.status === 'fulfilled' ? douyuTasksResult.value : null; const cookies = cookiesResult.status === 'fulfilled' ? cookiesResult.value : null; const huyaAccounts = huyaAccountsResult.status === 'fulfilled' ? huyaAccountsResult.value : null; - const huyaTasks = huyaTasksResult.status === 'fulfilled' ? huyaTasksResult.value : []; + const huyaTasks = huyaTasksResult.status === 'fulfilled' ? huyaTasksResult.value : null; const huyaGoods = huyaGoodsResult.status === 'fulfilled' ? huyaGoodsResult.value : []; const huyaRechargeGoods = huyaRechargeGoodsResult.status === 'fulfilled' ? huyaRechargeGoodsResult.value : []; setStats({ douyu: { accounts: douyuAccounts?.total || 0, - tasks: douyuTasks.length, - success: douyuTasks.filter((task: LoginTaskItem) => task.status === 'success').length, - failed: countFailed(douyuTasks), + tasks: douyuTasks?.total || 0, + success: douyuTasks?.success || 0, + failed: douyuTasks?.failed || 0, cookies: cookies?.total || 0, }, huya: { accounts: huyaAccounts?.total || 0, - tasks: huyaTasks.length, - success: huyaTasks.filter((task: HuyaTaskItem) => task.status === 'success').length, - failed: countFailed(huyaTasks), + tasks: huyaTasks?.total || 0, + success: huyaTasks?.success || 0, + failed: huyaTasks?.failed || 0, goods: huyaGoods.length, rechargeGoods: huyaRechargeGoods.length, }, diff --git a/web/frontend/src/pages/DouyuTasksPage.tsx b/web/frontend/src/pages/DouyuTasksPage.tsx index 9f63c15..0fb2e48 100644 --- a/web/frontend/src/pages/DouyuTasksPage.tsx +++ b/web/frontend/src/pages/DouyuTasksPage.tsx @@ -302,6 +302,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) const v = Number(localStorage.getItem('douyu_task_account_page_size')); return [10, 20, 50, 100].includes(v) ? v : 20; }); + const tasksLoadingRef = useRef(false); const [importOpen, setImportOpen] = useState(false); const [importPool, setImportPool] = useState([]); @@ -507,6 +508,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) }, [canConfig, isEsportsHandbook, workbenchIds]); const loadTasks = useCallback(async () => { + if (tasksLoadingRef.current) return; + tasksLoadingRef.current = true; try { const data = await douyuApi.listTasks(); setTasks(data); @@ -516,6 +519,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) } } catch { // 轮询失败不打扰操作 + } finally { + tasksLoadingRef.current = false; } }, []); diff --git a/web/frontend/src/pages/HuyaTasksPage.tsx b/web/frontend/src/pages/HuyaTasksPage.tsx index 8dc366f..96cf3f5 100644 --- a/web/frontend/src/pages/HuyaTasksPage.tsx +++ b/web/frontend/src/pages/HuyaTasksPage.tsx @@ -316,6 +316,7 @@ export default function HuyaTasksPage() { const notifiedPaidTaskIds = useRef>(new Set()); const accountTableAreaRef = useRef(null); const [accountTableAreaHeight, setAccountTableAreaHeight] = useState(460); + const tasksLoadingRef = useRef(false); const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs(); const { can } = usePermissions(); @@ -455,6 +456,8 @@ export default function HuyaTasksPage() { }, [canConfig, form, rememberExistingPaymentQrcodes, rememberExistingQrcodes]); const loadTasks = useCallback(async () => { + if (tasksLoadingRef.current) return; + tasksLoadingRef.current = true; try { const data = await huyaApi.listTasks(); const nextTasks = mergeTaskImageCache(data, qrImageCacheRef.current); @@ -466,6 +469,8 @@ export default function HuyaTasksPage() { } } catch { // 轮询失败不打扰操作,下一轮继续刷新。 + } finally { + tasksLoadingRef.current = false; } }, [rememberExistingPaymentQrcodes, rememberExistingQrcodes]); diff --git a/web/frontend/src/pages/LoginTasksPage.tsx b/web/frontend/src/pages/LoginTasksPage.tsx index 53baad0..16ab057 100644 --- a/web/frontend/src/pages/LoginTasksPage.tsx +++ b/web/frontend/src/pages/LoginTasksPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useMemo, useCallback } from 'react'; +import { useEffect, useState, useMemo, useCallback, useRef } from 'react'; import { Table, Button, Select, Tag, Space, InputNumber, Tooltip, Popconfirm, theme, Modal, Form, } from 'antd'; @@ -71,6 +71,7 @@ export default function LoginTasksPage() { return localStorage.getItem('login_api_strategy') || 'wgapi'; }); const [settingsOpen, setSettingsOpen] = useState(false); + const tasksLoadingRef = useRef(false); // 值变化时自动持久化 useEffect(() => { localStorage.setItem('login_concurrency', String(concurrency)); }, [concurrency]); @@ -145,22 +146,32 @@ export default function LoginTasksPage() { }, [selectedTags, tagAccountMap, accounts]); const loadTasks = useCallback(async () => { + if (tasksLoadingRef.current) return; + tasksLoadingRef.current = true; try { const data = await loginApi.listTasks(batchId || undefined); setTasks(data); } catch { // 忽略轮询失败,下一次定时刷新会继续尝试。 + } finally { + tasksLoadingRef.current = false; } }, [batchId]); + const hasActiveTasks = useMemo( + () => tasks.some((task) => ['pending', 'running'].includes(task.status)), + [tasks], + ); + useEffect(() => { Promise.all([loadAccounts(), loadTasks()]); }, [loadAccounts, loadTasks]); useEffect(() => { - const timer = setInterval(loadTasks, 3000); + const intervalMs = hasActiveTasks || wsConnected || starting || batchId ? 3000 : 15000; + const timer = setInterval(loadTasks, intervalMs); return () => clearInterval(timer); - }, [loadTasks]); + }, [batchId, hasActiveTasks, loadTasks, starting, wsConnected]); // 共享的批量任务启动逻辑 const startBatch = async (accountIds: number[], mode: BatchMode = 'login') => {