优化任务列表性能和本地登录代理

This commit is contained in:
yml2213
2026-08-05 10:03:18 +08:00
parent dff0d7db9e
commit 2fda47c631
13 changed files with 252 additions and 29 deletions
+18 -2
View File
@@ -79,6 +79,20 @@ detect_compose() {
fi 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 if [ -n "${DATABASE_URL:-}" ] && [[ "$DATABASE_URL" != mysql+* ]]; then
echo "dev.sh 只支持 MySQL,请移除 SQLite DATABASE_URL: $DATABASE_URL" echo "dev.sh 只支持 MySQL,请移除 SQLite DATABASE_URL: $DATABASE_URL"
exit 1 exit 1
@@ -99,6 +113,8 @@ if [ -z "$COMPOSE" ]; then
echo "缺少 docker compose" echo "缺少 docker compose"
exit 1 exit 1
fi 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 mkdir -p data logs
@@ -128,7 +144,7 @@ echo " 本地调试模式"
echo "==============================" echo "=============================="
echo " 后端: http://${BACKEND_HOST}:${BACKEND_PORT}" echo " 后端: http://${BACKEND_HOST}:${BACKEND_PORT}"
echo " 前端: http://localhost:${FRONTEND_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 " MySQL: ${DB_HOST}:${DB_PORT}/${DB_NAME}"
echo " 退出: Ctrl+C" echo " 退出: Ctrl+C"
echo "==============================" echo "=============================="
@@ -157,7 +173,7 @@ BACKEND_PID=$!
( (
cd web/frontend 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" npm run dev -- --host "$FRONTEND_HOST" --port "$FRONTEND_PORT"
) & ) &
FRONTEND_PID=$! FRONTEND_PID=$!
+7
View File
@@ -6,6 +6,7 @@ import uvicorn
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
@@ -66,6 +67,9 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# 压缩 JSON 和静态资源响应,避免任务列表轮询反复传输大体积文本。
app.add_middleware(GZipMiddleware, minimum_size=1024)
# 安全响应头中间件 # 安全响应头中间件
class SecurityHeadersMiddleware(BaseHTTPMiddleware): class SecurityHeadersMiddleware(BaseHTTPMiddleware):
@@ -74,6 +78,9 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY" response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" 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 return response
@@ -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)
+75 -4
View File
@@ -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 account = task.account
return DouyuTaskOut( return DouyuTaskOut(
id=task.id, id=task.id,
@@ -130,7 +196,11 @@ def _task_out(task: DouyuTask) -> DouyuTaskOut:
task_type=task.task_type, task_type=task.task_type,
status=task.status or "", status=task.status or "",
message=task.message 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_by=task.created_by,
created_at=task.created_at, created_at=task.created_at,
finished_at=task.finished_at, finished_at=task.finished_at,
@@ -299,6 +369,7 @@ async def create_task_batch(
@router.get("/tasks", response_model=list[DouyuTaskOut]) @router.get("/tasks", response_model=list[DouyuTaskOut])
def list_tasks( def list_tasks(
batch_id: str | None = None, batch_id: str | None = None,
include_detail: bool = Query(False, description="是否返回完整任务结果(默认否,轮询请保持 false)"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:task")), current: User = Depends(require_permission("douyu:task")),
): ):
@@ -313,7 +384,7 @@ def list_tasks(
if batch_id: if batch_id:
query = query.filter(DouyuTask.batch_id == batch_id) query = query.filter(DouyuTask.batch_id == batch_id)
rows = query.order_by(DouyuTask.id.desc()).limit(300).all() 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) @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() task = _visible_tasks_query(db, current).filter(DouyuTask.id == task_id).first()
if not task: if not task:
raise HTTPException(status_code=404, detail="任务不存在") raise HTTPException(status_code=404, detail="任务不存在")
return _task_out(task) return _task_out(task, include_detail=True)
@router.post("/stop/{batch_id}") @router.post("/stop/{batch_id}")
+27
View File
@@ -208,6 +208,24 @@ def _visible_huya_tasks_query(db: Session, current: User):
raise HTTPException(status_code=403, detail="无权查看虎牙任务") 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: def _require_huya_task_account_access(db: Session, current: User, account_ids: list[int]) -> None:
"""确保任务只会提交到当前用户可操作的虎牙账号。""" """确保任务只会提交到当前用户可操作的虎牙账号。"""
requested_ids = set(account_ids) requested_ids = set(account_ids)
@@ -1379,6 +1397,15 @@ def list_tasks(
return [_task_out(task, include_images=include_images) for task in 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) @router.get("/tasks/{task_id}", response_model=HuyaTaskOut)
def get_task( def get_task(
task_id: int, task_id: int,
+33 -3
View File
@@ -4,7 +4,8 @@ import asyncio
import threading import threading
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect 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 ..database import get_db, SessionLocal
from ..models import User, Account, LoginTask, ProxyConfig as ProxyConfigModel 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=["登录任务"]) 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") @router.post("/batch")
async def create_batch( async def create_batch(
req: LoginBatchRequest, req: LoginBatchRequest,
@@ -84,7 +102,7 @@ def list_tasks(
current: User = Depends(get_current_user), 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"): if not user_has_permission(current, "login:view_all"):
@@ -106,12 +124,24 @@ def list_tasks(
result.append(LoginTaskOut( result.append(LoginTaskOut(
id=t.id, batch_id=t.batch_id, account_id=t.account_id, id=t.id, batch_id=t.batch_id, account_id=t.account_id,
account_username=accounts_map.get(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, created_by=t.created_by, created_at=t.created_at, finished_at=t.finished_at,
)) ))
return result 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}") @router.delete("/tasks/{task_id}")
def delete_task( def delete_task(
task_id: int, task_id: int,
+2
View File
@@ -31,6 +31,7 @@ import type {
MessageResponse, MessageResponse,
PageParams, PageParams,
PaginatedResponse, PaginatedResponse,
TaskSummary,
} from './types'; } from './types';
export const huyaApi = { export const huyaApi = {
@@ -108,6 +109,7 @@ export const huyaApi = {
// 列表轮询默认不带 base64 小程序码,避免每次 1MB+ 流量。 // 列表轮询默认不带 base64 小程序码,避免每次 1MB+ 流量。
params: batchId ? { batch_id: batchId, include_images: false } : { include_images: false }, params: batchId ? { batch_id: batchId, include_images: false } : { include_images: false },
}), }),
tasksSummary: () => api.get<TaskSummary, TaskSummary>('/huya/tasks/summary'),
getTask: (taskId: number) => getTask: (taskId: number) =>
api.get<HuyaTaskItem, HuyaTaskItem>(`/huya/tasks/${taskId}`), api.get<HuyaTaskItem, HuyaTaskItem>(`/huya/tasks/${taskId}`),
}; };
+2 -1
View File
@@ -1,5 +1,5 @@
import api from './client'; import api from './client';
import type { BatchLoginResult, LoginTaskItem, MessageDeletedResponse, MessageResponse } from './types'; import type { BatchLoginResult, LoginTaskItem, MessageDeletedResponse, MessageResponse, TaskSummary } from './types';
interface CreateBatchParams { interface CreateBatchParams {
account_ids: number[]; account_ids: number[];
@@ -15,6 +15,7 @@ export const loginApi = {
api.post<BatchLoginResult, BatchLoginResult>('/login/batch', params), api.post<BatchLoginResult, BatchLoginResult>('/login/batch', params),
listTasks: (batch_id?: string) => listTasks: (batch_id?: string) =>
api.get<LoginTaskItem[], LoginTaskItem[]>('/login/tasks', { params: batch_id ? { batch_id } : {} }), api.get<LoginTaskItem[], LoginTaskItem[]>('/login/tasks', { params: batch_id ? { batch_id } : {} }),
tasksSummary: () => api.get<TaskSummary, TaskSummary>('/login/tasks/summary'),
stop: (batch_id: string) => api.post<MessageResponse, MessageResponse>(`/login/stop/${batch_id}`), stop: (batch_id: string) => api.post<MessageResponse, MessageResponse>(`/login/stop/${batch_id}`),
deleteTask: (id: number) => api.delete<MessageResponse, MessageResponse>(`/login/tasks/${id}`), deleteTask: (id: number) => api.delete<MessageResponse, MessageResponse>(`/login/tasks/${id}`),
deleteTasks: (ids: number[]) => api.delete<MessageDeletedResponse, MessageDeletedResponse>(`/login/tasks`, { params: { task_ids: ids.join(',') } }), deleteTasks: (ids: number[]) => api.delete<MessageDeletedResponse, MessageDeletedResponse>(`/login/tasks`, { params: { task_ids: ids.join(',') } }),
+7
View File
@@ -50,6 +50,13 @@ export interface BasicSummary {
tag_count?: number; tag_count?: number;
} }
export interface TaskSummary {
total: number;
success: number;
failed: number;
status_counts: Record<string, number>;
}
// ==================== Auth ==================== // ==================== Auth ====================
export interface LoginResult { export interface LoginResult {
+10 -16
View File
@@ -5,8 +5,6 @@ import {
cookieApi, cookieApi,
huyaApi, huyaApi,
loginApi, loginApi,
type HuyaTaskItem,
type LoginTaskItem,
} from '../api/modules'; } from '../api/modules';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
@@ -47,10 +45,6 @@ const EMPTY_STATS: DashboardStats = {
}, },
}; };
function countFailed<T extends { status: string }>(items: T[]) {
return items.filter((item) => ['failed', 'error'].includes(item.status)).length;
}
function StatCard({ function StatCard({
title, title,
value, value,
@@ -98,10 +92,10 @@ export default function DashboardPage() {
huyaRechargeGoodsResult, huyaRechargeGoodsResult,
] = await Promise.allSettled([ ] = await Promise.allSettled([
canViewDouyuAccounts ? accountApi.summary() : Promise.resolve(null), canViewDouyuAccounts ? accountApi.summary() : Promise.resolve(null),
canViewDouyuTasks ? loginApi.listTasks() : Promise.resolve([]), canViewDouyuTasks ? loginApi.tasksSummary() : Promise.resolve(null),
canViewCookies ? cookieApi.summary() : Promise.resolve(null), canViewCookies ? cookieApi.summary() : Promise.resolve(null),
canViewHuyaAccounts ? huyaApi.accountsSummary() : 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.listGoods() : Promise.resolve([]),
canViewHuyaTasks ? huyaApi.listRechargeGoods() : Promise.resolve([]), canViewHuyaTasks ? huyaApi.listRechargeGoods() : Promise.resolve([]),
]); ]);
@@ -109,26 +103,26 @@ export default function DashboardPage() {
if (ignore) return; if (ignore) return;
const douyuAccounts = douyuAccountsResult.status === 'fulfilled' ? douyuAccountsResult.value : null; 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 cookies = cookiesResult.status === 'fulfilled' ? cookiesResult.value : null;
const huyaAccounts = huyaAccountsResult.status === 'fulfilled' ? huyaAccountsResult.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 huyaGoods = huyaGoodsResult.status === 'fulfilled' ? huyaGoodsResult.value : [];
const huyaRechargeGoods = huyaRechargeGoodsResult.status === 'fulfilled' ? huyaRechargeGoodsResult.value : []; const huyaRechargeGoods = huyaRechargeGoodsResult.status === 'fulfilled' ? huyaRechargeGoodsResult.value : [];
setStats({ setStats({
douyu: { douyu: {
accounts: douyuAccounts?.total || 0, accounts: douyuAccounts?.total || 0,
tasks: douyuTasks.length, tasks: douyuTasks?.total || 0,
success: douyuTasks.filter((task: LoginTaskItem) => task.status === 'success').length, success: douyuTasks?.success || 0,
failed: countFailed(douyuTasks), failed: douyuTasks?.failed || 0,
cookies: cookies?.total || 0, cookies: cookies?.total || 0,
}, },
huya: { huya: {
accounts: huyaAccounts?.total || 0, accounts: huyaAccounts?.total || 0,
tasks: huyaTasks.length, tasks: huyaTasks?.total || 0,
success: huyaTasks.filter((task: HuyaTaskItem) => task.status === 'success').length, success: huyaTasks?.success || 0,
failed: countFailed(huyaTasks), failed: huyaTasks?.failed || 0,
goods: huyaGoods.length, goods: huyaGoods.length,
rechargeGoods: huyaRechargeGoods.length, rechargeGoods: huyaRechargeGoods.length,
}, },
@@ -302,6 +302,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
const v = Number(localStorage.getItem('douyu_task_account_page_size')); const v = Number(localStorage.getItem('douyu_task_account_page_size'));
return [10, 20, 50, 100].includes(v) ? v : 20; return [10, 20, 50, 100].includes(v) ? v : 20;
}); });
const tasksLoadingRef = useRef(false);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [importPool, setImportPool] = useState<DouyuTaskAccountItem[]>([]); const [importPool, setImportPool] = useState<DouyuTaskAccountItem[]>([]);
@@ -507,6 +508,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
}, [canConfig, isEsportsHandbook, workbenchIds]); }, [canConfig, isEsportsHandbook, workbenchIds]);
const loadTasks = useCallback(async () => { const loadTasks = useCallback(async () => {
if (tasksLoadingRef.current) return;
tasksLoadingRef.current = true;
try { try {
const data = await douyuApi.listTasks(); const data = await douyuApi.listTasks();
setTasks(data); setTasks(data);
@@ -516,6 +519,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
} }
} catch { } catch {
// 轮询失败不打扰操作 // 轮询失败不打扰操作
} finally {
tasksLoadingRef.current = false;
} }
}, []); }, []);
+5
View File
@@ -316,6 +316,7 @@ export default function HuyaTasksPage() {
const notifiedPaidTaskIds = useRef<Set<number>>(new Set()); const notifiedPaidTaskIds = useRef<Set<number>>(new Set());
const accountTableAreaRef = useRef<HTMLDivElement | null>(null); const accountTableAreaRef = useRef<HTMLDivElement | null>(null);
const [accountTableAreaHeight, setAccountTableAreaHeight] = useState(460); const [accountTableAreaHeight, setAccountTableAreaHeight] = useState(460);
const tasksLoadingRef = useRef(false);
const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs(); const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs();
const { can } = usePermissions(); const { can } = usePermissions();
@@ -455,6 +456,8 @@ export default function HuyaTasksPage() {
}, [canConfig, form, rememberExistingPaymentQrcodes, rememberExistingQrcodes]); }, [canConfig, form, rememberExistingPaymentQrcodes, rememberExistingQrcodes]);
const loadTasks = useCallback(async () => { const loadTasks = useCallback(async () => {
if (tasksLoadingRef.current) return;
tasksLoadingRef.current = true;
try { try {
const data = await huyaApi.listTasks(); const data = await huyaApi.listTasks();
const nextTasks = mergeTaskImageCache(data, qrImageCacheRef.current); const nextTasks = mergeTaskImageCache(data, qrImageCacheRef.current);
@@ -466,6 +469,8 @@ export default function HuyaTasksPage() {
} }
} catch { } catch {
// 轮询失败不打扰操作,下一轮继续刷新。 // 轮询失败不打扰操作,下一轮继续刷新。
} finally {
tasksLoadingRef.current = false;
} }
}, [rememberExistingPaymentQrcodes, rememberExistingQrcodes]); }, [rememberExistingPaymentQrcodes, rememberExistingQrcodes]);
+14 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo, useCallback } from 'react'; import { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import { import {
Table, Button, Select, Tag, Space, InputNumber, Tooltip, Popconfirm, theme, Modal, Form, Table, Button, Select, Tag, Space, InputNumber, Tooltip, Popconfirm, theme, Modal, Form,
} from 'antd'; } from 'antd';
@@ -71,6 +71,7 @@ export default function LoginTasksPage() {
return localStorage.getItem('login_api_strategy') || 'wgapi'; return localStorage.getItem('login_api_strategy') || 'wgapi';
}); });
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const tasksLoadingRef = useRef(false);
// 值变化时自动持久化 // 值变化时自动持久化
useEffect(() => { localStorage.setItem('login_concurrency', String(concurrency)); }, [concurrency]); useEffect(() => { localStorage.setItem('login_concurrency', String(concurrency)); }, [concurrency]);
@@ -145,22 +146,32 @@ export default function LoginTasksPage() {
}, [selectedTags, tagAccountMap, accounts]); }, [selectedTags, tagAccountMap, accounts]);
const loadTasks = useCallback(async () => { const loadTasks = useCallback(async () => {
if (tasksLoadingRef.current) return;
tasksLoadingRef.current = true;
try { try {
const data = await loginApi.listTasks(batchId || undefined); const data = await loginApi.listTasks(batchId || undefined);
setTasks(data); setTasks(data);
} catch { } catch {
// 忽略轮询失败,下一次定时刷新会继续尝试。 // 忽略轮询失败,下一次定时刷新会继续尝试。
} finally {
tasksLoadingRef.current = false;
} }
}, [batchId]); }, [batchId]);
const hasActiveTasks = useMemo(
() => tasks.some((task) => ['pending', 'running'].includes(task.status)),
[tasks],
);
useEffect(() => { useEffect(() => {
Promise.all([loadAccounts(), loadTasks()]); Promise.all([loadAccounts(), loadTasks()]);
}, [loadAccounts, loadTasks]); }, [loadAccounts, loadTasks]);
useEffect(() => { useEffect(() => {
const timer = setInterval(loadTasks, 3000); const intervalMs = hasActiveTasks || wsConnected || starting || batchId ? 3000 : 15000;
const timer = setInterval(loadTasks, intervalMs);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [loadTasks]); }, [batchId, hasActiveTasks, loadTasks, starting, wsConnected]);
// 共享的批量任务启动逻辑 // 共享的批量任务启动逻辑
const startBatch = async (accountIds: number[], mode: BatchMode = 'login') => { const startBatch = async (accountIds: number[], mode: BatchMode = 'login') => {