diff --git a/.env.example b/.env.example index 6a75fca..eb4865f 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,12 @@ ADMIN_PASSWORD=admin123 # Cookie 安全标志(生产环境 HTTPS 部署时设为 true) COOKIE_SECURE=false +# 本地调试端口(./dev.sh / deploy.sh dev 使用;Docker 生产默认 8800) +# BACKEND_PORT=8800 +# FRONTEND_PORT=5174 +# BACKEND_HOST=0.0.0.0 +# FRONTEND_HOST=0.0.0.0 + # CORS 允许的源(逗号分隔,不设则默认开发环境) # CORS_ORIGINS=https://example.com,https://www.example.com diff --git a/Dockerfile b/Dockerfile index 2453adc..a6c956d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -57,11 +57,11 @@ ENV PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 ENV PATH="/app/.venv/bin:$PATH" -EXPOSE 8000 +EXPOSE 8800 # 健康检查 HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ - CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" || exit 1 + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8800/api/health')" || exit 1 # 启动命令 -CMD ["python", "-m", "uvicorn", "web.backend.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] +CMD ["python", "-m", "uvicorn", "web.backend.main:app", "--host", "0.0.0.0", "--port", "8800", "--workers", "1"] diff --git a/README.md b/README.md index 6f2c220..34a3020 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ douyu_login_py/ ./deploy.sh ``` -访问 `http://localhost:8000`,默认账号 `admin / admin123`。 +访问 `http://localhost:8800`,默认账号 `admin / admin123`。 **常用命令:** @@ -109,7 +109,7 @@ MIT License ./deploy.sh reset ``` -部署完成后访问 `http://服务器IP:8000`,默认账号 `admin / admin123`。 +部署完成后访问 `http://服务器IP:8800`,默认账号 `admin / admin123`。 **数据持久化:** 数据库和日志通过 volume 挂载到宿主机 `./data` 和 `./logs` 目录,容器重建后数据不丢失。 diff --git a/deploy.sh b/deploy.sh index a5b4c38..de0b884 100755 --- a/deploy.sh +++ b/deploy.sh @@ -60,12 +60,12 @@ cmd_deploy() { # 健康检查 for i in $(seq 1 15); do - if curl -s http://localhost:8000/api/health | grep -q "ok" 2>/dev/null; then + if curl -s http://localhost:8800/api/health | grep -q "ok" 2>/dev/null; then echo "" echo "==============================" echo " ✅ 部署成功!" echo "==============================" - echo " 访问地址: http://localhost:8000" + echo " 访问地址: http://localhost:8800" echo " 默认账号见环境变量 ADMIN_USERNAME/ADMIN_PASSWORD(未设置则为 admin/admin123)" echo "" echo " 查看日志: $COMPOSE logs -f" @@ -186,8 +186,8 @@ cmd_help() { echo " dev 本地调试启动(后端 reload + 前端热更新)" echo " help 显示帮助" echo "" - echo " 访问地址: http://localhost:8000" - echo " 调试地址: http://localhost:5173" + echo " 访问地址: http://localhost:8800" + echo " 调试地址: http://localhost:5174" echo " 默认账号见环境变量 ADMIN_USERNAME/ADMIN_PASSWORD" echo "" } diff --git a/dev.sh b/dev.sh index 64363c3..e8f53ec 100755 --- a/dev.sh +++ b/dev.sh @@ -15,9 +15,9 @@ if [ -f "$ROOT_DIR/.env" ]; then fi BACKEND_HOST="${BACKEND_HOST:-0.0.0.0}" -BACKEND_PORT="${BACKEND_PORT:-8000}" +BACKEND_PORT="${BACKEND_PORT:-8800}" FRONTEND_HOST="${FRONTEND_HOST:-0.0.0.0}" -FRONTEND_PORT="${FRONTEND_PORT:-5173}" +FRONTEND_PORT="${FRONTEND_PORT:-5174}" LOG_LEVEL="${LOG_LEVEL:-DEBUG}" BACKEND_PID="" diff --git a/docker-compose.yml b/docker-compose.yml index 18f53e3..3b8476f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: container_name: douyu-login restart: unless-stopped ports: - - "8000:8000" + - "8800:8800" volumes: # 持久化数据库和日志 - ./data:/app/data @@ -32,7 +32,7 @@ services: soft: 65536 hard: 65536 healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8800/api/health')"] interval: 30s timeout: 5s retries: 3 diff --git a/web/backend/main.py b/web/backend/main.py index 0456174..c392e57 100644 --- a/web/backend/main.py +++ b/web/backend/main.py @@ -25,6 +25,19 @@ async def lifespan(app: FastAPI): setup_logger(level=_log_level, log_dir=str(_log_dir)) init_db() + # 进程重启后内存批次丢失,清理历史 pending/running,避免前端被假活跃批次锁死。 + from loguru import logger + + from .database import SessionLocal + from .services.huya_service import cleanup_orphan_huya_tasks + + db = SessionLocal() + try: + cleaned = cleanup_orphan_huya_tasks(db, message="任务已中断(服务重启)") + if cleaned: + logger.info(f"启动清理虎牙残留任务: {cleaned} 条") + finally: + db.close() yield @@ -39,7 +52,7 @@ _cors_env = os.getenv("CORS_ORIGINS", "") if _cors_env: _cors_origins = [o.strip() for o in _cors_env.split(",") if o.strip()] else: - _cors_origins = ["http://localhost:5173", "http://localhost:3000"] + _cors_origins = ["http://localhost:5174", "http://localhost:5173", "http://localhost:3000"] app.add_middleware( CORSMiddleware, @@ -115,7 +128,8 @@ if _INDEX_HTML.exists(): def run(): _reload = os.getenv("UVICORN_RELOAD", "false").lower() == "true" - uvicorn.run("web.backend.main:app", host="0.0.0.0", port=8000, reload=_reload) + _port = int(os.getenv("BACKEND_PORT", "8800")) + uvicorn.run("web.backend.main:app", host="0.0.0.0", port=_port, reload=_reload) if __name__ == "__main__": diff --git a/web/backend/routers/huya.py b/web/backend/routers/huya.py index 91c9ce4..6e99037 100644 --- a/web/backend/routers/huya.py +++ b/web/backend/routers/huya.py @@ -51,6 +51,7 @@ from ..services.huya_service import ( HUYA_CONFIG_FIELDS, SUPPORTED_TASK_TYPES, apply_huya_config_defaults, + cleanup_orphan_huya_tasks, create_planned_tasks, ensure_huya_config, huya_config_value, @@ -133,7 +134,23 @@ def _account_out(account: HuyaAccount, include_cookie: bool = True) -> HuyaAccou ) -def _task_out(task: HuyaTask) -> HuyaTaskOut: +def _sanitize_task_result(result: dict | None, *, include_images: bool = False) -> dict | None: + """列表接口默认剥离 base64 图片,避免轮询每次传 1MB+ 数据。""" + if not isinstance(result, dict): + return result + + data = dict(result) + image = data.get("mini_qrcode_image") + if isinstance(image, str) and image: + data["has_mini_qrcode"] = True + if not include_images: + data.pop("mini_qrcode_image", None) + elif data.get("has_mini_qrcode"): + data["has_mini_qrcode"] = True + return data + + +def _task_out(task: HuyaTask, *, include_images: bool = False) -> HuyaTaskOut: account = task.account return HuyaTaskOut( id=task.id, @@ -144,7 +161,7 @@ def _task_out(task: HuyaTask) -> HuyaTaskOut: task_type=task.task_type, status=task.status or "", message=task.message or "", - result=task.result, + result=_sanitize_task_result(task.result if isinstance(task.result, dict) else None, include_images=include_images), created_by=task.created_by, created_at=task.created_at, finished_at=task.finished_at, @@ -952,6 +969,16 @@ async def create_task_batch( """创建虎牙任务记录并启动后台执行器。""" if not req.account_ids: raise HTTPException(status_code=400, detail="请选择虎牙账号") + + # 先清掉没有执行器的历史 running/pending,避免前端被假活跃批次锁死。 + # 不清理 planned:避免与刚创建的新任务产生竞态。 + cleanup_orphan_huya_tasks( + db, + active_batch_ids=huya_batch_registry.active_ids(), + statuses=("pending", "running"), + message="任务已中断(无执行器接管)", + ) + try: batch_id, count = create_planned_tasks( db, @@ -988,27 +1015,71 @@ async def create_task_batch( @router.get("/tasks", response_model=list[HuyaTaskOut]) def list_tasks( batch_id: str | None = None, + include_images: bool = Query(False, description="是否返回 base64 小程序码(默认否,轮询请保持 false)"), db: Session = Depends(get_db), current: User = Depends(require_permission("huya:task")), ): """查看虎牙任务记录。""" + cleanup_orphan_huya_tasks( + db, + active_batch_ids=huya_batch_registry.active_ids(), + statuses=("pending", "running"), + message="任务已中断(无执行器接管)", + ) query = db.query(HuyaTask).options(joinedload(HuyaTask.account)) if batch_id: query = query.filter(HuyaTask.batch_id == batch_id) tasks = query.order_by(HuyaTask.id.desc()).limit(300).all() - return [_task_out(task) for task in tasks] + return [_task_out(task, include_images=include_images) for task in tasks] + + +@router.get("/tasks/{task_id}", response_model=HuyaTaskOut) +def get_task( + task_id: int, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:task")), +): + """获取单条虎牙任务详情(含二维码图片)。""" + task = ( + db.query(HuyaTask) + .options(joinedload(HuyaTask.account)) + .filter(HuyaTask.id == task_id) + .first() + ) + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + return _task_out(task, include_images=True) @router.post("/stop/{batch_id}") def stop_batch( batch_id: str, + db: Session = Depends(get_db), current: User = Depends(require_permission("huya:task")), ): """停止正在运行的虎牙批次。""" batch = huya_batch_registry.get(batch_id) if batch: + if batch.get("finished"): + huya_batch_registry.pop(batch_id) + cleaned = cleanup_orphan_huya_tasks( + db, + batch_id=batch_id, + message="批次已结束", + ) + if cleaned: + return {"message": f"批次已结束,已清理 {cleaned} 个残留任务", "success": True} + raise HTTPException(status_code=404, detail="批次已结束") batch["runner"].stop() return {"message": "已发送停止信号", "success": True} + + cleaned = cleanup_orphan_huya_tasks( + db, + batch_id=batch_id, + message="任务已停止(批次不存在)", + ) + if cleaned: + return {"message": f"已清理 {cleaned} 个残留任务", "success": True} raise HTTPException(status_code=404, detail="批次不存在或已结束") @@ -1042,4 +1113,6 @@ async def ws_huya_logs(websocket: WebSocket, batch_id: str): except WebSocketDisconnect: pass finally: - huya_batch_registry.pop(batch_id) + latest = huya_batch_registry.get(batch_id) + if latest and latest.get("finished"): + huya_batch_registry.pop(batch_id) diff --git a/web/backend/services/huya_runner.py b/web/backend/services/huya_runner.py index 772206d..2db2b83 100644 --- a/web/backend/services/huya_runner.py +++ b/web/backend/services/huya_runner.py @@ -22,8 +22,6 @@ HUYA_RECHARGE_SOURCE_ID = "yellowcarlist" HUYA_RECHARGE_SCENE = 4 HUYA_PAYMENT_POLL_SECONDS = 180 HUYA_PAYMENT_POLL_INTERVAL = 3 -HUYA_BIND_ROLE_POLL_SECONDS = 180 -HUYA_BIND_ROLE_POLL_INTERVAL = 3 HUYA_BIND_ZT_UUID = "b02faae1" HUYA_BIND_ROOM_ID = "30596253" HUYA_RECHARGE_EXTRA_PRODUCTS = [ @@ -65,6 +63,11 @@ class HuyaBatchRunner: self._stop.set() def _push_log(self, level: str, message: str): + if level == "result": + try: + huya_batch_registry.mark_finished(self.batch_id) + except NameError: + pass if level != "result" and message: log_func = getattr(logger, level, logger.info) log_func(f"[huya] {message}") @@ -252,86 +255,6 @@ class HuyaBatchRunner: f"&roomid={HUYA_BIND_ROOM_ID}" ) - def _wait_bind_role_result( - self, - client: HuyaHttpClient, - worker_db: Session, - task: HuyaTask, - account: HuyaAccount, - uid: int, - cookie: str, - b_act_id_int: int, - result: dict, - ) -> tuple[str, dict]: - deadline = time.monotonic() + HUYA_BIND_ROLE_POLL_SECONDS - qrcode_token = str(result.get("qrcode_token") or "") - qrcode_finished = not qrcode_token - while not self._stop.is_set() and time.monotonic() < deadline: - if self._stop.wait(HUYA_BIND_ROLE_POLL_INTERVAL): - break - - if qrcode_token and not qrcode_finished: - qrcode_status = client.get_livelink_qrcode_status(qrcode_token, timeout=10.0) - if qrcode_status is not None: - result["qrcode_status"] = qrcode_status - if qrcode_status["is_expired"] or qrcode_status["is_failure"]: - result.update({ - "bind_phase": "qrcode_expired", - "bind_ready_for_confirm": False, - }) - self._update_task_progress(worker_db, task, "running", "绑定小程序码已失效,请重新获取", result) - return "", result - if qrcode_status["is_completed"]: - qrcode_finished = True - result["bind_phase"] = "qrcode_completed" - elif qrcode_status["is_scan"]: - result["bind_phase"] = "qrcode_scanned" - else: - result["bind_phase"] = "waiting_scan" - - bind_status, bind_query_result = self._resolve_bind_status( - client=client, - uid=uid, - cookie=cookie, - b_act_id_int=b_act_id_int, - ) - if bind_status is None: - continue - result.update(bind_query_result) - - if bind_status.status != 200: - result.update({ - "bind_phase": "role_check_failed", - "bind_status": bind_status.to_dict(), - }) - self._update_task_progress(worker_db, task, "running", bind_status.msg or "等待绑定角色同步", result) - continue - - previous_phase = result.get("bind_phase") - ready = self._bind_ready_result(bind_status) - if not ready["role_name"] and previous_phase in {"waiting_scan", "qrcode_scanned", "qrcode_completed"}: - ready["bind_phase"] = previous_phase - result.update(ready) - role_name = ready["role_name"] - if role_name: - self._apply_role_to_account(account, bind_status, "game_queried") - self._update_task_progress(worker_db, task, "running", f"已识别角色: {role_name},待确认绑定", result) - return role_name, result - - if result.get("bind_phase") == "qrcode_completed": - message = "小程序绑定已完成,等待角色同步" - elif result.get("bind_phase") == "qrcode_scanned": - message = "已扫码,等待小程序绑定完成" - else: - message = "已生成绑定小程序码,等待扫码绑定" - self._update_task_progress(worker_db, task, "running", message, result) - - result.update({ - "bind_phase": "role_timeout" if not self._stop.is_set() else "stopped", - "bind_ready_for_confirm": False, - }) - return "", result - def _mark_task( self, worker_db: Session, @@ -671,7 +594,7 @@ class HuyaBatchRunner: return if scheduled_at and scheduled_at.timestamp() > time.time(): if not self._wait_until(scheduled_at, uid): - self._mark_task(worker_db, task, "failed", "兑换任务已停止") + self._mark_task(worker_db, task, "stopped", "兑换任务已停止") return client = HuyaHttpClient(logger=lambda msg: self._push_log("info", f"[{uid}] {msg}")) @@ -1037,6 +960,10 @@ class HuyaBatchRunner: paid_message += f",支付时间 {self._format_local_time(int(payment_order['pay_time']) // 1000)}" self._mark_task(worker_db, task, "success", paid_message, result) return + if payment_status == "stopped": + account.status = "recharge_order_created" + self._mark_task(worker_db, task, "stopped", f"{message},已停止监听支付", result) + return account.status = "recharge_order_created" timeout_message = f"{message},{result['payment_status_label']}" @@ -1167,6 +1094,7 @@ class HuyaBatchRunner: **bind_query_result, "bind_phase": "waiting_scan" if mini_qrcode.get("qrcode_token") else "waiting_role", "bind_ready_for_confirm": False, + "bind_polling": False, "bind_redirect_url": bind_redirect_url, **role_info, **change_state, @@ -1181,29 +1109,7 @@ class HuyaBatchRunner: account.game_channel = self._role_channel(bind_status) or account.game_channel account.nickname = profile_nick or account.nickname account.updated_at = datetime.now(timezone.utc) - self._update_task_progress(worker_db, task, "running", "已生成绑定小程序码,等待绑定角色", result) - - role_name, result = self._wait_bind_role_result( - client=client, - worker_db=worker_db, - task=task, - account=account, - uid=uid, - cookie=cookie, - b_act_id_int=b_act_id_int, - result=result, - ) - if role_name: - self._mark_task(worker_db, task, "success", f"已识别角色: {role_name},待确认绑定", result) - return - - if result.get("bind_phase") == "stopped": - self._mark_task(worker_db, task, "failed", "任务已停止", result) - return - if result.get("bind_phase") == "qrcode_expired": - self._mark_task(worker_db, task, "failed", "绑定小程序码已失效,请重新获取", result) - return - self._mark_task(worker_db, task, "success", "已生成绑定小程序码,未检测到绑定角色", result) + self._mark_task(worker_db, task, "success", "已生成绑定小程序码", result) def _execute_query_game_name( self, @@ -1260,6 +1166,8 @@ class HuyaBatchRunner: **role_info, **change_state, **bind_query_result, + "bind_ready_for_confirm": bool(role_info["role_name"]), + "bind_phase": "role_ready" if role_info["role_name"] else "waiting_role", } role_name = role_info["role_name"] if role_name: @@ -1417,7 +1325,7 @@ class HuyaBatchRunner: return if self._stop.is_set(): - self._mark_task(worker_db, task, "failed", "任务已停止") + self._mark_task(worker_db, task, "stopped", "任务已停止") return task.status = "running" @@ -1562,6 +1470,17 @@ class HuyaBatchRegistry: def __init__(self): self._batches: dict[str, dict] = {} + self._lock = threading.Lock() + + def _cleanup_locked(self, ttl_seconds: int = 300): + now = time.time() + expired = [ + batch_id + for batch_id, batch in self._batches.items() + if batch.get("finished") and now - float(batch.get("finished_at") or now) > ttl_seconds + ] + for batch_id in expired: + self._batches.pop(batch_id, None) def register( self, @@ -1570,17 +1489,41 @@ class HuyaBatchRegistry: loop: asyncio.AbstractEventLoop, runner: HuyaBatchRunner, ): - self._batches[batch_id] = { - "log_queue": log_queue, - "loop": loop, - "runner": runner, - } + with self._lock: + self._cleanup_locked() + self._batches[batch_id] = { + "log_queue": log_queue, + "loop": loop, + "runner": runner, + "finished": False, + "finished_at": None, + } def get(self, batch_id: str): - return self._batches.get(batch_id) + with self._lock: + self._cleanup_locked() + return self._batches.get(batch_id) + + def active_ids(self) -> set[str]: + with self._lock: + self._cleanup_locked() + return { + batch_id + for batch_id, batch in self._batches.items() + if not batch.get("finished") + } + + def mark_finished(self, batch_id: str): + with self._lock: + batch = self._batches.get(batch_id) + if not batch: + return + batch["finished"] = True + batch["finished_at"] = time.time() def pop(self, batch_id: str): - return self._batches.pop(batch_id, None) + with self._lock: + return self._batches.pop(batch_id, None) huya_batch_registry = HuyaBatchRegistry() diff --git a/web/backend/services/huya_service.py b/web/backend/services/huya_service.py index 4b1f537..4e9c60a 100644 --- a/web/backend/services/huya_service.py +++ b/web/backend/services/huya_service.py @@ -313,6 +313,40 @@ def ensure_huya_config(db: Session) -> HuyaConfig: return config +HUYA_ACTIVE_TASK_STATUSES = ("planned", "pending", "running") +HUYA_STALE_TASK_STATUSES = ("pending", "running") + + +def cleanup_orphan_huya_tasks( + db: Session, + *, + active_batch_ids: set[str] | None = None, + batch_id: str | None = None, + statuses: tuple[str, ...] = HUYA_ACTIVE_TASK_STATUSES, + message: str = "任务已中断(服务重启或批次丢失)", +) -> int: + """清理没有执行器接管的虎牙任务,避免前端被历史 running 状态锁死。""" + query = db.query(HuyaTask).filter(HuyaTask.status.in_(statuses)) + if batch_id: + query = query.filter(HuyaTask.batch_id == batch_id) + elif active_batch_ids is not None: + if active_batch_ids: + query = query.filter(~HuyaTask.batch_id.in_(list(active_batch_ids))) + # active_batch_ids is None 且未指定 batch_id:清理全部匹配状态 + + tasks = query.all() + if not tasks: + return 0 + + now = datetime.now(timezone.utc) + for task in tasks: + task.status = "stopped" + task.message = message + task.finished_at = now + db.commit() + return len(tasks) + + def create_planned_tasks( db: Session, account_ids: list[int], diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index 48a1f5b..1f07f73 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -59,6 +59,8 @@ export const huyaApi = { api.get('/huya/register/success-logs', { params }), exportRegisterSuccessLogs: (params?: { batch_id?: string; tag?: string; limit?: number }) => api.get('/huya/register/success-logs/export', { responseType: 'blob', params }), + stopBatch: (batchId: string) => + api.post(`/huya/stop/${batchId}`), assign: (id: number, assigned_to: number | null) => api.put(`/huya/accounts/${id}/assign`, { assigned_to }), batchAssign: (account_ids: number[], assigned_to: number | null) => @@ -85,5 +87,10 @@ export const huyaApi = { createTasks: (data: HuyaTaskBatchRequest) => api.post('/huya/tasks/batch', data), listTasks: (batchId?: string) => - api.get('/huya/tasks', { params: batchId ? { batch_id: batchId } : {} }), + api.get('/huya/tasks', { + // 列表轮询默认不带 base64 小程序码,避免每次 1MB+ 流量。 + params: batchId ? { batch_id: batchId, include_images: false } : { include_images: false }, + }), + getTask: (taskId: number) => + api.get(`/huya/tasks/${taskId}`), }; diff --git a/web/frontend/src/pages/HuyaTasksPage.tsx b/web/frontend/src/pages/HuyaTasksPage.tsx index 9451c9a..df634b1 100644 --- a/web/frontend/src/pages/HuyaTasksPage.tsx +++ b/web/frontend/src/pages/HuyaTasksPage.tsx @@ -7,7 +7,7 @@ import type { Dayjs } from 'dayjs'; import type { MouseEvent as ReactMouseEvent } from 'react'; import { AppstoreOutlined, CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined, - ImportOutlined, LinkOutlined, MoreOutlined, PlayCircleOutlined, QrcodeOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, + ImportOutlined, LinkOutlined, MoreOutlined, PlayCircleOutlined, QrcodeOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined, } from '@ant-design/icons'; import { huyaApi, @@ -56,6 +56,7 @@ const STATUS_COLORS: Record = { success: 'success', failed: 'error', error: 'error', + stopped: 'warning', }; const STATUS_LABELS: Record = { @@ -65,6 +66,7 @@ const STATUS_LABELS: Record = { success: '成功', failed: '失败', error: '异常', + stopped: '已停止', }; const ACCOUNT_STATUS_LABELS: Record = { @@ -211,7 +213,32 @@ function formatPriceText(value: number | null | undefined): string { } function hasMiniQrcode(task: HuyaTaskItem): boolean { - return task.task_type === 'get_bind_qr' && Boolean(resultText(task.result, 'mini_qrcode_image')); + if (task.task_type !== 'get_bind_qr') return false; + if (resultText(task.result, 'mini_qrcode_image')) return true; + return task.result?.has_mini_qrcode === true; +} + +function mergeTaskImageCache( + items: HuyaTaskItem[], + imageCache: Map, +): HuyaTaskItem[] { + return items.map((task) => { + const image = resultText(task.result, 'mini_qrcode_image'); + if (image) { + imageCache.set(task.id, image); + return task; + } + const cached = imageCache.get(task.id); + if (!cached || !task.result) return task; + return { + ...task, + result: { + ...task.result, + mini_qrcode_image: cached, + has_mini_qrcode: true, + }, + }; + }); } function bindReadyForConfirm(task: HuyaTaskItem | null | undefined): boolean { @@ -267,6 +294,7 @@ export default function HuyaTasksPage() { }); const [loading, setLoading] = useState(false); const [starting, setStarting] = useState(false); + const [stopping, setStopping] = useState(false); const [savingConfig, setSavingConfig] = useState(false); const [taskRecordsVisible, setTaskRecordsVisible] = useState(false); const [batchId, setBatchId] = useState(null); @@ -277,6 +305,7 @@ export default function HuyaTasksPage() { const autoOpenQrReady = useRef(false); const autoOpenedPayTaskIds = useRef>(new Set()); const autoOpenPayReady = useRef(false); + const qrImageCacheRef = useRef>(new Map()); const notifiedPaidTaskIds = useRef>(new Set()); const accountTableAreaRef = useRef(null); const [accountTableAreaHeight, setAccountTableAreaHeight] = useState(460); @@ -323,7 +352,34 @@ export default function HuyaTasksPage() { const openQrTask = useCallback((task: HuyaTaskItem) => { autoOpenedQrTaskIds.current.add(task.id); + const cachedImage = qrImageCacheRef.current.get(task.id); + const currentImage = resultText(task.result, 'mini_qrcode_image'); + if (currentImage) { + qrImageCacheRef.current.set(task.id, currentImage); + setQrTask(task); + return; + } + if (cachedImage) { + setQrTask({ + ...task, + result: { + ...(task.result || {}), + mini_qrcode_image: cachedImage, + has_mini_qrcode: true, + }, + }); + return; + } setQrTask(task); + // 列表接口默认不带 base64,打开弹窗时再拉详情。 + void huyaApi.getTask(task.id).then((detail) => { + const image = resultText(detail.result, 'mini_qrcode_image'); + if (image) qrImageCacheRef.current.set(detail.id, image); + setQrTask((current) => (current && current.id === detail.id ? detail : current)); + setTasks((prev) => prev.map((item) => (item.id === detail.id ? detail : item))); + }).catch(() => { + // 详情失败时仍展示已有状态,不打断操作。 + }); }, []); const openPayTask = useCallback((task: HuyaTaskItem) => { @@ -356,9 +412,13 @@ export default function HuyaTasksPage() { setSelectedIds((prev) => prev.filter((id) => nextPoolById.has(id))); } if (taskResult.status === 'fulfilled') { - rememberExistingQrcodes(taskResult.value); - rememberExistingPaymentQrcodes(taskResult.value); - setTasks(taskResult.value); + const nextTasks = mergeTaskImageCache(taskResult.value, qrImageCacheRef.current); + rememberExistingQrcodes(nextTasks); + rememberExistingPaymentQrcodes(nextTasks); + setTasks(nextTasks); + if (!nextTasks.some((task) => ['pending', 'running'].includes(task.status))) { + setBatchId(null); + } } if (goodsResult.status === 'fulfilled') setGoods(goodsResult.value); if (rechargeGoodsResult.status === 'fulfilled') setRechargeGoods(rechargeGoodsResult.value); @@ -386,9 +446,13 @@ export default function HuyaTasksPage() { const loadTasks = useCallback(async () => { try { const data = await huyaApi.listTasks(); - rememberExistingQrcodes(data); - rememberExistingPaymentQrcodes(data); - setTasks(data); + const nextTasks = mergeTaskImageCache(data, qrImageCacheRef.current); + rememberExistingQrcodes(nextTasks); + rememberExistingPaymentQrcodes(nextTasks); + setTasks(nextTasks); + if (!nextTasks.some((task) => ['pending', 'running'].includes(task.status))) { + setBatchId(null); + } } catch { // 轮询失败不打扰操作,下一轮继续刷新。 } @@ -398,10 +462,16 @@ export default function HuyaTasksPage() { loadAll(); }, [loadAll]); + // 有活跃任务时 3 秒轮询;空闲时 15 秒轻量刷新,避免 Network 面板一直刷 tasks。 + const hasActiveTasks = useMemo( + () => tasks.some((task) => ['pending', 'running', 'planned'].includes(task.status)), + [tasks], + ); useEffect(() => { - const timer = setInterval(loadTasks, 3000); + const intervalMs = hasActiveTasks || wsConnected ? 3000 : 15000; + const timer = setInterval(loadTasks, intervalMs); return () => clearInterval(timer); - }, [loadTasks]); + }, [hasActiveTasks, loadTasks, wsConnected]); useEffect(() => { if (!autoOpenQrReady.current || qrTask) return; @@ -523,6 +593,16 @@ export default function HuyaTasksPage() { return map; }, [tasks]); + const latestQueryGameTaskByAccount = useMemo(() => { + const map = new Map(); + tasks.forEach((task) => { + if (task.task_type !== 'query_game_name') return; + const current = map.get(task.account_id); + if (!current || task.id > current.id) map.set(task.account_id, task); + }); + return map; + }, [tasks]); + const latestGoodsTaskByAccount = useMemo(() => { const map = new Map(); tasks.forEach((task) => { @@ -542,6 +622,26 @@ export default function HuyaTasksPage() { ? accountLabel(selectedAccounts[0]) : `已选 ${selectedIds.length} 个账号`; + const runningTaskBatchId = useMemo(() => { + // 支付监听会长期 running;绑定二维码若仍 running 也算活跃。 + // 但“已生成二维码/已识别角色”这类终态不应再锁 UI。 + const task = tasks.find((item) => { + if (!['pending', 'running'].includes(item.status)) return false; + if (item.task_type === 'create_recharge_order') { + const status = paymentStatus(item); + return !status || !['paid', 'timeout', 'stopped'].includes(status); + } + if (item.task_type === 'get_bind_qr') { + // 旧版轮询中断后可能残留 running,但已有二维码结果;不作为活跃批次。 + if (hasMiniQrcode(item)) return false; + } + return true; + }); + return task?.batch_id || null; + }, [tasks]); + const activeBatchId = runningTaskBatchId || (wsConnected ? batchId : null); + const batchBusy = Boolean(activeBatchId); + const sortedGoods = useMemo(() => { return [...goods].sort((a, b) => ( goodsRawNumber(a, 'category_sort') - goodsRawNumber(b, 'category_sort') @@ -652,6 +752,10 @@ export default function HuyaTasksPage() { message.warning('请先选择虎牙 CK'); return; } + if (activeBatchId) { + message.warning('当前已有虎牙批次在运行,请先停止或等待结束'); + return; + } if (taskType === 'create_recharge_order' && !selectedRechargeGoodsId) { message.warning('请先选择充值商品'); return; @@ -669,18 +773,26 @@ export default function HuyaTasksPage() { concurrency, payload: createPayload(taskType), }); - const finishTask = () => { - setBatchId(null); - setStarting(false); - void loadAll(); - }; setBatchId(result.batch_id); message.success(`已创建 ${taskTypes[taskType] || taskType},共 ${result.count} 个账号`); await loadTasks(); connectLogs(`/api/huya/ws/${result.batch_id}`, { - onClose: finishTask, - onResult: finishTask, - onError: finishTask, + onClose: () => { + setStarting(false); + setStopping(false); + void loadAll(); + }, + onResult: () => { + setBatchId(null); + setStarting(false); + setStopping(false); + void loadAll(); + }, + onError: () => { + setStarting(false); + setStopping(false); + void loadAll(); + }, }); } catch (e: unknown) { message.error(getErrorMessage(e)); @@ -688,6 +800,41 @@ export default function HuyaTasksPage() { } }; + const handleStopBatch = async () => { + if (!activeBatchId) { + // 没有可识别活跃批次时,尝试清理历史残留 running。 + const stale = tasks.find((item) => ['pending', 'running'].includes(item.status)); + if (!stale?.batch_id) { + message.warning('当前没有可停止的虎牙批次'); + return; + } + setStopping(true); + try { + const result = await huyaApi.stopBatch(stale.batch_id); + message.success(result.message); + setBatchId(null); + setStarting(false); + void loadAll(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setStopping(false); + } + return; + } + setStopping(true); + try { + const result = await huyaApi.stopBatch(activeBatchId); + message.success(result.message); + setStarting(false); + void loadTasks(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setStopping(false); + } + }; + const accountActionItems: MenuProps['items'] = QUICK_ACTIONS.map((item) => ({ key: item.key, icon: item.icon, @@ -766,27 +913,36 @@ export default function HuyaTasksPage() { const plannedCount = tasks.filter((task) => task.status === 'planned').length; const failedCount = tasks.filter((task) => ['failed', 'error'].includes(task.status)).length; const qrResult = qrTask?.result || null; + const qrQueryTask = qrTask ? latestQueryGameTaskByAccount.get(qrTask.account_id) || null : null; + const qrQueryResult = qrTask && qrQueryTask && qrQueryTask.id > qrTask.id ? qrQueryTask.result : null; + const qrQueryRunning = Boolean(qrQueryTask && qrTask && qrQueryTask.id > qrTask.id && qrQueryTask.status === 'running'); + const qrQueryFinished = Boolean(qrQueryTask && qrTask && qrQueryTask.id > qrTask.id && qrQueryTask.status !== 'running'); const qrImage = resultText(qrResult, 'mini_qrcode_image'); const qrBindPhase = resultText(qrResult, 'bind_phase'); - const qrBindReady = bindReadyForConfirm(qrTask); - const qrWaitingRole = qrTask?.status === 'running' && !qrBindReady; - const qrGameTitle = resultText(qrResult, 'game_title'); - const qrRoleName = resultText(qrResult, 'role_name'); - const qrGameRole = resultObject(qrResult, 'game_role'); + const qrBindReady = bindReadyForConfirm(qrTask) || Boolean(resultText(qrQueryResult, 'role_name')); + const qrRoleSourceResult = resultText(qrQueryResult, 'role_name') ? qrQueryResult : qrResult; + const qrWaitingRole = qrQueryRunning; + const qrGameTitle = resultText(qrRoleSourceResult, 'game_title'); + const qrRoleName = resultText(qrRoleSourceResult, 'role_name'); + const qrGameRole = resultObject(qrRoleSourceResult, 'game_role'); const qrRoleArea = typeof qrGameRole?.area_name === 'string' ? qrGameRole.area_name : ''; const qrRolePlat = typeof qrGameRole?.plat_name === 'string' ? qrGameRole.plat_name : ''; const qrRoleLine = qrBindReady ? [qrRolePlat, qrRoleArea, qrRoleName].filter(Boolean).join(' - ') : ''; const qrStatusText = qrBindReady ? '已识别角色,待确认' - : qrBindPhase === 'role_timeout' - ? '未检测到角色' - : qrBindPhase === 'qrcode_completed' - ? '等待角色同步' - : qrBindPhase === 'qrcode_scanned' - ? '已扫码' - : qrBindPhase === 'qrcode_expired' - ? '二维码已失效' - : '等待绑定'; + : qrQueryRunning + ? '查询角色中' + : qrQueryFinished + ? '未检测到角色' + : qrBindPhase === 'role_timeout' + ? '未检测到角色' + : qrBindPhase === 'qrcode_completed' + ? '等待角色同步' + : qrBindPhase === 'qrcode_scanned' + ? '已扫码' + : qrBindPhase === 'qrcode_expired' + ? '二维码已失效' + : '等待绑定'; const qrAccountName = qrTask ? resultProfileNick(qrResult) || qrTask.account_nickname || qrTask.account_uid || `#${qrTask.account_id}` : ''; @@ -811,14 +967,21 @@ export default function HuyaTasksPage() { : ''; const confirmQrBind = () => { if (!qrTask || !qrBindReady) return; + if (batchBusy) { + message.warning('当前已有虎牙批次在运行,请先停止或等待结束'); + return; + } const accountId = qrTask.account_id; setQrTask(null); void startTask('confirm_bind', [accountId]); }; const queryQrRole = () => { if (!qrTask) return; + if (batchBusy) { + message.warning('当前已有虎牙批次在运行,请先停止或等待结束'); + return; + } const accountId = qrTask.account_id; - setQrTask(null); void startTask('query_game_name', [accountId]); }; @@ -1090,7 +1253,7 @@ export default function HuyaTasksPage() { }} trigger={['click']} > - - {batchId && 批次 {batchId}} + {activeBatchId && 批次 {activeBatchId}} + @@ -1290,7 +1462,7 @@ export default function HuyaTasksPage() { size="small" block icon={item.icon} - disabled={!canTask || wsConnected} + disabled={!canTask || batchBusy || wsConnected} onClick={() => runContextAccountAction(item.key)} style={{ justifyContent: 'flex-start' }} > @@ -1448,7 +1620,7 @@ export default function HuyaTasksPage() { block icon={} loading={starting} - disabled={!canTask || selectedIds.length === 0 || wsConnected} + disabled={!canTask || selectedIds.length === 0 || batchBusy || wsConnected} onClick={() => startTask()} > 创建任务 @@ -1461,7 +1633,7 @@ export default function HuyaTasksPage() { @@ -1479,7 +1651,7 @@ export default function HuyaTasksPage() { size="small" icon={} onClick={() => startTask('refresh_goods')} - disabled={!canTask || selectedIds.length === 0 || wsConnected} + disabled={!canTask || selectedIds.length === 0 || batchBusy || wsConnected} > 刷新 @@ -1526,7 +1698,7 @@ export default function HuyaTasksPage() { type="primary" icon={} onClick={() => startTask('exchange_goods')} - disabled={!canTask || selectedIds.length === 0 || !selectedExchangeGoodsId || wsConnected} + disabled={!canTask || selectedIds.length === 0 || !selectedExchangeGoodsId || batchBusy || wsConnected} > 批量兑换商品 @@ -1541,7 +1713,7 @@ export default function HuyaTasksPage() { size="small" icon={} onClick={() => startTask('refresh_recharge_goods')} - disabled={!canTask || selectedIds.length === 0 || wsConnected} + disabled={!canTask || selectedIds.length === 0 || batchBusy || wsConnected} > 刷新 @@ -1575,7 +1747,7 @@ export default function HuyaTasksPage() { type="primary" icon={} onClick={() => startTask('create_recharge_order')} - disabled={!canTask || selectedIds.length === 0 || !selectedRechargeGoodsId || wsConnected} + disabled={!canTask || selectedIds.length === 0 || !selectedRechargeGoodsId || batchBusy || wsConnected} > 生成支付二维码 @@ -1747,7 +1919,7 @@ export default function HuyaTasksPage() { {qrAccountName} {qrStatusText} @@ -1788,7 +1960,7 @@ export default function HuyaTasksPage() {