diff --git a/core/douyu/activity_client.py b/core/douyu/activity_client.py index 1e308d0..9ede6e9 100644 --- a/core/douyu/activity_client.py +++ b/core/douyu/activity_client.py @@ -32,7 +32,7 @@ class DouyuActivityClient: ACF_CCN_API = "https://www.douyu.com/curl/csrfApi/getCsrfCookie?" ACF_CCN_FALLBACK_API = "https://www.douyu.com/curl/csrfNlApi/getCsrfCookie" SUBSCRIBE_API = "https://www.douyu.com/wgapi/livenc/asubscribe/userSubStatus" - ROLE_PARAM_API = "https://www.douyu.com/japi/carnivalApi/tencent/roleParam" + ROLE_PARAM_API = "https://www.douyu.com/japi/carnivalApi/v2/tencent/roleParam" BIND_INFO_API = "https://www.douyu.com/japi/carnivalApi/tencent/bindInfo" BIND_INFO_V2_API = "https://www.douyu.com/japi/carnivalApi/v2/tencent/bindInfo" BIND_API = "https://www.douyu.com/japi/carnivalApi/tencent/bind" @@ -194,15 +194,14 @@ class DouyuActivityClient: return ctn def get_bind_qr(self, act_alias: str) -> dict[str, Any]: - """生成腾讯游戏绑定二维码链接。""" + """生成腾讯游戏绑定二维码链接(v2 接口)。""" ctn = self.acf_ccn(refresh_subscribe=True) payload = self._request_json( "post", self.ROLE_PARAM_API, "获取绑定二维码", - data={"actAlias": act_alias, "ctn": ctn}, + params={"actAlias": act_alias}, headers={ - "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "Referer": "https://www.douyu.com/", }, ) @@ -211,6 +210,14 @@ class DouyuActivityClient: raise DouyuActivityError(payload.get("msg") or "获取绑定二维码失败") return {"url": url, "ctn": ctn, "raw": payload} + @staticmethod + def _first_present(data: dict[str, Any], *keys: str): + """按候选 key 取第一个非 None 值。""" + for key in keys: + if key in data and data.get(key) is not None: + return data.get(key) + return None + def bind_info(self, act_alias: str, *, v2: bool = False) -> dict[str, Any]: """查询绑定游戏账号信息。""" url = self.BIND_INFO_V2_API if v2 else self.BIND_INFO_API @@ -221,19 +228,56 @@ class DouyuActivityClient: params={"actAlias": act_alias}, headers={"Origin": "", "Referer": "https://www.douyu.com/"}, ) + if payload.get("error") not in (0, "0", None): + raise DouyuActivityError(payload.get("msg") or "查询绑定信息失败") data = payload.get("data") or {} game_account = data.get("gameAccount") or {} game_role = data.get("gameRole") or {} + tx_act = data.get("txAct") or {} + bind_account = data.get("bindAccount") + bind_role = data.get("bindRole") + bind_act = data.get("bindAct") + # 换绑倒计时在不同 alias/版本字段可能不一致,尽量兼容。 + change_role_wait_time = self._first_present( + data, + "changeRoleWaitTime", + "change_role_wait_time", + "changeBindWaitTime", + "changBindTime", + ) + if change_role_wait_time is None and isinstance(tx_act, dict): + change_role_wait_time = self._first_present( + tx_act, + "changeRoleWaitTime", + "changeBindWaitTime", + "changBindTime", + ) + can_change_role = self._first_present( + data, + "canChangeRole", + "can_change_role", + "canChangeBind", + ) + if can_change_role is None and isinstance(tx_act, dict): + can_change_role = self._first_present(tx_act, "canChangeRole", "canChangeBind") return { - "bind_account": data.get("bindAccount"), - "bind_role": data.get("bindRole"), - "bind_act": data.get("bindAct"), + "act_alias": act_alias, + "api_version": "v2" if v2 else "v1", + "bind_account": bind_account, + "bind_role": bind_role, + "bind_act": bind_act, + "need_bind_role": data.get("needBindRole"), + "need_bind_act": data.get("needBindAct"), + "is_bound_account": str(bind_account) == "1", + "is_bound_role": str(bind_role) == "1", + "is_bound_act": str(bind_act) == "1", "nickname": game_account.get("nick") or "", "role_name": game_role.get("roleName") or "", "area_name": game_role.get("areaName") or "", "plat_name": game_role.get("platName") or "", - "change_role_wait_time": data.get("changeRoleWaitTime"), - "can_change_role": data.get("canChangeRole"), + "tx_act": tx_act if isinstance(tx_act, dict) else {}, + "change_role_wait_time": change_role_wait_time, + "can_change_role": can_change_role, "raw": payload, } diff --git a/web/backend/services/douyu_runner.py b/web/backend/services/douyu_runner.py index 881a5a7..8787e9d 100644 --- a/web/backend/services/douyu_runner.py +++ b/web/backend/services/douyu_runner.py @@ -26,6 +26,11 @@ from .douyu_service import ( ) +DOUYU_LEGACY_BIND_ACT_ALIAS = "20250213NQCYX" +DOUYU_BIND_ROLE_POLL_SECONDS = 65 +DOUYU_BIND_ROLE_POLL_INTERVAL = 5 + + class DouyuBatchRunner: """批量执行斗鱼活动任务,通过队列推送实时日志。""" @@ -95,6 +100,179 @@ class DouyuBatchRunner: return f"{hours}小时{minutes}分{sec}秒" return f"{minutes}分{sec}秒" + @staticmethod + def _bind_qr_act_alias(config: dict) -> str: + """生成绑定二维码用的活动 alias。""" + bind_alias = str(config.get("bind_act_alias") or "").strip() + confirm_alias = str(config.get("confirm_act_alias") or "").strip() + if not bind_alias or bind_alias == DOUYU_LEGACY_BIND_ACT_ALIAS: + return confirm_alias or bind_alias + return bind_alias + + @staticmethod + def _query_bind_act_aliases(config: dict) -> list[str]: + """查询/轮询角色用的 alias 列表。 + + 现网最新绑定信息在 legacy(cjm);活动 alias 可能仍用于扫码/确认, + 所以按优先级去重返回多个,轮询时取“更像新扫码结果”的那个。 + """ + ordered = [ + str(config.get("legacy_act_alias") or "").strip(), + str(config.get("confirm_act_alias") or "").strip(), + str(config.get("bind_act_alias") or "").strip(), + ] + aliases: list[str] = [] + for alias in ordered: + if alias and alias not in aliases: + aliases.append(alias) + return aliases + + @staticmethod + def _confirm_act_alias(config: dict) -> str: + """确认绑定接口用的活动 alias。""" + confirm_alias = str(config.get("confirm_act_alias") or "").strip() + if confirm_alias: + return confirm_alias + bind_alias = str(config.get("bind_act_alias") or "").strip() + if bind_alias and bind_alias != DOUYU_LEGACY_BIND_ACT_ALIAS: + return bind_alias + return str(config.get("legacy_act_alias") or "").strip() + + # 兼容旧调用名 + @classmethod + def _current_bind_act_alias(cls, config: dict) -> str: + aliases = cls._query_bind_act_aliases(config) + return aliases[0] if aliases else cls._bind_qr_act_alias(config) + + @staticmethod + def _role_channel(bind_info: dict) -> str: + return " / ".join( + part for part in [bind_info.get("area_name"), bind_info.get("plat_name")] if part + ) + + @staticmethod + def _is_truthy_flag(value) -> bool: + if value is True: + return True + if value is False or value is None: + return False + text = str(value).strip().lower() + return text in {"1", "true", "yes", "y"} + + @classmethod + def _is_bound_act(cls, bind_info: dict | None) -> bool: + if not bind_info: + return False + return cls._is_truthy_flag(bind_info.get("is_bound_act")) + + @classmethod + def _can_change_role(cls, bind_info: dict | None) -> bool: + """综合 can_change_role 与换绑倒计时判断是否允许换绑。""" + if not bind_info: + return True + wait_time = cls._to_int(bind_info.get("change_role_wait_time")) + if wait_time is not None and wait_time > 0: + return False + if bind_info.get("can_change_role") is not None: + return cls._is_truthy_flag(bind_info.get("can_change_role")) + # 已绑定但接口没给倒计时/开关时,默认允许(避免误杀首次绑定) + return True + + @classmethod + def _is_change_cooling(cls, bind_info: dict | None) -> bool: + """是否处于换绑冷却:已有绑定角色且当前不可换绑。""" + if not bind_info: + return False + role_name = str(bind_info.get("role_name") or "").strip() + is_bound = cls._is_bound_act(bind_info) or bool(role_name) + if not is_bound: + return False + return not cls._can_change_role(bind_info) + + @classmethod + def _is_pending_role( + cls, + bind_info: dict | None, + *, + baseline_role_name: str = "", + baseline_is_bound_act: bool = False, + ) -> bool: + """判断是否出现了可确认的扫码角色。 + + 规则: + 1. 必须有角色名 + 2. 已绑定同一角色(或无 baseline 的已绑定)不算 pending + 3. 角色名相对 baseline 变化,或从已绑定变成待确认,算 pending + 4. need_bind_act / need_bind_role 且角色相对 baseline 有变化,算 pending + 5. 无 baseline 且未绑定但有角色,视为待确认残留 + """ + if not bind_info: + return False + role_name = str(bind_info.get("role_name") or "").strip() + if not role_name: + return False + is_bound_act = cls._is_bound_act(bind_info) + need_bind_act = cls._is_truthy_flag(bind_info.get("need_bind_act")) + need_bind_role = cls._is_truthy_flag(bind_info.get("need_bind_role")) + role_changed = bool(baseline_role_name) and role_name != baseline_role_name + if is_bound_act: + # 已绑定:仅当相对 baseline 角色发生变化时才视为新扫码结果 + return role_changed + if need_bind_act or need_bind_role: + if not baseline_role_name: + return True + return role_changed or baseline_is_bound_act + if not baseline_role_name: + return True + if role_changed: + return True + # 同一角色从已绑定变为未绑定 + return baseline_is_bound_act + + def _bind_snapshot(self, bind_info: dict | None) -> dict: + info = bind_info or {} + role_name = str(info.get("role_name") or "") + return { + "role_name": role_name, + "area_name": info.get("area_name") or "", + "plat_name": info.get("plat_name") or "", + "nickname": info.get("nickname") or "", + "is_bound_act": self._is_bound_act(info), + "is_bound_role": self._is_truthy_flag(info.get("is_bound_role")), + "is_bound_account": self._is_truthy_flag(info.get("is_bound_account")), + "need_bind_act": self._is_truthy_flag(info.get("need_bind_act")), + "need_bind_role": self._is_truthy_flag(info.get("need_bind_role")), + "change_role_wait_time": self._to_int(info.get("change_role_wait_time")), + "can_change_role": self._can_change_role(info), + "bind_info": info, + } + + def _format_bind_summary(self, bind_info: dict | None, *, pending: bool | None = None) -> str: + snap = self._bind_snapshot(bind_info) + role = snap["role_name"] or "-" + area = snap["area_name"] or "-" + plat = snap["plat_name"] or "-" + pending_text = "" + if pending is not None: + pending_text = f" pending={1 if pending else 0}" + return ( + f"role={role} area={area} plat={plat}" + f" bound_act={1 if snap['is_bound_act'] else 0}" + f" bound_role={1 if snap['is_bound_role'] else 0}" + f" need_act={1 if snap['need_bind_act'] else 0}" + f" need_role={1 if snap['need_bind_role'] else 0}" + f" wait={snap['change_role_wait_time'] if snap['change_role_wait_time'] is not None else '-'}" + f"{pending_text}" + ) + + def _apply_bind_info_to_account(self, account: Account, bind_info: dict, status: str) -> None: + role_name = str(bind_info.get("role_name") or "") + account.game_name = role_name or account.game_name + account.game_channel = self._role_channel(bind_info) or account.game_channel + account.change_role_wait_time = self._to_int(bind_info.get("change_role_wait_time")) + account.bind_status = status + account.updated_at = datetime.now(timezone.utc) + def _mark_task( self, db: Session, @@ -110,6 +288,20 @@ class DouyuBatchRunner: task.finished_at = datetime.now(timezone.utc) db.commit() + def _update_task_progress( + self, + db: Session, + task: DouyuTask, + status: str, + message: str, + result: dict | None = None, + ) -> None: + task.status = status + task.message = message[:512] + if result is not None: + task.result = result + db.commit() + def _upsert_goods(self, db: Session, goods: list[dict]) -> None: now = datetime.now(timezone.utc) for raw in goods: @@ -144,6 +336,239 @@ class DouyuBatchRunner: def _client(self, cookie: str) -> DouyuActivityClient: return DouyuActivityClient(cookie, logger=lambda msg: self._push_log("debug", msg)) + def _fetch_bind_info_candidates( + self, + client: DouyuActivityClient, + aliases: list[str], + ) -> list[dict]: + """按多个 actAlias 查询绑定信息,保留成功结果。""" + results: list[dict] = [] + for alias in aliases: + if not alias: + continue + try: + info = client.bind_info(alias, v2=True) + except DouyuActivityError as exc: + self._push_log("warning", f"查询绑定信息失败 act={alias}: {exc}") + continue + info = {**info, "act_alias": alias} + results.append(info) + return results + + def _pick_bind_info( + self, + candidates: list[dict], + *, + baseline_role_name: str = "", + baseline_is_bound_act: bool = False, + prefer_pending: bool = True, + prefer_aliases: list[str] | None = None, + ) -> dict | None: + """从多个 alias 结果里挑最有用的绑定信息。 + + - prefer_pending=True:优先选“待确认/新扫码角色”(通常来自 cjm) + - prefer_aliases:在同等条件下优先指定 alias(如活动当前绑定) + """ + if not candidates: + return None + + def _alias_rank(info: dict) -> int: + alias = str(info.get("act_alias") or "") + if not prefer_aliases: + return 0 + try: + return prefer_aliases.index(alias) + except ValueError: + return len(prefer_aliases) + 1 + + ranked = sorted(enumerate(candidates), key=lambda item: (_alias_rank(item[1]), item[0])) + ordered = [item[1] for item in ranked] + + if prefer_pending: + for info in ordered: + if self._is_pending_role( + info, + baseline_role_name=baseline_role_name, + baseline_is_bound_act=baseline_is_bound_act, + ): + return info + for info in ordered: + if str(info.get("role_name") or "").strip(): + return info + return ordered[0] + + def _pick_baseline_bind_info( + self, + candidates: list[dict], + config: dict, + ) -> dict | None: + """选“扫码前当前已绑定角色”作为 baseline。 + + 活动 alias(20260120QYOOB)只反映当前已绑定; + cjm 才是换绑最新态。baseline 应优先活动 alias 的已绑定结果, + 避免把 cjm 上的待确认新角色误当成扫码前旧角色。 + """ + if not candidates: + return None + prefer = [] + for key in ("confirm_act_alias", "bind_act_alias", "legacy_act_alias"): + alias = str(config.get(key) or "").strip() + if alias and alias not in prefer: + prefer.append(alias) + + # 先找“已绑定且有角色”的活动结果 + for alias in prefer: + for info in candidates: + if str(info.get("act_alias") or "") != alias: + continue + if self._is_bound_act(info) and str(info.get("role_name") or "").strip(): + return info + + # 再退回任意有角色的结果(仍按活动 alias 优先) + return self._pick_bind_info( + candidates, + prefer_pending=False, + prefer_aliases=prefer, + ) + + def _wait_bind_role_result( + self, + client: DouyuActivityClient, + db: Session, + task: DouyuTask, + account: Account, + query_aliases: list[str], + result: dict, + baseline_role_name: str = "", + baseline_is_bound_act: bool = False, + ) -> tuple[str, dict]: + """生成二维码后轮询绑定信息,直到识别到待确认角色、超时或停止。""" + deadline = time.monotonic() + DOUYU_BIND_ROLE_POLL_SECONDS + result["bind_polling"] = True + result["bind_phase"] = result.get("bind_phase") or "waiting_scan" + result["bind_ready_for_confirm"] = False + result["bind_confirmed"] = False + result["baseline_role_name"] = baseline_role_name + result["baseline_is_bound_act"] = baseline_is_bound_act + result["query_act_aliases"] = query_aliases + self._push_log( + "info", + "开始轮询绑定角色 " + f"aliases={','.join(query_aliases) or '-'} " + f"baseline={baseline_role_name or '-'} bound={1 if baseline_is_bound_act else 0}", + ) + self._update_task_progress(db, task, "running", "已生成绑定二维码,等待扫码绑定", result) + + poll_count = 0 + last_summary = "" + while not self._stop.is_set() and time.monotonic() < deadline: + if self._stop.wait(DOUYU_BIND_ROLE_POLL_INTERVAL): + break + + candidates = self._fetch_bind_info_candidates(client, query_aliases) + if not candidates: + result["bind_poll_error"] = "所有 actAlias 查询绑定信息失败" + self._update_task_progress(db, task, "running", "等待绑定角色同步: 查询失败", result) + continue + + poll_count += 1 + bind_info = self._pick_bind_info( + candidates, + baseline_role_name=baseline_role_name, + baseline_is_bound_act=baseline_is_bound_act, + prefer_pending=True, + prefer_aliases=query_aliases, + ) or candidates[0] + snapshot = self._bind_snapshot(bind_info) + is_pending_role = self._is_pending_role( + bind_info, + baseline_role_name=baseline_role_name, + baseline_is_bound_act=baseline_is_bound_act, + ) + query_alias = str(bind_info.get("act_alias") or "") + summary = ( + f"act={query_alias or '-'} " + f"{self._format_bind_summary(bind_info, pending=is_pending_role)}" + ) + # 字段变化或每 3 次打印一次,避免刷屏但仍能看到过程 + if summary != last_summary or poll_count == 1 or poll_count % 3 == 0: + self._push_log("info", f"轮询绑定#{poll_count}: {summary}") + last_summary = summary + + # 注意:未识别到新角色时,不要把当前已绑定角色写进 role_name, + # 否则前端会把旧角色误当成“待确认角色/查询结果”。 + if is_pending_role: + result.update({ + **snapshot, + "act_alias": query_alias, + "query_act_alias": query_alias, + "bind_ready_for_confirm": True, + "bind_confirmed": False, + "bind_phase": "role_ready", + "bind_polling": False, + "poll_count": poll_count, + "bind_summary": summary, + "bind_candidates": [ + { + "act_alias": item.get("act_alias"), + "role_name": item.get("role_name"), + "is_bound_act": self._is_bound_act(item), + } + for item in candidates + ], + }) + role_name = snapshot["role_name"] + self._apply_bind_info_to_account(account, bind_info, "game_queried") + self._push_log("success", f"识别到待确认角色: {role_name} (act={query_alias})") + self._update_task_progress( + db, + task, + "running", + f"已识别角色: {role_name},待确认绑定", + result, + ) + return role_name, result + + if snapshot["role_name"] and snapshot["is_bound_act"]: + result["current_role_name"] = snapshot["role_name"] + result["current_area_name"] = snapshot["area_name"] + result["current_plat_name"] = snapshot["plat_name"] + + result.update({ + "bind_info": bind_info, + "act_alias": query_alias, + "query_act_alias": query_alias, + "is_bound_act": snapshot["is_bound_act"], + "is_bound_role": snapshot["is_bound_role"], + "is_bound_account": snapshot["is_bound_account"], + "need_bind_act": snapshot["need_bind_act"], + "need_bind_role": snapshot["need_bind_role"], + "change_role_wait_time": snapshot["change_role_wait_time"], + "can_change_role": snapshot["can_change_role"], + "role_name": "", + "area_name": "", + "plat_name": "", + "bind_ready_for_confirm": False, + "bind_confirmed": False, + "bind_phase": "waiting_scan", + "bind_polling": True, + "poll_count": poll_count, + "bind_summary": summary, + }) + self._update_task_progress(db, task, "running", f"等待扫码绑定 ({summary})", result) + + result["bind_polling"] = False + result["bind_ready_for_confirm"] = False + result["bind_phase"] = "stopped" if self._stop.is_set() else "role_timeout" + result["poll_count"] = poll_count + if last_summary: + result["bind_summary"] = last_summary + self._push_log( + "warning", + f"轮询结束 phase={result['bind_phase']} polls={poll_count} last={last_summary or '-'}", + ) + return "", result + def _execute_refresh_goods(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): client = self._client(cookie) result = client.list_goods(manual_id=config["manual_id"], rid=config["rid"]) @@ -155,22 +580,319 @@ class DouyuBatchRunner: def _execute_get_bind_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): client = self._client(cookie) - result = client.get_bind_qr(str(config["bind_act_alias"])) + qr_act_alias = self._bind_qr_act_alias(config) + query_aliases = self._query_bind_act_aliases(config) + if not qr_act_alias and not query_aliases: + self._mark_task(db, task, "failed", "请先配置绑定活动 actAlias") + return + if qr_act_alias and qr_act_alias not in query_aliases: + query_aliases = [qr_act_alias, *query_aliases] + + before_candidates = self._fetch_bind_info_candidates(client, query_aliases) + if not before_candidates: + self._mark_task(db, task, "failed", "查询绑定信息失败,请检查 Cookie 或 actAlias") + return + # baseline 用活动 alias 的“当前已绑定”;pending 检测优先 cjm 的换绑最新态 + before = self._pick_baseline_bind_info(before_candidates, config) or before_candidates[0] + # 换绑冷却必须看活动当前绑定(QYOOB),不要用 cjm + cooldown_info = self._pick_change_wait_bind_info(before_candidates, config) or before + pending_before = self._pick_bind_info( + before_candidates, + baseline_role_name=str(before.get("role_name") or ""), + baseline_is_bound_act=self._is_bound_act(before), + prefer_pending=True, + prefer_aliases=query_aliases, + ) or before + before_snapshot = self._bind_snapshot(before) + cooldown_snapshot = self._bind_snapshot(cooldown_info) + current_role_name = before_snapshot["role_name"] or cooldown_snapshot["role_name"] + wait_time = cooldown_snapshot["change_role_wait_time"] + self._push_log( + "info", + "绑定前状态 " + f"qr_act={qr_act_alias or '-'} query={','.join(query_aliases)} " + f"baseline_hit={before.get('act_alias') or '-'} {self._format_bind_summary(before)} " + f"cooldown_hit={cooldown_info.get('act_alias') or '-'} " + f"{self._format_bind_summary(cooldown_info)} " + f"pending_hit={pending_before.get('act_alias') or '-'} " + f"{self._format_bind_summary(pending_before)}", + ) + + # 生成二维码前强制检查换绑冷却:冷却中直接失败,绝不发码 + if self._is_change_cooling(cooldown_info): + role_label = current_role_name or cooldown_snapshot["role_name"] or "当前角色" + wait_text = self._format_wait_time(wait_time) or "冷却中" + self._push_log( + "warning", + f"换绑冷却中,跳过生成二维码 role={role_label} wait={wait_time if wait_time is not None else '-'} " + f"can={cooldown_info.get('can_change_role')}", + ) + result = { + "act_alias": qr_act_alias or cooldown_info.get("act_alias") or before.get("act_alias"), + "query_act_alias": cooldown_info.get("act_alias") or before.get("act_alias"), + "query_act_aliases": query_aliases, + "before_bind_info": before, + "cooldown_bind_info": cooldown_info, + **cooldown_snapshot, + "current_role_name": role_label if role_label != "当前角色" else current_role_name, + "current_area_name": cooldown_snapshot["area_name"] or before_snapshot["area_name"], + "current_plat_name": cooldown_snapshot["plat_name"] or before_snapshot["plat_name"], + "change_role_wait_time": wait_time, + "change_role_wait_text": self._format_wait_time(wait_time), + "bind_ready_for_confirm": False, + "bind_confirmed": bool(cooldown_snapshot["is_bound_act"] or before_snapshot["is_bound_act"]), + "bind_phase": "change_waiting", + "bind_polling": False, + } + self._apply_bind_info_to_account( + account, + cooldown_info if cooldown_snapshot["role_name"] else before, + "bind_confirmed" if result["bind_confirmed"] else "game_queried", + ) + self._mark_task( + db, + task, + "failed", + f"{role_label} 暂不能换绑,剩余 {wait_text}", + result, + ) + return + + # 生成码前若 cjm 已有待确认新角色,直接返回,无需重复扫码。 + pending_snapshot = self._bind_snapshot(pending_before) + if self._is_pending_role( + pending_before, + baseline_role_name=current_role_name, + baseline_is_bound_act=before_snapshot["is_bound_act"], + ) and pending_snapshot["role_name"]: + result = { + "act_alias": qr_act_alias or pending_before.get("act_alias"), + "query_act_alias": pending_before.get("act_alias"), + "query_act_aliases": query_aliases, + "before_bind_info": before, + **pending_snapshot, + "current_role_name": current_role_name, + "current_area_name": before_snapshot["area_name"], + "current_plat_name": before_snapshot["plat_name"], + "bind_ready_for_confirm": True, + "bind_confirmed": False, + "bind_phase": "role_ready", + "bind_polling": False, + } + self._apply_bind_info_to_account(account, pending_before, "game_queried") + self._mark_task( + db, + task, + "success", + f"已识别角色: {pending_snapshot['role_name']},待确认绑定", + result, + ) + return + + if not qr_act_alias: + self._mark_task(db, task, "failed", "请先配置绑定二维码活动 actAlias") + return + + self._push_log( + "info", + f"换绑校验通过,开始生成二维码 act={qr_act_alias} " + f"role={current_role_name or '-'} wait={wait_time if wait_time is not None else 0}", + ) + qr_result = client.get_bind_qr(qr_act_alias) + result = { + **qr_result, + "act_alias": qr_act_alias, + "query_act_aliases": query_aliases, + "before_bind_info": before, + "current_role_name": current_role_name, + "current_area_name": before_snapshot["area_name"], + "current_plat_name": before_snapshot["plat_name"], + "role_name": "", + "area_name": "", + "plat_name": "", + "bind_ready_for_confirm": False, + "bind_confirmed": False, + "bind_phase": "waiting_scan", + "bind_polling": True, + } account.bind_status = "bind_qr_generated" account.updated_at = datetime.now(timezone.utc) - self._mark_task(db, task, "success", "绑定二维码已生成", result) + # 关键:先把二维码 progress 出去,前端 running 期间即可弹窗扫码。 + self._update_task_progress(db, task, "running", "已生成绑定二维码,等待扫码绑定", result) + + role_name, result = self._wait_bind_role_result( + client, + db, + task, + account, + query_aliases, + result, + baseline_role_name=current_role_name, + baseline_is_bound_act=before_snapshot["is_bound_act"], + ) + if role_name: + self._mark_task(db, task, "success", f"已识别角色: {role_name},待确认绑定", result) + return + if result.get("bind_phase") == "stopped": + self._mark_task(db, task, "stopped", "任务已停止", result) + return + last_summary = str(result.get("bind_summary") or "") + timeout_msg = "已生成绑定二维码,未检测到新扫码角色" + if current_role_name: + timeout_msg = f"{timeout_msg}(当前仍是 {current_role_name})" + if last_summary: + timeout_msg = f"{timeout_msg} | {last_summary}" + self._mark_task(db, task, "success", timeout_msg, result) def _execute_confirm_bind(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): client = self._client(cookie) - before = client.bind_info(str(config["legacy_act_alias"]), v2=True) - result = client.confirm_bind(str(config["confirm_act_alias"])) - after = client.bind_info(str(config["confirm_act_alias"]), v2=False) - role_name = after.get("role_name") or before.get("role_name") or "" - account.game_name = role_name or account.game_name - account.game_channel = " / ".join(part for part in [after.get("area_name"), after.get("plat_name")] if part) - account.bind_status = "bind_confirmed" - account.updated_at = datetime.now(timezone.utc) - self._mark_task(db, task, "success", f"绑定成功: {role_name or '已确认'}", {"before": before, "confirm": result, "bind_info": after}) + confirm_alias = self._confirm_act_alias(config) + query_aliases = self._query_bind_act_aliases(config) + if confirm_alias and confirm_alias not in query_aliases: + query_aliases = [confirm_alias, *query_aliases] + if not query_aliases: + self._mark_task(db, task, "failed", "请先配置绑定活动 actAlias") + return + + before_candidates = self._fetch_bind_info_candidates(client, query_aliases) + if not before_candidates: + self._mark_task(db, task, "failed", "查询绑定信息失败,请检查 Cookie 或 actAlias") + return + before = self._pick_bind_info( + before_candidates, + baseline_role_name="", + baseline_is_bound_act=False, + prefer_pending=True, + prefer_aliases=query_aliases, + ) or before_candidates[0] + before_snapshot = self._bind_snapshot(before) + role_name = before_snapshot["role_name"] + query_alias = str(before.get("act_alias") or "") + self._push_log( + "info", + f"确认前状态 confirm_act={confirm_alias or '-'} hit={query_alias or '-'} " + f"{self._format_bind_summary(before)}", + ) + if not role_name: + self._mark_task( + db, + task, + "failed", + "尚未识别到待确认角色,请先扫码完成绑定", + { + "act_alias": confirm_alias or query_alias, + "query_act_alias": query_alias, + "query_act_aliases": query_aliases, + "before_bind_info": before, + **before_snapshot, + "bind_ready_for_confirm": False, + "bind_confirmed": False, + "bind_phase": "waiting_role", + }, + ) + return + + if before_snapshot["is_bound_act"]: + self._apply_bind_info_to_account(account, before, "bind_confirmed") + self._mark_task( + db, + task, + "success", + f"已绑定: {role_name}", + { + "act_alias": confirm_alias or query_alias, + "query_act_alias": query_alias, + "query_act_aliases": query_aliases, + "before_bind_info": before, + **before_snapshot, + "bind_ready_for_confirm": True, + "bind_confirmed": True, + "bind_phase": "confirmed", + }, + ) + return + + # 确认接口优先用配置的确认 alias;没有则回退到命中查询的 alias + use_confirm_alias = confirm_alias or query_alias + confirm_result = client.confirm_bind(use_confirm_alias) + try: + after_candidates = self._fetch_bind_info_candidates(client, query_aliases) + after = self._pick_bind_info( + after_candidates, + baseline_role_name="", + baseline_is_bound_act=False, + prefer_pending=False, + ) if after_candidates else None + if after is None: + raise DouyuActivityError("确认后回查绑定信息失败") + except DouyuActivityError as exc: + # 确认接口已成功时,回查失败仍按确认成功处理,但保留错误信息。 + self._apply_bind_info_to_account(account, before, "bind_confirmed") + self._mark_task( + db, + task, + "success", + f"绑定成功: {role_name}", + { + "act_alias": use_confirm_alias, + "query_act_alias": query_alias, + "query_act_aliases": query_aliases, + "before_bind_info": before, + "confirm": confirm_result, + **before_snapshot, + "bind_ready_for_confirm": True, + "bind_confirmed": True, + "bind_phase": "confirmed", + "refresh_error": str(exc), + }, + ) + return + + after_snapshot = self._bind_snapshot(after) + final_info = after if after_snapshot["role_name"] else before + final_snapshot = after_snapshot if after_snapshot["role_name"] else before_snapshot + final_role_name = final_snapshot["role_name"] or role_name + if not after_snapshot["is_bound_act"]: + self._apply_bind_info_to_account(account, final_info, "game_queried") + self._mark_task( + db, + task, + "failed", + f"确认绑定未生效: {final_role_name}", + { + "act_alias": use_confirm_alias, + "query_act_alias": after.get("act_alias") or query_alias, + "query_act_aliases": query_aliases, + "before_bind_info": before, + "confirm": confirm_result, + **final_snapshot, + "bind_ready_for_confirm": True, + "bind_confirmed": False, + "bind_phase": "confirm_failed", + "after_bind_info": after, + }, + ) + return + + self._apply_bind_info_to_account(account, final_info, "bind_confirmed") + self._mark_task( + db, + task, + "success", + f"绑定成功: {final_role_name}", + { + "act_alias": use_confirm_alias, + "query_act_alias": after.get("act_alias") or query_alias, + "query_act_aliases": query_aliases, + "before_bind_info": before, + "confirm": confirm_result, + "after_bind_info": after, + **final_snapshot, + "bind_ready_for_confirm": True, + "bind_confirmed": True, + "bind_phase": "confirmed", + }, + ) def _execute_create_elite_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): client = self._client(cookie) @@ -179,7 +901,7 @@ class DouyuBatchRunner: ctn = client.acf_ccn(refresh_subscribe=True) result = client.create_elite_qr( ctn=ctn, - act_alias=str(config["confirm_act_alias"]), + act_alias=self._current_bind_act_alias(config) or str(config["confirm_act_alias"]), amount=int(config["elite_amount"]), room_id=str(config["room_id"]), ) @@ -285,27 +1007,155 @@ class DouyuBatchRunner: def _execute_query_game_name(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): client = self._client(cookie) - result = client.bind_info(str(config["confirm_act_alias"]), v2=False) - if not result.get("role_name"): - result = client.bind_info(str(config["legacy_act_alias"]), v2=True) - role_name = result.get("role_name") or "" - account.game_name = role_name - account.game_channel = " / ".join(part for part in [result.get("area_name"), result.get("plat_name")] if part) - account.bind_status = "game_queried" if role_name else "game_not_bound" - account.change_role_wait_time = self._to_int(result.get("change_role_wait_time")) - account.updated_at = datetime.now(timezone.utc) - message = f"游戏名: {role_name}" if role_name else "未获取到游戏名" + query_aliases = self._query_bind_act_aliases(config) + if not query_aliases: + self._mark_task(db, task, "failed", "请先配置绑定活动 actAlias") + return + candidates = self._fetch_bind_info_candidates(client, query_aliases) + if not candidates: + self._mark_task(db, task, "failed", "查询绑定信息失败,请检查 Cookie 或 actAlias") + return + bind_info = self._pick_bind_info( + candidates, + prefer_pending=True, + prefer_aliases=query_aliases, + ) or candidates[0] + snapshot = self._bind_snapshot(bind_info) + role_name = snapshot["role_name"] + source_alias = str(bind_info.get("act_alias") or "") + summary = f"act={source_alias or '-'} {self._format_bind_summary(bind_info)}" + self._push_log( + "info", + "查询角色 " + f"aliases={','.join(query_aliases)} hit={source_alias or '-'} " + f"{self._format_bind_summary(bind_info)}", + ) + self._apply_bind_info_to_account(account, bind_info, "game_queried" if role_name else "game_not_bound") + pending = bool(role_name) and not snapshot["is_bound_act"] + result = { + "act_alias": source_alias, + "query_act_alias": source_alias, + "query_act_aliases": query_aliases, + **snapshot, + "bind_ready_for_confirm": pending, + "bind_confirmed": snapshot["is_bound_act"], + "bind_phase": ( + "confirmed" if snapshot["is_bound_act"] + else ("role_ready" if role_name else "waiting_role") + ), + "bind_summary": summary, + "bind_candidates": [ + { + "act_alias": item.get("act_alias"), + "role_name": item.get("role_name"), + "is_bound_act": self._is_bound_act(item), + } + for item in candidates + ], + } + if not role_name: + message = f"未获取到游戏名 | {summary}" + elif snapshot["is_bound_act"]: + message = f"当前已绑定: {role_name} | {summary}" + else: + message = f"待确认角色: {role_name} | {summary}" self._mark_task(db, task, "success" if role_name else "failed", message, result) + def _pick_change_wait_bind_info(self, candidates: list[dict], config: dict) -> dict | None: + """换绑倒计时优先看活动当前绑定(QYOOB),不是 cjm 换绑最新态。""" + if not candidates: + return None + # 1) 优先活动 alias 上有角色的结果 + baseline = self._pick_baseline_bind_info(candidates, config) + if baseline is not None: + wait = self._to_int(baseline.get("change_role_wait_time")) + if wait is not None: + return baseline + # 2) 任意带 wait_time 的结果里取 wait 最大的 + with_wait = [] + for info in candidates: + wait = self._to_int(info.get("change_role_wait_time")) + if wait is not None: + with_wait.append((wait, info)) + if with_wait: + with_wait.sort(key=lambda item: item[0], reverse=True) + return with_wait[0][1] + # 3) 回退 baseline / 首个有角色 + return baseline or self._pick_bind_info( + candidates, + prefer_pending=False, + prefer_aliases=[ + str(config.get("confirm_act_alias") or "").strip(), + str(config.get("bind_act_alias") or "").strip(), + str(config.get("legacy_act_alias") or "").strip(), + ], + ) + def _execute_query_change_bind_time(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): client = self._client(cookie) - result = client.bind_info(str(config["confirm_act_alias"]), v2=True) - wait_time = self._to_int(result.get("change_role_wait_time")) + query_aliases = self._query_bind_act_aliases(config) + if not query_aliases: + self._mark_task(db, task, "failed", "请先配置绑定活动 actAlias") + return + candidates = self._fetch_bind_info_candidates(client, query_aliases) + if not candidates: + self._mark_task(db, task, "failed", "查询绑定信息失败,请检查 Cookie 或 actAlias") + return + # 换绑时间看“当前已绑定”活动态,不要优先 cjm(cjm 常无 changeRoleWaitTime) + bind_info = self._pick_change_wait_bind_info(candidates, config) or candidates[0] + snapshot = self._bind_snapshot(bind_info) + wait_time = snapshot["change_role_wait_time"] account.change_role_wait_time = wait_time account.bind_status = "change_time_queried" account.updated_at = datetime.now(timezone.utc) - result["change_role_wait_text"] = self._format_wait_time(wait_time) - self._mark_task(db, task, "success", f"换绑剩余: {result['change_role_wait_text'] or '-'}", result) + source_alias = str(bind_info.get("act_alias") or "") + wait_text = self._format_wait_time(wait_time) + can_change = snapshot["can_change_role"] + role_name = snapshot["role_name"] or "-" + if wait_time is None: + if can_change is False: + status_text = "不可换绑(接口未返回倒计时)" + elif can_change is True: + status_text = "可换绑" + else: + status_text = "未返回换绑倒计时" + elif wait_time <= 0: + status_text = "可换绑" + wait_text = "0" + else: + status_text = f"剩余 {wait_text}" + result = { + "act_alias": source_alias, + "query_act_alias": source_alias, + "query_act_aliases": query_aliases, + **snapshot, + "change_role_wait_text": wait_text, + "bind_ready_for_confirm": bool(snapshot["role_name"]) and not snapshot["is_bound_act"], + "bind_confirmed": snapshot["is_bound_act"], + "bind_summary": f"act={source_alias or '-'} {self._format_bind_summary(bind_info)}", + "bind_candidates": [ + { + "act_alias": item.get("act_alias"), + "role_name": item.get("role_name"), + "is_bound_act": self._is_bound_act(item), + "change_role_wait_time": self._to_int(item.get("change_role_wait_time")), + "can_change_role": item.get("can_change_role"), + } + for item in candidates + ], + } + self._push_log( + "info", + f"查询换绑时间 hit={source_alias or '-'} role={role_name} " + f"wait={wait_time if wait_time is not None else '-'} can={can_change}", + ) + self._mark_task( + db, + task, + "success", + f"{role_name} {status_text} | act={source_alias or '-'}", + result, + ) def _execute_query_limited_goods(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): client = self._client(cookie) diff --git a/web/backend/services/douyu_service.py b/web/backend/services/douyu_service.py index 769524c..f5af109 100644 --- a/web/backend/services/douyu_service.py +++ b/web/backend/services/douyu_service.py @@ -34,7 +34,7 @@ SUPPORTED_DOUYU_TASK_TYPES = { DOUYU_CONFIG_DEFAULTS = { "manual_id": "G4KA4Qnz4LDp7", "rid": "9263298", - "bind_act_alias": "20250213NQCYX", + "bind_act_alias": "20260120QYOOB", "confirm_act_alias": "20260120QYOOB", "legacy_act_alias": "cjm", "room_id": "9263298", @@ -64,6 +64,10 @@ def apply_douyu_config_defaults(config: DouyuConfig) -> bool: """补齐斗鱼配置默认值,返回是否发生变更。""" changed = False for field in DOUYU_CONFIG_FIELDS: + if field == "bind_act_alias" and str(getattr(config, field, "") or "").strip() == "20250213NQCYX": + setattr(config, field, DOUYU_CONFIG_DEFAULTS[field]) + changed = True + continue normalized = douyu_config_value(field, getattr(config, field, None)) if getattr(config, field, None) != normalized: setattr(config, field, normalized) diff --git a/web/frontend/src/pages/DouyuTasksPage.tsx b/web/frontend/src/pages/DouyuTasksPage.tsx index fc97157..d29c130 100644 --- a/web/frontend/src/pages/DouyuTasksPage.tsx +++ b/web/frontend/src/pages/DouyuTasksPage.tsx @@ -1,11 +1,12 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - Button, Card, Col, Form, Input, InputNumber, Modal, QRCode, Row, Select, Space, Table, Tag, Typography, theme, + Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tag, Typography, } from 'antd'; import type { TableProps } from 'antd'; import { - CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined, GiftOutlined, LinkOutlined, - QrcodeOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined, + CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined, GiftOutlined, + ImportOutlined, QrcodeOutlined, ReloadOutlined, + SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined, } from '@ant-design/icons'; import { douyuApi, @@ -22,165 +23,286 @@ import { message } from '../utils/antdMessage'; const { Text } = Typography; -const FALLBACK_TASK_TYPES: Record = { - get_bind_qr: '获取绑定二维码', - confirm_bind: '确认绑定', - create_elite_qr: '开通精英宝典30', - create_gold_qr: '充值鱼翅', - donate_elite_gift: '赠送精英令', - query_points: '一键查询积分', - exchange_goods: '兑换商品', - query_game_name: '一键获取游戏名', - query_change_bind_time: '一键查询换绑时间', - query_limited_goods: '一键查询限兑商品', - query_gold_balance: '一键查询鱼刺余额', - refresh_goods: '刷新商品列表', - query_exchange_records: '一键查询兑换记录', - prefetch_csrf_token: '一键获取兑换 CSRF Token', -}; - -const TASK_ICONS: Record = { - get_bind_qr: , - confirm_bind: , - create_elite_qr: , - create_gold_qr: , - donate_elite_gift: , - query_points: , - exchange_goods: , - query_game_name: , - query_change_bind_time: , - query_limited_goods: , - query_gold_balance: , - refresh_goods: , - query_exchange_records: , - prefetch_csrf_token: , -}; +const QUICK_ACTIONS = [ + { key: 'get_bind_qr', icon: }, + { key: 'confirm_bind', icon: }, + { key: 'create_elite_qr', icon: }, + { key: 'query_points', icon: }, + { key: 'query_game_name', icon: }, + { key: 'query_change_bind_time', icon: }, + { key: 'query_limited_goods', icon: }, + { key: 'query_gold_balance', icon: }, + { key: 'exchange_goods', icon: }, + { key: 'create_gold_qr', icon: }, + { key: 'donate_elite_gift', icon: }, +]; const STATUS_COLORS: Record = { - planned: 'default', - pending: 'default', - running: 'processing', - success: 'success', - failed: 'error', - error: 'error', - stopped: 'warning', + planned: 'default', pending: 'default', running: 'processing', + success: 'success', failed: 'error', error: 'error', stopped: 'warning', }; const STATUS_LABELS: Record = { - planned: '已计划', - pending: '等待中', - running: '执行中', - success: '成功', - failed: '失败', - error: '异常', - stopped: '已停止', + planned: '已计划', pending: '等待中', running: '执行中', + success: '成功', failed: '失败', error: '异常', stopped: '已停止', }; -function accountLabel(account: DouyuTaskAccountItem): string { - const name = account.nickname || account.username || account.uid || `#${account.id}`; - const tag = account.tag ? ` [${account.tag}]` : ''; - const game = account.game_name ? ` / ${account.game_name}` : ''; - return `${name}${tag}${game}`; -} +const BIND_STATUS_LABELS: Record = { + bind_qr_generated: '待扫码', + game_queried: '已识别角色', + game_not_bound: '未绑定', + bind_confirmed: '已绑定', + change_time_queried: '已查换绑', +}; function resultText(result: Record | null | undefined, key: string): string { const value = result?.[key]; return typeof value === 'string' ? value : ''; } -function resultNumber(result: Record | null | undefined, key: string): number | null { +function resultFlag(result: Record | null | undefined, key: string): boolean { const value = result?.[key]; - if (typeof value === 'number') return value; - if (typeof value === 'string' && value.trim()) return Number(value) || null; - return null; + if (value === true) return true; + if (value === false || value == null) return false; + const text = String(value).trim().toLowerCase(); + return text === '1' || text === 'true' || text === 'yes'; } -function taskPayUrl(task: DouyuTaskItem | null): string { +function taskPayUrl(task: DouyuTaskItem | null | undefined): string { return resultText(task?.result, 'pay_url') || resultText(task?.result, 'url'); } +function bindReadyForConfirm(task: DouyuTaskItem | null | undefined): boolean { + if (!task || task.task_type !== 'get_bind_qr') return false; + if (task.result?.bind_ready_for_confirm === true) return true; + const roleName = resultText(task.result, 'role_name'); + if (!roleName) return false; + // 已确认绑定的角色不能再次直接确认 + if (resultFlag(task.result, 'bind_confirmed') || resultFlag(task.result, 'is_bound_act')) { + return false; + } + return true; +} + +function hasBindQrcode(task: DouyuTaskItem | null | undefined): boolean { + if (!task || task.task_type !== 'get_bind_qr') return false; + // 有二维码,或已有待确认角色(无需再扫码)都应可展示绑定面板 + return Boolean(resultText(task.result, 'url')) || bindReadyForConfirm(task); +} + +function hasPaymentQrcode(task: DouyuTaskItem | null | undefined): boolean { + return Boolean(task && ['create_elite_qr', 'create_gold_qr'].includes(task.task_type) && resultText(task.result, 'pay_url')); +} + +function bindPhaseText(phase: string, polling: boolean): string { + if (phase === 'role_ready') return '已识别角色,待确认'; + if (phase === 'change_waiting') return '换绑冷却中'; + if (phase === 'role_timeout') return '未检测到角色'; + if (phase === 'stopped') return '任务已停止'; + if (phase === 'confirmed') return '已绑定'; + if (phase === 'confirm_failed') return '确认未生效'; + if (polling || phase === 'waiting_scan') return '等待扫码绑定'; + return phase || '等待绑定'; +} + function goodsLabel(item: DouyuGoodsItem): string { return `${item.name || item.commodity_id}${item.score ? ` / ${item.score}积分` : ''}`; } -const defaultConfig: DouyuConfig = { - manual_id: 'G4KA4Qnz4LDp7', - rid: '9263298', - bind_act_alias: '20250213NQCYX', - confirm_act_alias: '20260120QYOOB', - legacy_act_alias: 'cjm', - room_id: '9263298', - elite_amount: 3000, - gold_pay_type: 1, - gift_id: '23643', - skin_id: '2942', - updated_at: null, -}; +function formatWaitSeconds(seconds: number | null | undefined): string { + if (seconds == null || Number.isNaN(Number(seconds))) return ''; + const total = Math.max(0, Math.floor(Number(seconds))); + if (total <= 0) return '可换绑'; + const days = Math.floor(total / 86400); + const hours = Math.floor((total % 86400) / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + if (days > 0) return `${days}天${hours}小时${minutes}分`; + if (hours > 0) return `${hours}小时${minutes}分${secs}秒`; + return `${minutes}分${secs}秒`; +} + +function resultNumber(result: Record | null | undefined, key: string): number | null { + const value = result?.[key]; + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim()) { + const n = Number(value); + return Number.isFinite(n) ? n : null; + } + return null; +} export default function DouyuTasksPage() { - const { token } = theme.useToken(); const [accounts, setAccounts] = useState([]); + const [selectedIds, setSelectedIds] = useState([]); const [goods, setGoods] = useState([]); const [tasks, setTasks] = useState([]); - const [taskTypes, setTaskTypes] = useState>(FALLBACK_TASK_TYPES); - const [config, setConfig] = useState(defaultConfig); - const [selectedAccountIds, setSelectedAccountIds] = useState([]); - const [selectedGoodsId, setSelectedGoodsId] = useState(''); - const [concurrency, setConcurrency] = useState(3); - const [goldAmount, setGoldAmount] = useState(1); - const [giftCount, setGiftCount] = useState(1); + const [taskTypes, setTaskTypes] = useState>({}); + const [config, setConfig] = useState(null); const [loading, setLoading] = useState(false); const [runningBatchId, setRunningBatchId] = useState(''); const [configOpen, setConfigOpen] = useState(false); + const [concurrency, setConcurrency] = useState(3); + const [goldAmount, setGoldAmount] = useState(1); + const [giftCount, setGiftCount] = useState(1); + const [selectedGoodsId, setSelectedGoodsId] = useState(''); + const [accountSearch, setAccountSearch] = useState(''); + + const [importOpen, setImportOpen] = useState(false); + const [importPool, setImportPool] = useState([]); + const [importSearch, setImportSearch] = useState(''); + const [importSelectedIds, setImportSelectedIds] = useState([]); + const [importLoading, setImportLoading] = useState(false); + + // 绑定/支付弹窗以 task 为唯一数据源 + const [qrTask, setQrTask] = useState(null); const [payTask, setPayTask] = useState(null); - const [configForm] = Form.useForm(); + const autoOpenedQrTaskIds = useRef>(new Set()); + const autoOpenedPayTaskIds = useRef>(new Set()); + const autoOpenQrReady = useRef(false); + const autoOpenPayReady = useRef(false); + const logs = useWebSocketLogs(); + const latestQueryGameTaskByAccount = useMemo(() => { + const map = new Map(); + for (const task of tasks) { + if (task.task_type !== 'query_game_name') continue; + const previous = map.get(task.account_id); + if (!previous || task.id > previous.id) map.set(task.account_id, task); + } + return map; + }, [tasks]); + + // 换绑时间:优先最近一次查询/拦截任务结果,回退账号落库字段 + const latestChangeWaitByAccount = useMemo(() => { + const map = new Map(); + for (const task of tasks) { + if (!['query_change_bind_time', 'get_bind_qr'].includes(task.task_type)) continue; + if (!['success', 'failed'].includes(task.status)) continue; + const wait = resultNumber(task.result, 'change_role_wait_time'); + // get_bind_qr 只有冷却拦截时才有 wait;无 wait 时跳过,避免覆盖 + if (task.task_type === 'get_bind_qr' && wait == null) continue; + const existing = map.get(task.account_id); + if (existing && existing.taskId > task.id) continue; + const text = resultText(task.result, 'change_role_wait_text') || formatWaitSeconds(wait); + map.set(task.account_id, { wait, text, taskId: task.id }); + } + return map; + }, [tasks]); const loadData = useCallback(async () => { setLoading(true); try { - const [accountResult, goodsResult, taskResult, configResult, taskTypeResult] = await Promise.allSettled([ + const [accResult, goodsResult, taskResult, cfgResult, typeResult] = await Promise.allSettled([ douyuApi.listAccounts(), douyuApi.listGoods(), douyuApi.listTasks(), douyuApi.getConfig(), douyuApi.taskTypes(), ]); - if (accountResult.status === 'fulfilled') setAccounts(accountResult.value); + if (accResult.status === 'fulfilled') setAccounts(accResult.value); if (goodsResult.status === 'fulfilled') setGoods(goodsResult.value); - if (taskResult.status === 'fulfilled') setTasks(taskResult.value); - if (configResult.status === 'fulfilled') setConfig(configResult.value); - if (taskTypeResult.status === 'fulfilled') setTaskTypes(taskTypeResult.value); - const errors = [ - accountResult.status === 'rejected' ? `账号: ${getErrorMessage(accountResult.reason)}` : '', - goodsResult.status === 'rejected' ? `商品: ${getErrorMessage(goodsResult.reason)}` : '', - taskResult.status === 'rejected' ? `任务: ${getErrorMessage(taskResult.reason)}` : '', - configResult.status === 'rejected' ? `配置: ${getErrorMessage(configResult.reason)}` : '', - ].filter(Boolean); - if (errors.length) message.warning(errors.join(';')); + if (taskResult.status === 'fulfilled') { + setTasks(taskResult.value); + // 仅首次加载时屏蔽历史二维码自动弹窗;后续刷新不能把新任务一并标记掉 + if (!autoOpenQrReady.current) { + for (const task of taskResult.value) { + if (hasBindQrcode(task)) autoOpenedQrTaskIds.current.add(task.id); + if (hasPaymentQrcode(task)) autoOpenedPayTaskIds.current.add(task.id); + } + autoOpenQrReady.current = true; + autoOpenPayReady.current = true; + } + } + if (cfgResult.status === 'fulfilled') setConfig(cfgResult.value); + if (typeResult.status === 'fulfilled') setTaskTypes(typeResult.value); } finally { setLoading(false); } }, []); - useEffect(() => { - loadData(); - }, [loadData]); + const loadTasks = useCallback(async () => { + try { + const data = await douyuApi.listTasks(); + setTasks(data); + if (!data.some((task) => ['pending', 'running', 'planned'].includes(task.status))) { + // 批次已结束时清理 busy 标记,避免 onResult 丢失导致永久锁住 + setRunningBatchId((current) => (current ? '' : current)); + } + } catch { + // 轮询失败不打扰操作 + } + }, []); - useEffect(() => { - if (configOpen) configForm.setFieldsValue(config); - }, [configOpen, config, configForm]); + useEffect(() => { loadData(); }, [loadData]); - const activeTask = tasks.find((item) => ['planned', 'pending', 'running'].includes(item.status)); - - const goodsOptions = useMemo( - () => goods.map((item) => ({ value: item.commodity_id, label: goodsLabel(item) })), - [goods], + const hasActiveTasks = useMemo( + () => tasks.some((task) => ['pending', 'running', 'planned'].includes(task.status)), + [tasks], ); - const startTask = async (taskType: string) => { - if (selectedAccountIds.length === 0) { - message.warning('请先选择斗鱼账号'); + // 有活跃任务或 WS 连接时 3 秒刷 tasks;空闲 15 秒轻量刷新 + useEffect(() => { + const intervalMs = hasActiveTasks || logs.connected || runningBatchId ? 3000 : 15000; + const timer = setInterval(loadTasks, intervalMs); + return () => clearInterval(timer); + }, [hasActiveTasks, loadTasks, logs.connected, runningBatchId]); + + const openQrTask = useCallback((task: DouyuTaskItem) => { + autoOpenedQrTaskIds.current.add(task.id); + setPayTask(null); + setQrTask(task); + }, []); + + const openPayTask = useCallback((task: DouyuTaskItem) => { + autoOpenedPayTaskIds.current.add(task.id); + setQrTask(null); + setPayTask(task); + }, []); + + // 自动弹出最新绑定二维码(running 有 url 即可) + useEffect(() => { + if (!autoOpenQrReady.current || qrTask) return; + const nextQrTask = tasks + .filter((task) => hasBindQrcode(task) && !autoOpenedQrTaskIds.current.has(task.id)) + .sort((a, b) => b.id - a.id)[0]; + if (nextQrTask) openQrTask(nextQrTask); + }, [openQrTask, qrTask, tasks]); + + // 自动弹出支付二维码 + useEffect(() => { + if (!autoOpenPayReady.current || payTask) return; + const nextPayTask = tasks + .filter((task) => hasPaymentQrcode(task) && !autoOpenedPayTaskIds.current.has(task.id)) + .sort((a, b) => b.id - a.id)[0]; + if (nextPayTask) openPayTask(nextPayTask); + }, [openPayTask, payTask, tasks]); + + // 弹窗跟随最新 task 状态(后端 progress 写入后 UI 自动更新) + useEffect(() => { + if (!qrTask) return; + const latest = tasks.find((task) => task.id === qrTask.id); + if (latest && latest !== qrTask) setQrTask(latest); + }, [qrTask, tasks]); + + useEffect(() => { + if (!payTask) return; + const latest = tasks.find((task) => task.id === payTask.id); + if (latest && latest !== payTask) setPayTask(latest); + }, [payTask, tasks]); + + const batchBusy = Boolean(runningBatchId || logs.connected || hasActiveTasks); + + const startTask = async (taskType: string, accountIds?: number[]) => { + const ids = accountIds || selectedIds; + if (ids.length === 0) { + message.warning('请先选择账号'); + return; + } + // 绑定确认/查询允许在其他账号仍轮询时继续,避免卡在多账号批次里 + const allowDuringBatch = taskType === 'confirm_bind' || taskType === 'query_game_name'; + if (batchBusy && !allowDuringBatch) { + message.warning('当前已有批次在运行,请先停止或等待结束'); return; } if (taskType === 'exchange_goods' && !selectedGoodsId) { @@ -188,19 +310,12 @@ export default function DouyuTasksPage() { return; } const payload: Record = {}; - if (taskType === 'exchange_goods') { - payload.commodity_id = selectedGoodsId; - } - if (taskType === 'create_gold_qr') { - payload.amount = goldAmount; - } - if (taskType === 'donate_elite_gift') { - payload.gift_count = giftCount; - } - + if (taskType === 'exchange_goods') payload.commodity_id = selectedGoodsId; + if (taskType === 'create_gold_qr') payload.amount = goldAmount; + if (taskType === 'donate_elite_gift') payload.gift_count = giftCount; try { const result = await douyuApi.createTasks({ - account_ids: selectedAccountIds, + account_ids: ids, task_type: taskType, concurrency, payload, @@ -210,29 +325,28 @@ export default function DouyuTasksPage() { clear: true, onResult: () => { setRunningBatchId(''); - loadData(); + void loadData(); }, }); message.success(`已创建 ${result.count} 个任务`); - setTimeout(loadData, 500); + setTimeout(() => { void loadTasks(); }, 400); } catch (error) { message.error(getErrorMessage(error)); } }; - const stopTask = async () => { + const stopBatch = async () => { if (!runningBatchId) return; try { await douyuApi.stopBatch(runningBatchId); - message.success('已发送停止信号'); + message.success('已停止'); } catch (error) { message.error(getErrorMessage(error)); } }; - const saveConfig = async () => { + const saveConfig = async (values: DouyuConfig) => { try { - const values = await configForm.validateFields(); const saved = await douyuApi.updateConfig(values); setConfig(saved); setConfigOpen(false); @@ -242,20 +356,150 @@ export default function DouyuTasksPage() { } }; - const openPayTask = (task: DouyuTaskItem) => { - const url = taskPayUrl(task); - if (!url) { - message.warning('任务结果中没有二维码链接'); - return; + // ---- Import ---- + const loadImportPool = useCallback(async () => { + setImportLoading(true); + try { + const result = await douyuApi.listAccounts({ search: importSearch }); + setImportPool(result.filter((a) => !accounts.find((x) => x.id === a.id))); + } finally { + setImportLoading(false); } - setPayTask(task); + }, [importSearch, accounts]); + useEffect(() => { if (importOpen) void loadImportPool(); }, [importOpen, loadImportPool]); + + const importAccounts = (ids: number[]) => { + const toImport = importPool.filter((a) => ids.includes(a.id)); + setAccounts((prev) => { + const existing = new Set(prev.map((a) => a.id)); + return [...prev, ...toImport.filter((a) => !existing.has(a.id))]; + }); + setImportSelectedIds([]); + setImportOpen(false); + message.success(`已导入 ${toImport.length} 个账号`); }; + const removeSelected = () => { + const sel = new Set(selectedIds); + setAccounts((prev) => prev.filter((a) => !sel.has(a.id))); + setSelectedIds([]); + }; + + // ---- Context menu ---- + const [contextMenu, setContextMenu] = useState<{ + accountIds: number[]; x: number; y: number; + } | null>(null); + + const handleRowContextMenu = (record: DouyuTaskAccountItem, event: React.MouseEvent) => { + event.preventDefault(); + const ids = selectedIds.includes(record.id) ? selectedIds : [record.id]; + setContextMenu({ accountIds: ids, x: event.clientX, y: event.clientY }); + if (!selectedIds.includes(record.id)) setSelectedIds([record.id]); + }; + + useEffect(() => { + if (!contextMenu) return; + const close = () => setContextMenu(null); + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') close(); + }; + window.addEventListener('click', close); + window.addEventListener('scroll', close, true); + window.addEventListener('resize', close); + window.addEventListener('keydown', closeOnEscape); + return () => { + window.removeEventListener('click', close); + window.removeEventListener('scroll', close, true); + window.removeEventListener('resize', close); + window.removeEventListener('keydown', closeOnEscape); + }; + }, [contextMenu]); + + const contextMenuStyle = contextMenu ? { + position: 'fixed' as const, + left: Math.max(8, Math.min(contextMenu.x, window.innerWidth - 220)), + top: Math.max(8, Math.min(contextMenu.y, window.innerHeight - 390)), + zIndex: 1050, + } : { display: 'none' }; + + const runContextAction = (taskType: string) => { + if (!contextMenu) return; + setContextMenu(null); + void startTask(taskType, contextMenu.accountIds); + }; + + // ---- QR panel derived state ---- + const qrResult = qrTask?.result || null; + const qrUrl = resultText(qrResult, 'url'); + const qrBindPhase = resultText(qrResult, 'bind_phase'); + const qrAutoPolling = qrTask?.status === 'running' && qrResult?.bind_polling === true; + 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 qrQueryRoleName = resultText(qrQueryResult, 'role_name'); + const qrQueryIsBoundAct = resultFlag(qrQueryResult, 'is_bound_act') || resultFlag(qrQueryResult, 'bind_confirmed'); + const qrQueryPending = Boolean( + qrQueryResult + && qrQueryRoleName + && ( + qrQueryResult.bind_ready_for_confirm === true + || (!qrQueryIsBoundAct) + ), + ); + const qrBindReady = bindReadyForConfirm(qrTask) || qrQueryPending; + const qrRoleSourceResult = qrQueryPending + ? qrQueryResult + : (bindReadyForConfirm(qrTask) ? qrResult : null); + const qrRoleName = resultText(qrRoleSourceResult, 'role_name'); + const qrAreaName = resultText(qrRoleSourceResult, 'area_name'); + const qrPlatName = resultText(qrRoleSourceResult, 'plat_name'); + const qrCurrentRoleName = resultText(qrResult, 'current_role_name') + || (resultFlag(qrResult, 'is_bound_act') ? resultText(qrResult, 'role_name') : '') + || (qrQueryIsBoundAct ? qrQueryRoleName : ''); + const qrCurrentAreaName = resultText(qrResult, 'current_area_name') + || (resultFlag(qrResult, 'is_bound_act') ? resultText(qrResult, 'area_name') : '') + || (qrQueryIsBoundAct ? resultText(qrQueryResult, 'area_name') : ''); + const qrCurrentPlatName = resultText(qrResult, 'current_plat_name') + || (resultFlag(qrResult, 'is_bound_act') ? resultText(qrResult, 'plat_name') : '') + || (qrQueryIsBoundAct ? resultText(qrQueryResult, 'plat_name') : ''); + const qrRoleLine = qrBindReady + ? [qrPlatName, qrAreaName, qrRoleName].filter(Boolean).join(' - ') + : ''; + const qrBindSummary = resultText(qrResult, 'bind_summary') || resultText(qrQueryResult, 'bind_summary'); + const qrStatusText = qrBindReady + ? '已识别角色,待确认' + : qrQueryRunning + ? '查询角色中' + : qrQueryTask && qrQueryTask.id > (qrTask?.id || 0) && qrQueryTask.status === 'success' && qrQueryIsBoundAct + ? '查询结果:仍是当前绑定角色' + : bindPhaseText(qrBindPhase, qrAutoPolling); + const qrAccountName = qrTask + ? (qrTask.account_nickname || qrTask.account_username || qrTask.account_uid || `#${qrTask.account_id}`) + : ''; + + const confirmQrBind = () => { + if (!qrTask || !qrBindReady) { + message.warning('请先扫码识别待确认角色'); + return; + } + const accountId = qrTask.account_id; + setQrTask(null); + void startTask('confirm_bind', [accountId]); + }; + + const queryQrRole = () => { + if (!qrTask) return; + if (qrAutoPolling) { + message.info('正在自动识别角色,请稍候'); + return; + } + void startTask('query_game_name', [qrTask.account_id]); + }; + + // Account table const accountColumns: TableProps['columns'] = [ { - title: '账号', - dataIndex: 'username', - width: 190, + title: '账号', dataIndex: 'username', width: 150, render: (_, record) => ( {record.nickname || record.username} @@ -264,70 +508,143 @@ export default function DouyuTasksPage() { ), }, { - title: '游戏名', - dataIndex: 'game_name', - width: 180, - render: (_, record) => record.game_name ? ( - - {record.game_name} - {record.game_channel || '-'} - - ) : 未查, + title: '游戏名', dataIndex: 'game_name', width: 130, + render: (_, record) => { + const queryResult = latestQueryGameTaskByAccount.get(record.id)?.result || null; + const roleName = resultText(queryResult, 'role_name') || record.game_name; + const channel = ( + [resultText(queryResult, 'area_name'), resultText(queryResult, 'plat_name')] + .filter(Boolean) + .join(' / ') + || record.game_channel + ); + return roleName ? ( + + {roleName} + {channel ? {channel} : null} + + ) : 未查; + }, }, { - title: '积分', - dataIndex: 'points', - width: 90, - render: (value) => value ?? -, - sorter: (a, b) => (a.points ?? -1) - (b.points ?? -1), + title: '积分', dataIndex: 'points', width: 70, + render: (v) => v ?? -, }, { - title: '鱼翅', - dataIndex: 'gold_balance', - width: 90, - render: (value) => value ?? -, + title: '鱼翅', dataIndex: 'gold_balance', width: 70, + render: (v) => v ?? -, }, { - title: '状态', - dataIndex: 'bind_status', - width: 120, - render: (value) => value ? {value} : -, + title: '换绑时间', dataIndex: 'change_role_wait_time', width: 130, + render: (_, record) => { + const fromTask = latestChangeWaitByAccount.get(record.id); + const wait = fromTask?.wait ?? record.change_role_wait_time; + const text = fromTask?.text || formatWaitSeconds(wait); + if (wait == null && !text) { + return 未查; + } + if (wait != null && wait <= 0) { + return 可换绑; + } + if (wait != null && wait > 0) { + return ( + + 冷却中 + {text || formatWaitSeconds(wait)} + + ); + } + // 接口没返回数字但有文案 + if (text === '可换绑') return 可换绑; + return {text || '-'}; + }, + sorter: (a, b) => { + const aw = latestChangeWaitByAccount.get(a.id)?.wait ?? a.change_role_wait_time ?? -1; + const bw = latestChangeWaitByAccount.get(b.id)?.wait ?? b.change_role_wait_time ?? -1; + return aw - bw; + }, + }, + { + title: '状态', dataIndex: 'bind_status', width: 100, + render: (v) => (v + ? {BIND_STATUS_LABELS[v] || v} + : -), }, ]; + // Task table const taskColumns: TableProps['columns'] = [ { - title: '账号', - dataIndex: 'account_username', - width: 150, - render: (_, record) => record.account_nickname || record.account_username || record.account_uid, + title: '账号', dataIndex: 'account_username', width: 120, + render: (_, r) => r.account_nickname || r.account_username || r.account_uid, + }, + { title: '任务', dataIndex: 'task_type', width: 140, render: (v) => taskTypes[v] || v }, + { + title: '状态', dataIndex: 'status', width: 80, + render: (v) => {STATUS_LABELS[v] || v}, }, { - title: '任务', - dataIndex: 'task_type', - width: 170, - render: (value) => taskTypes[value] || value, - }, - { - title: '状态', - dataIndex: 'status', - width: 90, - render: (value) => {STATUS_LABELS[value] || value}, - }, - { - title: '结果', - dataIndex: 'message', - ellipsis: true, - render: (_, record) => { - const payUrl = taskPayUrl(record); - const points = resultNumber(record.result, 'points'); + title: '结果', dataIndex: 'message', ellipsis: true, + render: (_, r) => { + if (r.task_type === 'get_bind_qr') { + const roleName = resultText(r.result, 'role_name'); + const phase = resultText(r.result, 'bind_phase'); + const ready = bindReadyForConfirm(r); + const polling = r.status === 'running' && r.result?.bind_polling === true; + const bindUrl = resultText(r.result, 'url'); + return ( + + {r.message || '-'} + {ready ? {roleName || '待确认'} : null} + {!ready && (polling || phase) ? ( + {bindPhaseText(phase, polling)} + ) : null} + {bindUrl && ( + + )} + + ); + } + if (r.task_type === 'query_game_name') { + const roleName = resultText(r.result, 'role_name'); + const areaName = resultText(r.result, 'area_name'); + const platName = resultText(r.result, 'plat_name'); + return ( + + {r.message || '-'} + {roleName ? {roleName} : null} + {(areaName || platName) ? ( + {[areaName, platName].filter(Boolean).join(' / ')} + ) : null} + + ); + } + if (r.task_type === 'confirm_bind') { + const roleName = resultText(r.result, 'role_name'); + const confirmed = resultFlag(r.result, 'bind_confirmed'); + return ( + + {r.message || '-'} + {roleName ? {roleName} : null} + + ); + } + const url = taskPayUrl(r); return ( - - {record.message || '-'} - {typeof points === 'number' && 积分 {points}} - {payUrl && ( - )} @@ -335,16 +652,36 @@ export default function DouyuTasksPage() { }, }, { - title: '时间', - dataIndex: 'finished_at', - width: 170, - render: (_, record) => formatTime(record.finished_at || record.created_at), + title: '时间', dataIndex: 'finished_at', width: 150, + render: (_, r) => formatTime(r.finished_at || r.created_at), }, ]; + const filteredAccounts = useMemo(() => { + if (!accountSearch.trim()) return accounts; + const kw = accountSearch.toLowerCase(); + return accounts.filter((a) => + (a.nickname || '').toLowerCase().includes(kw) + || (a.username || '').toLowerCase().includes(kw) + || (a.uid || '').toLowerCase().includes(kw) + || (a.game_name || '').toLowerCase().includes(kw), + ); + }, [accounts, accountSearch]); + + const [configFormValues, setConfigFormValues] = useState(null); + useEffect(() => { + if (configOpen && config) setConfigFormValues({ ...config }); + }, [configOpen, config]); + + const payUrl = resultText(payTask?.result, 'pay_url'); + const payAccountName = payTask + ? (payTask.account_nickname || payTask.account_username || payTask.account_uid || `#${payTask.account_id}`) + : ''; + return ( -
- +
+ {/* Header */} +

斗鱼任务操作台

绑定、宝典、鱼翅、积分、兑换和余额任务 @@ -352,163 +689,367 @@ export default function DouyuTasksPage() { - {runningBatchId && ( - - )} + {runningBatchId && } - - + {/* Main grid */} +
+ {/* Left: accounts + tasks */} +
已选 {selectedAccountIds.length}} + size="small" title="账号" + extra={已选 {selectedIds.length}/{accounts.length}} + styles={{ body: { flex: 1, overflow: 'auto', padding: 8 } }} > + + + } + value={accountSearch} onChange={(e) => setAccountSearch(e.target.value)} + style={{ width: 200 }} allowClear + /> + + + + {selectedIds.length > 0 && ( + + )} + + setSelectedAccountIds(keys.map(Number)), + selectedRowKeys: selectedIds, + onChange: (keys) => setSelectedIds(keys.map(Number)), }} columns={accountColumns} - dataSource={accounts} - pagination={{ pageSize: 8, showSizeChanger: false }} + dataSource={filteredAccounts} + pagination={{ pageSize: 15, showSizeChanger: false }} + scroll={{ y: 'calc(100vh - 520px)' }} + onRow={(record) => ({ + onContextMenu: (e) => handleRowContextMenu(record, e), + style: { cursor: 'context-menu' }, + title: '右键打开账号动作', + })} + /> + + + + 成功 {tasks.filter((t) => t.status === 'success').length} + 失败 {tasks.filter((t) => ['failed', 'error'].includes(t.status)).length} + + } + styles={{ body: { padding: 8 } }} + > +
- - - - - - - - {[ - 'get_bind_qr', - 'confirm_bind', - 'create_elite_qr', - 'query_points', - 'query_game_name', - 'query_change_bind_time', - 'query_limited_goods', - 'query_gold_balance', - ].map((key) => ( - - - + + + {/* Right: operations + logs */} +
+ + + + {selectedIds.length > 0 ? `已选 ${selectedIds.length} 个账号` : '点击/右键选择账号'} + setConcurrency(v || 1)} style={{ width: 70 }} addonBefore="并发" /> + + + {QUICK_ACTIONS.map((item) => ( + ))} - - + + } + value={importSearch} onChange={(e) => setImportSearch(e.target.value)} + style={{ width: 250 }} + /> + + + + +
setImportSelectedIds(keys.map(Number)) }} + dataSource={importPool} + columns={[ + { title: '昵称', dataIndex: 'nickname', render: (_, r) => r.nickname || r.username }, + { title: 'UID', dataIndex: 'uid' }, + { title: '游戏名', dataIndex: 'game_name', render: (v) => v || '-' }, + { title: '积分', dataIndex: 'points', render: (v) => v ?? '-' }, + ]} + pagination={{ pageSize: 10 }} + locale={{ emptyText: '没有可导入的账号(操作台已包含所有账号)' }} /> - + + {/* Config modal */} setConfigOpen(false)} - onOk={saveConfig} - okText="保存" - cancelText="取消" - width={720} + onOk={() => configFormValues && saveConfig(configFormValues)} + okText="保存" cancelText="取消" + width={600} > -
- -
- - - - - - - - - - - + {configFormValues && ( + + {[ + { label: 'manualID', key: 'manual_id' }, + { label: 'RID', key: 'rid' }, + { label: '绑定二维码活动', key: 'bind_act_alias' }, + { label: '确认绑定活动', key: 'confirm_act_alias' }, + { label: '查询绑定活动(最新)', key: 'legacy_act_alias' }, + { label: '房间 ID', key: 'room_id' }, + { label: '精英令礼物 ID', key: 'gift_id' }, + { label: '皮肤 ID', key: 'skin_id' }, + ].map(({ label, key }) => ( + + {label} + setConfigFormValues((prev) => prev ? { ...prev, [key]: e.target.value } : prev)} + /> + + ))} + {[ + { label: '宝典金额(分)', key: 'elite_amount' }, + { label: '鱼翅支付方式', key: 'gold_pay_type' }, + ].map(({ label, key }) => ( + + {label} + setConfigFormValues((prev) => prev ? { ...prev, [key]: v ?? 0 } : prev)} + /> + + ))} + + )} + {/* Bind QR Modal — driven by qrTask */} + + {qrBindReady ? '已识别新角色' : qrStatusText} + + } + open={!!qrTask} + onCancel={() => setQrTask(null)} + footer={null} + centered + width={420} + > + {qrTask && ( +
+ {qrAccountName && ( + {qrAccountName} + )} + + + {qrStatusText} + + {/* 已有待确认角色时可能不生成二维码(cjm 已是最新换绑态) */} + {qrUrl ? ( + + ) : qrBindReady ? ( + 已识别待确认角色,无需再次扫码 + ) : qrTask?.status === 'running' ? ( + 二维码生成中... + ) : ( + 本次未生成二维码 + )} + {qrBindReady ? ( + <> + + {qrRoleName} + + {qrRoleLine && {qrRoleLine}} + + ) : qrCurrentRoleName ? ( + <> + + 当前绑定: {qrCurrentRoleName} + + {(qrCurrentAreaName || qrCurrentPlatName) && ( + + {[qrCurrentPlatName, qrCurrentAreaName].filter(Boolean).join(' - ')} + + )} + + ) : null} + {qrBindSummary ? ( + + {qrBindSummary} + + ) : null} + {qrQueryTask && qrQueryTask.id > (qrTask?.id || 0) && qrQueryTask.message ? ( + + 最近查询: {qrQueryTask.message} + + ) : null} + + + + + +
+ )} +
+ + {/* Pay QR Modal */} + + + 支付二维码 + + } open={!!payTask} onCancel={() => setPayTask(null)} footer={null} centered + width={400} > {payTask && ( - - {taskTypes[payTask.task_type] || payTask.task_type} - - - {taskPayUrl(payTask)} - - +
+ {payAccountName && ( + {payAccountName} + )} + + {payUrl ? ( + + ) : ( + 支付码生成中... + )} + {payTask.message || '请扫码支付'} + +
)}