fix(huya): 精英宝典绑定二维码对齐最新抓包并支持配置 bind_gid

This commit is contained in:
yml2213
2026-11-09 21:01:42 +08:00
parent 7282787398
commit 5f1d78e1e4
16 changed files with 446 additions and 80 deletions
+3
View File
@@ -3,6 +3,8 @@
HUYA_DEFAULT_ROOM_PID = "1199650619883"
HUYA_DEFAULT_SID = "2203"
HUYA_DEFAULT_OUTER_ACT_ID = "17096"
# 2026-11-09 最新活动页抓包:绑定走 gid=3203、bActId=0。
HUYA_DEFAULT_BIND_GID = "3203"
HUYA_DEFAULT_BIND_ACT_ID = "9271"
HUYA_DEFAULT_PAY_CHANNEL = "Zfb"
@@ -10,6 +12,7 @@ HUYA_CONFIG_DEFAULTS = {
"room_pid": HUYA_DEFAULT_ROOM_PID,
"sid": HUYA_DEFAULT_SID,
"outer_act_id": HUYA_DEFAULT_OUTER_ACT_ID,
"bind_gid": HUYA_DEFAULT_BIND_GID,
"bind_act_id": HUYA_DEFAULT_BIND_ACT_ID,
"pay_channel": HUYA_DEFAULT_PAY_CHANNEL,
}
@@ -0,0 +1,39 @@
"""虎牙绑定协议增加 gid 配置。"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20261109_0075"
down_revision: str | None = "20261108_0074"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if not inspector.has_table("huya_config"):
return
columns = {column["name"] for column in inspector.get_columns("huya_config")}
if "bind_gid" not in columns:
op.add_column(
"huya_config",
sa.Column(
"bind_gid",
sa.String(length=32),
nullable=False,
server_default="3203",
),
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if not inspector.has_table("huya_config"):
return
columns = {column["name"] for column in inspector.get_columns("huya_config")}
if "bind_gid" in columns:
op.drop_column("huya_config", "bind_gid")
+2
View File
@@ -23,6 +23,7 @@ from .crypto_storage import EncryptedText
from .database import Base, UTCDateTime
from .huya_defaults import (
HUYA_DEFAULT_BIND_ACT_ID,
HUYA_DEFAULT_BIND_GID,
HUYA_DEFAULT_OUTER_ACT_ID,
HUYA_DEFAULT_PAY_CHANNEL,
HUYA_DEFAULT_ROOM_PID,
@@ -1369,6 +1370,7 @@ class HuyaConfig(Base):
outer_act_id: Mapped[str] = mapped_column(
String(32), default=HUYA_DEFAULT_OUTER_ACT_ID
)
bind_gid: Mapped[str] = mapped_column(String(32), default=HUYA_DEFAULT_BIND_GID)
bind_act_id: Mapped[str] = mapped_column(
String(32), default=HUYA_DEFAULT_BIND_ACT_ID
)
+1
View File
@@ -453,6 +453,7 @@ def _config_out(config: HuyaConfig) -> HuyaConfigOut:
room_pid=huya_config_value("room_pid", config.room_pid),
sid=huya_config_value("sid", config.sid),
outer_act_id=huya_config_value("outer_act_id", config.outer_act_id),
bind_gid=huya_config_value("bind_gid", config.bind_gid),
bind_act_id=huya_config_value("bind_act_id", config.bind_act_id),
pay_channel=huya_config_value("pay_channel", config.pay_channel),
updated_at=config.updated_at,
+4
View File
@@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_serializer
from .huya_defaults import (
HUYA_DEFAULT_BIND_ACT_ID,
HUYA_DEFAULT_BIND_GID,
HUYA_DEFAULT_OUTER_ACT_ID,
HUYA_DEFAULT_PAY_CHANNEL,
HUYA_DEFAULT_ROOM_PID,
@@ -1154,6 +1155,7 @@ class HuyaConfigOut(BaseModel):
room_pid: str = HUYA_DEFAULT_ROOM_PID
sid: str = HUYA_DEFAULT_SID
outer_act_id: str = HUYA_DEFAULT_OUTER_ACT_ID
bind_gid: str = HUYA_DEFAULT_BIND_GID
bind_act_id: str = HUYA_DEFAULT_BIND_ACT_ID
pay_channel: str = HUYA_DEFAULT_PAY_CHANNEL
updated_at: datetime | None = None
@@ -1164,6 +1166,7 @@ class HuyaConfigOut(BaseModel):
"room_pid": self.room_pid,
"sid": self.sid,
"outer_act_id": self.outer_act_id,
"bind_gid": self.bind_gid,
"bind_act_id": self.bind_act_id,
"pay_channel": self.pay_channel,
"updated_at": _isoformat(self.updated_at),
@@ -1174,6 +1177,7 @@ class HuyaConfigUpdate(BaseModel):
room_pid: str | None = None
sid: str | None = None
outer_act_id: str | None = None
bind_gid: str | None = None
bind_act_id: str | None = None
pay_channel: str | None = None
+124 -47
View File
@@ -101,6 +101,41 @@ class BindMixin:
"bind_phase": "role_ready" if role_info["role_name"] else "waiting_role",
}
@staticmethod
def _bind_protocol(config_info: dict, payload: dict | None = None) -> dict:
"""读取绑定协议参数;有 gid 时用 2026-11 抓包协议,否则回退旧协议。"""
payload = payload or {}
bind_gid_text = str(
payload.get("bind_gid") or config_info.get("bind_gid") or ""
).strip()
bind_gid = HuyaBatchRunnerCore._to_int(bind_gid_text)
outer_act_id = str(
payload.get("outer_act_id")
or config_info.get("outer_act_id")
or HUYA_DEFAULT_OUTER_ACT_ID
).strip()
if bind_gid and outer_act_id:
return {
"bind_gid": bind_gid_text,
"b_act_id": 0,
"gid": bind_gid,
"outer_act_id": outer_act_id,
"is_use_outer_act_id": 0,
"scene": "undefined",
}
b_act_id_text = str(
payload.get("bind_act_id") or config_info.get("bind_act_id") or ""
).strip()
return {
"bind_gid": "",
"b_act_id": HuyaBatchRunnerCore._to_int(b_act_id_text),
"gid": 0,
# 旧协议最初不传 outerActId;需要旧路径的地方按原逻辑显式补默认值。
"outer_act_id": "",
"is_use_outer_act_id": 1,
"scene": "",
}
def _resolve_bind_status(
self,
client: Any,
@@ -110,16 +145,20 @@ class BindMixin:
*,
outer_act_id: str = "",
timeout: float | None = None,
gid: int = 0,
is_use_outer_act_id: int = 1,
scene: str = "",
):
"""按活动页逻辑解析绑定状态,优先返回含角色的状态。"""
probe_kwargs: dict[str, Any] = {
"uid": uid,
"cookie": cookie,
"b_act_id": b_act_id_int,
"is_use_outer_act_id": 1,
"gid": gid,
"outer_act_id": outer_act_id,
"is_use_outer_act_id": is_use_outer_act_id,
"scene": scene,
}
if outer_act_id:
probe_kwargs["outer_act_id"] = outer_act_id
if timeout is not None:
probe_kwargs["timeout"] = timeout
outer_status = client.check_user_bind_game_account(**probe_kwargs)
@@ -135,10 +174,16 @@ class BindMixin:
chosen_status = outer_status
account_data = outer_status.accountData
should_check_inner = not self._has_bind_role(outer_status) and bool(
account_data.isNeedActCheck
or not account_data.isBindAcount
or not account_data.isBindRole
# 最新抓包协议只用外层查询(gid + outerActId)。旧的内层兜底会带上
# gid=0/outerActId=空,被服务端判 501「参数错误」,仅在旧协议下保留。
should_check_inner = (
not gid
and not self._has_bind_role(outer_status)
and bool(
account_data.isNeedActCheck
or not account_data.isBindAcount
or not account_data.isBindRole
)
)
if should_check_inner:
inner_status = client.check_user_bind_game_account(
@@ -162,6 +207,9 @@ class BindMixin:
cookie: str,
b_act_id_int: int,
outer_act_id: str = "",
gid: int = 0,
is_use_outer_act_id: int = 1,
scene: str = "",
):
"""用外部单协作的新 CK 探针查角色。
@@ -177,6 +225,9 @@ class BindMixin:
b_act_id_int,
outer_act_id=outer_act_id or HUYA_DEFAULT_OUTER_ACT_ID,
timeout=HUYA_CK_PROBE_TIMEOUT_SECONDS,
gid=gid,
is_use_outer_act_id=is_use_outer_act_id,
scene=scene,
)
def _apply_role_to_account(self, account: HuyaAccount, bind_status, status: str):
@@ -209,6 +260,11 @@ class BindMixin:
cookie: str,
b_act_id_int: int,
result: dict,
*,
gid: int = 0,
outer_act_id: str = "",
is_use_outer_act_id: int = 1,
scene: str = "",
) -> tuple[str, dict]:
"""生成二维码后轮询扫码状态与角色同步,直到识别到角色、超时或停止。"""
deadline = time.monotonic() + HUYA_BIND_ROLE_POLL_SECONDS
@@ -258,6 +314,10 @@ class BindMixin:
uid=uid,
cookie=cookie,
b_act_id_int=b_act_id_int,
outer_act_id=outer_act_id,
gid=gid,
is_use_outer_act_id=is_use_outer_act_id,
scene=scene,
)
if bind_status is None:
continue
@@ -328,20 +388,12 @@ class BindMixin:
account_info: dict,
config_info: dict,
):
b_act_id = str(
self.payload.get("bind_act_id") or config_info.get("bind_act_id") or ""
).strip()
if not b_act_id:
protocol = self._bind_protocol(config_info, self.payload)
b_act_id_int = protocol["b_act_id"]
if not protocol["bind_gid"] and not b_act_id_int:
self._mark_task(worker_db, task, "failed", "请先配置虎牙绑定 bActId")
return
b_act_id_int = self._to_int(b_act_id)
if not b_act_id_int:
self._mark_task(
worker_db, task, "failed", f"虎牙绑定 bActId 无效: {b_act_id}"
)
return
uid = self._resolve_uid(account_info)
if not uid:
self._mark_task(worker_db, task, "failed", "无法从账号或 Cookie 解析 yyuid")
@@ -358,6 +410,10 @@ class BindMixin:
uid=uid,
cookie=cookie,
b_act_id_int=b_act_id_int,
outer_act_id=protocol["outer_act_id"],
gid=protocol["gid"],
is_use_outer_act_id=protocol["is_use_outer_act_id"],
scene=protocol["scene"],
)
if bind_status is None:
self._mark_task(worker_db, task, "error", "虎牙绑定状态接口无响应")
@@ -383,6 +439,8 @@ class BindMixin:
available_at = change_state["change_available_at"]
result = {
"bind_act_id": b_act_id_int,
"bind_gid": protocol["bind_gid"],
"gid": protocol["gid"],
**role_info,
**change_state,
"bind_status": bind_status.to_dict(),
@@ -400,6 +458,8 @@ class BindMixin:
uid=uid,
cookie=cookie,
b_act_id=b_act_id_int,
gid=protocol["gid"],
outer_act_id=int(protocol["outer_act_id"] or 0),
game_auth_scene=bind_status.gameAuthScene,
)
if live_link is None:
@@ -450,6 +510,8 @@ class BindMixin:
result = {
"bind_act_id": b_act_id_int,
"bind_gid": protocol["bind_gid"],
"gid": protocol["gid"],
"mini_qrcode_image": mini_qrcode["mini_qrcode_image"],
"qrcode_token": mini_qrcode.get("qrcode_token") or "",
**bind_query_result,
@@ -483,6 +545,10 @@ class BindMixin:
cookie=cookie,
b_act_id_int=b_act_id_int,
result=result,
gid=protocol["gid"],
outer_act_id=protocol["outer_act_id"],
is_use_outer_act_id=protocol["is_use_outer_act_id"],
scene=protocol["scene"],
)
if role_name:
self._mark_task(
@@ -514,20 +580,12 @@ class BindMixin:
account_info: dict,
config_info: dict,
):
b_act_id = str(
self.payload.get("bind_act_id") or config_info.get("bind_act_id") or ""
).strip()
if not b_act_id:
protocol = self._bind_protocol(config_info, self.payload)
b_act_id_int = protocol["b_act_id"]
if not protocol["bind_gid"] and not b_act_id_int:
self._mark_task(worker_db, task, "failed", "请先配置虎牙绑定 bActId")
return
b_act_id_int = self._to_int(b_act_id)
if not b_act_id_int:
self._mark_task(
worker_db, task, "failed", f"虎牙绑定 bActId 无效: {b_act_id}"
)
return
uid = self._resolve_uid(account_info)
if not uid:
self._mark_task(worker_db, task, "failed", "无法从账号或 Cookie 解析 yyuid")
@@ -538,16 +596,14 @@ class BindMixin:
self._mark_task(worker_db, task, "failed", "账号 Cookie 为空")
return
outer_act_id = str(
self.payload.get("outer_act_id")
or config_info.get("outer_act_id")
or HUYA_DEFAULT_OUTER_ACT_ID
).strip()
bind_status, bind_query_result = self._probe_bind_status(
uid=uid,
cookie=cookie,
b_act_id_int=b_act_id_int,
outer_act_id=outer_act_id,
outer_act_id=protocol["outer_act_id"],
gid=protocol["gid"],
is_use_outer_act_id=protocol["is_use_outer_act_id"],
scene=protocol["scene"],
)
if bind_status is None:
# 新探针没有回包 = 这份 CK 已经不认了,和外部单协作的判定一致。
@@ -560,6 +616,8 @@ class BindMixin:
"虎牙角色信息接口无响应(CK 已失效,请重新登录或重新导入 Cookie)",
{
"bind_act_id": b_act_id_int,
"bind_gid": protocol["bind_gid"],
"gid": protocol["gid"],
"ck_expired": True,
"ck_expired_reason": "角色信息接口无响应",
},
@@ -572,7 +630,12 @@ class BindMixin:
task,
"failed",
bind_status.msg or f"虎牙角色信息查询失败: {bind_status.status}",
{"bind_act_id": b_act_id_int, **bind_query_result},
{
"bind_act_id": b_act_id_int,
"bind_gid": protocol["bind_gid"],
"gid": protocol["gid"],
**bind_query_result,
},
)
return
@@ -580,6 +643,8 @@ class BindMixin:
change_state = self._bind_change_state(bind_status)
result = {
"bind_act_id": b_act_id_int,
"bind_gid": protocol["bind_gid"],
"gid": protocol["gid"],
**role_info,
**change_state,
**bind_query_result,
@@ -604,20 +669,12 @@ class BindMixin:
account_info: dict,
config_info: dict,
):
b_act_id = str(
self.payload.get("bind_act_id") or config_info.get("bind_act_id") or ""
).strip()
if not b_act_id:
protocol = self._bind_protocol(config_info, self.payload)
b_act_id_int = protocol["b_act_id"]
if not protocol["bind_gid"] and not b_act_id_int:
self._mark_task(worker_db, task, "failed", "请先配置虎牙绑定 bActId")
return
b_act_id_int = self._to_int(b_act_id)
if not b_act_id_int:
self._mark_task(
worker_db, task, "failed", f"虎牙绑定 bActId 无效: {b_act_id}"
)
return
uid = self._resolve_uid(account_info)
if not uid:
self._mark_task(worker_db, task, "failed", "无法从账号或 Cookie 解析 yyuid")
@@ -634,6 +691,10 @@ class BindMixin:
uid=uid,
cookie=cookie,
b_act_id_int=b_act_id_int,
outer_act_id=protocol["outer_act_id"],
gid=protocol["gid"],
is_use_outer_act_id=protocol["is_use_outer_act_id"],
scene=protocol["scene"],
)
if role_status is None:
self._mark_task(worker_db, task, "error", "虎牙绑定角色查询接口无响应")
@@ -653,6 +714,8 @@ class BindMixin:
if not role_name:
result = {
"bind_act_id": b_act_id_int,
"bind_gid": protocol["bind_gid"],
"gid": protocol["gid"],
"bind_confirmed": False,
**role_query_result,
}
@@ -669,6 +732,8 @@ class BindMixin:
uid=uid,
cookie=cookie,
b_act_id=b_act_id_int,
gid=protocol["gid"],
outer_act_id=protocol["outer_act_id"],
)
if confirm_resp is None:
self._mark_task(worker_db, task, "error", "虎牙确认绑定接口无响应")
@@ -676,6 +741,8 @@ class BindMixin:
if confirm_resp.status != 200:
result = {
"bind_act_id": b_act_id_int,
"bind_gid": protocol["bind_gid"],
"gid": protocol["gid"],
"bind_confirmed": False,
"confirm_result": confirm_resp.to_dict(),
"before_bind_status": role_status.to_dict(),
@@ -695,10 +762,16 @@ class BindMixin:
uid=uid,
cookie=cookie,
b_act_id_int=b_act_id_int,
outer_act_id=protocol["outer_act_id"],
gid=protocol["gid"],
is_use_outer_act_id=protocol["is_use_outer_act_id"],
scene=protocol["scene"],
)
if refreshed_status is None:
result = {
"bind_act_id": b_act_id_int,
"bind_gid": protocol["bind_gid"],
"gid": protocol["gid"],
"bind_confirmed": True,
"confirm_result": confirm_resp.to_dict(),
"before_bind_status": role_status.to_dict(),
@@ -713,6 +786,8 @@ class BindMixin:
if refreshed_status.status != 200:
result = {
"bind_act_id": b_act_id_int,
"bind_gid": protocol["bind_gid"],
"gid": protocol["gid"],
"bind_confirmed": True,
"confirm_result": confirm_resp.to_dict(),
"before_bind_status": role_status.to_dict(),
@@ -739,6 +814,8 @@ class BindMixin:
)
result = {
"bind_act_id": b_act_id_int,
"bind_gid": protocol["bind_gid"],
"gid": protocol["gid"],
"bind_confirmed": True,
"refreshed_is_bound": refreshed_confirmed,
"confirm_result": confirm_resp.to_dict(),
+5 -1
View File
@@ -228,7 +228,11 @@ class HuyaBatchRunnerCore:
def _format_local_time(timestamp: int) -> str:
if not timestamp:
return ""
return datetime.fromtimestamp(timestamp, UTC).strftime("%Y-%m-%d %H:%M:%S")
return (
datetime.fromtimestamp(timestamp, UTC)
.astimezone()
.strftime("%Y-%m-%d %H:%M:%S")
)
@staticmethod
def _parse_scheduled_time(value) -> datetime | None:
+7 -10
View File
@@ -285,23 +285,20 @@ class EliteMixin:
role_name = ""
# ---- 角色绑定 / CK 有效性(先做,失效就不空跑后面两个接口) ----
b_act_id_int = self._to_int(
self.payload.get("bind_act_id") or config_info.get("bind_act_id")
)
if not b_act_id_int:
protocol = self._bind_protocol(config_info, self.payload)
b_act_id_int = protocol["b_act_id"]
if not protocol["bind_gid"] and not b_act_id_int:
failures.append("未配置绑定 bActId")
result["ck_valid"] = False
else:
outer_act_id = str(
self.payload.get("outer_act_id")
or config_info.get("outer_act_id")
or ""
).strip()
bind_status, bind_query_result = self._probe_bind_status(
uid=uid,
cookie=cookie,
b_act_id_int=b_act_id_int,
outer_act_id=outer_act_id,
outer_act_id=protocol["outer_act_id"],
gid=protocol["gid"],
is_use_outer_act_id=protocol["is_use_outer_act_id"],
scene=protocol["scene"],
)
if bind_status is None:
account.status = "ck_expired"
+7 -11
View File
@@ -10,7 +10,6 @@ from sqlalchemy.orm import Session
from core.huya.wss_client import POINTS_REDEEM_GOODS_NUM
from ..huya_defaults import HUYA_DEFAULT_OUTER_ACT_ID
from ..models import HuyaAccount, HuyaGoodsSnapshot, HuyaTask
# 积分查询超时:HTTP 通道正常几百毫秒,10 秒足够;失败时别让整批任务长时间挂住。
@@ -123,20 +122,17 @@ class GoodsMixin:
# ---- 先验 CK:没有回包就立即判失效,不再空跑积分与旧接口 ----
ck_valid = False
b_act_id_int = self._to_int(
self.payload.get("bind_act_id") or config_info.get("bind_act_id")
)
if b_act_id_int:
outer_act_id = str(
self.payload.get("outer_act_id")
or config_info.get("outer_act_id")
or HUYA_DEFAULT_OUTER_ACT_ID
).strip()
protocol = self._bind_protocol(config_info, self.payload)
b_act_id_int = protocol["b_act_id"]
if protocol["bind_gid"] or b_act_id_int:
bind_status, _bind_query_result = self._probe_bind_status(
uid=uid,
cookie=cookie,
b_act_id_int=b_act_id_int,
outer_act_id=outer_act_id,
outer_act_id=protocol["outer_act_id"],
gid=protocol["gid"],
is_use_outer_act_id=protocol["is_use_outer_act_id"],
scene=protocol["scene"],
)
if bind_status is None:
account.status = "ck_expired"
+1
View File
@@ -1184,6 +1184,7 @@ export interface HuyaConfig {
room_pid: string;
sid: string;
outer_act_id: string;
bind_gid: string;
bind_act_id: string;
pay_channel: string;
updated_at: string | null;
+58 -8
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { MouseEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tag, Tooltip, Typography,
Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Spin, Table, Tag, Tooltip, Typography,
} from 'antd';
import type { TableProps } from 'antd';
import {
@@ -107,6 +107,11 @@ function resultText(task: HuyaTaskItem | undefined, key: string): string {
return value == null ? '' : String(value);
}
/** mini_qrcode_image 可能存裸 base64,也可能存 livelink 返回的 data URI;统一成 img src。 */
function miniQrcodeSrc(image: string): string {
return image.startsWith('data:image/') ? image : `data:image/png;base64,${image}`;
}
/** 开通支付码是否还需要扫码:已支付或已确认开通就不用再展示了 */
function needsScanPay(task: HuyaTaskItem): boolean {
const result = task.result || {};
@@ -203,6 +208,9 @@ export default function HuyaElitePage() {
const [importTags, setImportTags] = useState<string[]>([]);
const [importSelected, setImportSelected] = useState<number[]>([]);
const [qrTask, setQrTask] = useState<HuyaTaskItem | null>(null);
const [qrTaskLoading, setQrTaskLoading] = useState(false);
// 小程序码图片独立保存:任务轮询只会更新轻量 tasks,不允许覆盖这张大图。
const [qrImage, setQrImage] = useState('');
// 开通支付码:多选账号时可以一次生成多个码,弹窗里逐个展示(按批次收集)
const [payBatchIds, setPayBatchIds] = useState<string[]>([]);
const [payOpen, setPayOpen] = useState(false);
@@ -216,6 +224,8 @@ export default function HuyaElitePage() {
const [configSaving, setConfigSaving] = useState(false);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; accountIds: number[] } | null>(null);
const tasksLoading = useRef(false);
const qrTaskFetchId = useRef<number | null>(null);
const qrImageCacheRef = useRef<Map<number, string>>(new Map());
const logs = useWebSocketLogs();
const load = useCallback(async () => {
@@ -368,10 +378,31 @@ export default function HuyaElitePage() {
onTask: (raw) => {
const next = raw as unknown as HuyaTaskItem;
setTasks((prev) => [next, ...prev.filter((item) => item.id !== next.id)].slice(0, 300));
const result = next.result || {};
const result = (next.result || {}) as Record<string, unknown>;
// 支付弹窗只在「生成开通支付码」和点账号行时打开:多选时每个码都会回包,
// 这里再自动弹会把用户刚关掉的弹窗反复顶出来(码会在弹窗里自动补齐)。
if (next.task_type === 'get_bind_qr' && (result.mini_qrcode_image || result.bind_redirect_url)) setQrTask(next);
if (next.task_type === 'get_bind_qr') {
if (result.mini_qrcode_image) {
const directImage = String(result.mini_qrcode_image);
qrImageCacheRef.current.set(next.id, directImage);
setQrImage(directImage);
setQrTask(next);
} else if (result.has_mini_qrcode && qrTaskFetchId.current !== next.id) {
// WS 只送 has_mini_qrcode,真实小程序码体积大,必须按详情接口取。
qrTaskFetchId.current = next.id;
setQrImage('');
setQrTaskLoading(true);
setQrTask(next);
void huyaApi.getTask(next.id).then((detail) => {
const image = resultText(detail, 'mini_qrcode_image');
if (image) {
qrImageCacheRef.current.set(detail.id, image);
setQrImage(image);
}
setQrTask((current) => (current && current.id === detail.id ? detail : current));
}).catch(() => {}).finally(() => setQrTaskLoading(false));
} else if (result.bind_redirect_url) setQrTask(next);
}
},
onResult: () => { setActiveBatches((prev) => prev.filter((id) => id !== created.batch_id)); void load(); },
});
@@ -420,8 +451,27 @@ export default function HuyaElitePage() {
};
const openQrTask = async (task: HuyaTaskItem) => {
const currentImage = resultText(task, 'mini_qrcode_image');
const cachedImage = currentImage || qrImageCacheRef.current.get(task.id) || '';
// 图片是独立 state,轮询/任务对象刷新不会把它清掉。
setQrImage(cachedImage);
setQrTask(task);
try { setQrTask(await huyaApi.getTask(task.id)); } catch { /* 使用列表结果 */ }
if (currentImage) {
qrImageCacheRef.current.set(task.id, currentImage);
return;
}
if (cachedImage) return;
if (task.result?.has_mini_qrcode !== true) return;
setQrTaskLoading(true);
try {
const detail = await huyaApi.getTask(task.id);
const image = resultText(detail, 'mini_qrcode_image');
if (image) {
qrImageCacheRef.current.set(detail.id, image);
setQrImage((current) => current || image);
}
setQrTask((current) => (current && current.id === detail.id ? detail : current));
} catch { /* 使用列表结果 */ } finally { setQrTaskLoading(false); }
};
const filteredAccounts = useMemo(() => {
@@ -491,7 +541,7 @@ export default function HuyaElitePage() {
{ title: '精英宝典', width: '13%', align: 'center', ellipsis: true, render: (_value, row) => <Space direction="vertical" align="center" size={0} style={{ width: '100%' }}><Tag color={row.elite_opened === true ? 'success' : row.elite_opened === false ? 'default' : undefined}>{row.elite_opened === true ? '已开通' : row.elite_opened === false ? '未开通' : '未查询'}</Tag>{row.elite_opened === true && row.elite_open_time ? <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{`开通 ${formatUnixSeconds(row.elite_open_time)}`}</Text> : null}{row.elite_expire_time ? <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{`有效期至 ${formatUnixSeconds(row.elite_expire_time)}`}</Text> : null}</Space> },
{ title: '积分', dataIndex: 'points', width: '7%', align: 'center', render: (value: number | null) => value ?? <Text type="secondary">-</Text> },
{ title: '最近操作', width: '25%', align: 'center', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); const expired = ckExpired(task, row); return task ? <Space direction="vertical" align="center" size={1} style={{ width: '100%' }}><Text strong ellipsis>{TASK_LABELS[task.task_type] || task.task_type}</Text><Text type={expired ? 'danger' : 'secondary'} ellipsis title={task.message}>{task.message || '-'}</Text><Text type="secondary" style={{ fontSize: 12 }}>{formatTime(task.finished_at || task.created_at)}</Text></Space> : <Text type="secondary">暂无操作</Text>; } },
{ title: '二维码', width: '6%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); if (!task) return <Text type="secondary">-</Text>; const url = taskQrUrl(task); const image = resultText(task, 'mini_qrcode_image'); if (!url && !image) return <Text type="secondary">-</Text>; const doneLabel = qrDoneLabel(task); if (doneLabel) return <Text type="secondary">{doneLabel}</Text>; return <div onClick={(event) => { event.stopPropagation(); if (!task) return; if (task.task_type === 'create_recharge_order') setZoomPayTask(task); else void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 3, borderRadius: 4 }} title="点击放大扫码">{image ? <img src={`data:image/png;base64,${image}`} alt="二维码" style={{ width: 52, height: 52, objectFit: 'contain' }} /> : <QRCode value={url} size={52} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</div>; } },
{ title: '二维码', width: '6%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); if (!task) return <Text type="secondary">-</Text>; const url = taskQrUrl(task); const image = resultText(task, 'mini_qrcode_image'); if (task.task_type === 'get_bind_qr' && !image) { const hasMini = task.result?.has_mini_qrcode === true; if (!hasMini) return <Text type="secondary">-</Text>; return <div onClick={(event) => { event.stopPropagation(); void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 6, borderRadius: 4 }} title="点击查看小程序码"><QrcodeOutlined style={{ fontSize: 26, color: '#000' }} /></div>; } if (!url && !image) return <Text type="secondary">-</Text>; const doneLabel = qrDoneLabel(task); if (doneLabel) return <Text type="secondary">{doneLabel}</Text>; return <div onClick={(event) => { event.stopPropagation(); if (!task) return; if (task.task_type === 'create_recharge_order') setZoomPayTask(task); else void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 3, borderRadius: 4 }} title="点击放大扫码">{image ? <img src={miniQrcodeSrc(image)} alt="二维码" style={{ width: 52, height: 52, objectFit: 'contain' }} /> : <QRCode value={url} size={52} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</div>; } },
{ title: '状态', width: '7%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); if (ckExpired(task, row)) return <Tooltip title={`${resultText(task, 'ck_expired_reason') || '角色信息查不到'} —— 请重新登录或重新导入 Cookie`}><Tag color="error">CK 失效</Tag></Tooltip>; const status = task?.status || row.status || ''; return <Tag color={TASK_COLORS[status] || 'default'}>{TASK_STATUS_LABELS[status] || status || '待操作'}</Tag>; } },
];
@@ -524,7 +574,7 @@ export default function HuyaElitePage() {
{layoutMode === 'split' ? <div style={{ flex: 1, minHeight: 0, display: 'flex', gap: 12, overflow: 'hidden' }}><Card size="small" title="账号" extra={<Tag color="blue">已选 {selectedIds.length}/{accounts.length}</Tag>} style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}>{accountsBody}</Card><Card size="small" title="精英宝典操作" extra={<Space size={4}><Text type="secondary">并发</Text><InputNumber size="small" min={1} max={10} value={concurrency} onChange={(value) => setConcurrency(value || 1)} style={{ width: 58 }} /></Space>} style={{ width: 360, flexShrink: 0, minHeight: 0, overflow: 'hidden' }} styles={{ body: { padding: 8, overflowY: 'auto', height: 'calc(100% - 38px)' } }}>{operationBody}</Card></div> : <><Card size="small" title="精英宝典操作" extra={<Space size={4}><Text type="secondary">并发</Text><InputNumber size="small" min={1} max={10} value={concurrency} onChange={(value) => setConcurrency(value || 1)} style={{ width: 58 }} /></Space>} style={{ flexShrink: 0, marginBottom: 12 }} styles={{ body: { padding: 8 } }}>{operationBody}</Card><Card size="small" title="账号" extra={<Tag color="blue">已选 {selectedIds.length}/{accounts.length}</Tag>} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}>{accountsBody}</Card></>}
<Text type="secondary" style={{ fontSize: 12, marginTop: 6 }}>兑换任务 {exchangeCount} 条;勾选账号后可批量执行,账号行支持右键操作。</Text>
{contextMenu && <div style={contextMenuStyle} onClick={(event) => event.stopPropagation()}><Card size="small" title={contextMenu.accountIds.length > 1 ? '批量动作' : '账号动作'} styles={{ body: { padding: 4 } }} style={{ width: 220, boxShadow: '0 4px 12px rgba(0,0,0,0.14)' }}><Space direction="vertical" style={{ width: '100%' }}>{HUYA_QUICK_ACTIONS.map((item) => <Button key={item.key} block size="small" type="text" icon={item.icon} onClick={() => runContextAction(item.key)} disabled={starting} style={{ justifyContent: 'flex-start' }}>{TASK_LABELS[item.key] || item.key}</Button>)}</Space></Card></div>}
<Modal open={qrTask !== null} title="绑定二维码" footer={null} onCancel={() => setQrTask(null)} centered>{qrTask && <div style={{ textAlign: 'center' }}>{resultText(qrTask, 'mini_qrcode_image') ? <img src={`data:image/png;base64,${resultText(qrTask, 'mini_qrcode_image')}`} alt="绑定二维码" style={{ width: 240, height: 240 }} /> : <QRCode value={resultText(qrTask, 'bind_redirect_url') || 'https://zt.huya.com/b02faae1/pc/index.html'} size={240} color="#000" bgColor="#fff" />}<p><Tag color={TASK_COLORS[qrTask.status] || 'default'}>{qrTask.message || qrTask.status}</Tag></p></div>}</Modal>
<Modal open={qrTask !== null} title="绑定二维码" footer={null} onCancel={() => { setQrTask(null); setQrImage(''); }} centered>{qrTask && <div style={{ textAlign: 'center' }}>{qrImage ? <img src={miniQrcodeSrc(qrImage)} alt="绑定二维码" style={{ width: 240, height: 240, background: '#fff', padding: 12, boxSizing: 'border-box', borderRadius: 8 }} /> : qrTask.result?.has_mini_qrcode === true ? <div style={{ width: 240, height: 240, display: 'grid', placeItems: 'center', background: '#fff', borderRadius: 8 }}>{qrTaskLoading ? <Spin size="large" /> : <Text type="secondary">正在获取小程序码…</Text>}</div> : <QRCode value={resultText(qrTask, 'bind_redirect_url') || 'https://zt.huya.com/b02faae1/pc/index.html'} size={240} color="#000" bgColor="#fff" />}<p><Tag color={TASK_COLORS[qrTask.status] || 'default'}>{qrTask.message || qrTask.status}</Tag></p></div>}</Modal>
<Modal
open={payOpen}
title={payTasks.length ? `开通支付码(${payPendingTasks.length} 个待扫码 · 已完成 ${payDoneTasks.length}/${payTasks.length})` : '开通支付码'}
@@ -540,7 +590,7 @@ export default function HuyaElitePage() {
const qrValue = payQrValue(task);
return (
<div key={task.id} style={{ border: '1px solid rgba(128,128,128,.22)', borderRadius: 6, padding: 10, textAlign: 'center' }}>
<div style={{ background: '#fff', padding: 6, borderRadius: 6, lineHeight: 0, display: 'inline-block' }}>{resultText(task, 'mini_qrcode_image') ? <img src={`data:image/png;base64,${resultText(task, 'mini_qrcode_image')}`} alt="支付码" style={{ width: 150, height: 150, objectFit: 'contain' }} /> : <QRCode value={qrValue} size={150} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</div>
<div style={{ background: '#fff', padding: 6, borderRadius: 6, lineHeight: 0, display: 'inline-block' }}>{resultText(task, 'mini_qrcode_image') ? <img src={miniQrcodeSrc(resultText(task, 'mini_qrcode_image'))} alt="支付码" style={{ width: 150, height: 150, objectFit: 'contain' }} /> : <QRCode value={qrValue} size={150} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</div>
<div><Text strong ellipsis>{payTaskLabel(task)}</Text></div>
<Text type="secondary" style={{ fontSize: 12 }}>UID {task.account_uid || '-'}</Text>
<div><Text type="secondary" style={{ fontSize: 12 }}>{[resultText(task, 'product_name') || '精英宝典', Number(resultText(task, 'count')) > 1 ? `×${resultText(task, 'count')}` : '', resultText(task, 'amount_text')].filter(Boolean).join(' ')}</Text></div>
@@ -577,7 +627,7 @@ export default function HuyaElitePage() {
</div>}
</Modal>
<Modal open={importOpen} title="导入精英宝典账号" onCancel={() => setImportOpen(false)} onOk={() => { void saveAccounts([...new Set([...accounts.map((item) => item.id), ...importSelected])]).then(() => { setImportSelected([]); setImportOpen(false); }).catch((error) => message.error(getErrorMessage(error))); }} okText="导入" cancelText="取消"><Input.Search allowClear placeholder="搜索账号" value={importSearch} onChange={(event) => setImportSearch(event.target.value)} style={{ marginBottom: 8 }} /><Select allowClear placeholder="标签" value={importTag || undefined} onChange={(value) => setImportTag(value || '')} options={importTags.map((tag) => ({ value: tag, label: tag }))} style={{ width: '100%', marginBottom: 8 }} /><Table rowKey="id" size="small" dataSource={pool.filter((item) => !accounts.some((current) => current.id === item.id) && (!importSearch || [item.uid, item.nickname, item.username].some((value) => String(value || '').includes(importSearch))) && (!importTag || item.tag === importTag))} columns={[{ title: '账号', render: (_value, row) => row.nickname || row.username || row.uid }, { title: '标签', dataIndex: 'tag' }]} pagination={{ pageSize: 8 }} rowSelection={{ selectedRowKeys: importSelected, onChange: (keys) => setImportSelected(keys.map(Number)) }} /></Modal>
<Modal open={configOpen} title="精英宝典设置" onCancel={() => setConfigOpen(false)} onOk={() => void saveConfig()} confirmLoading={configSaving} okText="保存" cancelText="取消" width={500}>{configLoading ? <div style={{ padding: 24, textAlign: 'center' }}>加载中...</div> : configDraft && <Space direction="vertical" size={10} style={{ width: '100%' }}>{configField('sid', '活动 SID')}{configField('bind_act_id', '绑定活动 ID')}{configField('outer_act_id', '外部活动 ID')}{configField('room_pid', '直播间 PID')}{configField('pay_channel', '支付渠道')}<Space style={{ width: '100%', justifyContent: 'space-between' }}><Text>空闲轮询间隔(秒)</Text><InputNumber min={5} max={60} value={refreshInterval} onChange={(value) => { const next = Math.min(60, Math.max(5, value || 15)); setRefreshInterval(next); localStorage.setItem('huya_elite_refresh_interval', String(next)); }} style={{ width: 300 }} /></Space></Space>}</Modal>
<Modal open={configOpen} title="精英宝典设置" onCancel={() => setConfigOpen(false)} onOk={() => void saveConfig()} confirmLoading={configSaving} okText="保存" cancelText="取消" width={500}>{configLoading ? <div style={{ padding: 24, textAlign: 'center' }}>加载中...</div> : configDraft && <Space direction="vertical" size={10} style={{ width: '100%' }}>{configField('sid', '活动 SID')}{configField('bind_act_id', '绑定活动 ID')}{configField('outer_act_id', '外部活动 ID')}{configField('bind_gid', '绑定 gid')}{configField('room_pid', '直播间 PID')}{configField('pay_channel', '支付渠道')}<Space style={{ width: '100%', justifyContent: 'space-between' }}><Text>空闲轮询间隔(秒)</Text><InputNumber min={5} max={60} value={refreshInterval} onChange={(value) => { const next = Math.min(60, Math.max(5, value || 15)); setRefreshInterval(next); localStorage.setItem('huya_elite_refresh_interval', String(next)); }} style={{ width: 300 }} /></Space></Space>}</Modal>
<HuyaExternalCkImportModal open={ckImportOpen} onClose={() => setCkImportOpen(false)} tags={importTags} scope={SCOPE} onImported={(result) => { localStorage.setItem('huya_elite_workbench_account_ids', JSON.stringify(result.account_ids)); void load(); }} />
</div>;
}
+5
View File
@@ -1836,6 +1836,11 @@ export default function HuyaTasksPage() {
<Input placeholder="9504" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="绑定 gid" name="bind_gid">
<Input placeholder="3203" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="绑定 bActId" name="bind_act_id">
<Input placeholder="9271" />