diff --git a/VERIFICATION.txt b/VERIFICATION.txt index 1c37d55..e742b55 100644 --- a/VERIFICATION.txt +++ b/VERIFICATION.txt @@ -33,3 +33,35 @@ Additional checks: .venv/bin/python -m ruff check core/huya tests/test_huya_app_login.py -> All checks passed, exit 0 git diff --check -> exit 0 bash -n dev.sh deploy.sh -> exit 0 + +Analysis round (2026-08-31): +changed branch/field: Huya elite handbook HAR analysis document (protocol flow, field matrix, implementation gaps) +analysis artifact: /Users/yml/codes/live-hub-py/docs/HUYA_精英宝典-8.31-PY重新分析.md +client artifact: /Users/yml/codes/live-hub-py/scripts/huya_elite_client.py +MODIFIED_FILE: /Users/yml/codes/live-hub-py/artifacts/har-to-client/MODIFIED_FILE +DIFF_FILE: /Users/yml/codes/live-hub-py/artifacts/har-to-client/DIFF_FILE +VERIFICATION.txt: /Users/yml/codes/live-hub-py/artifacts/har-to-client/VERIFICATION.txt +ROLLBACK.sh: /Users/yml/codes/live-hub-py/artifacts/har-to-client/ROLLBACK.sh +source HAR SHA-256: bcb1e830bfc368c9029f85f90458b708dcf086993a79b833e8e544c65e5c679f; f8d13a837c477aa4dc55b27df6828ffb7840bbdcccf7c078f840164d62a00e07 + +BASELINE exact command: +set -e; BASE=$(mktemp -d /tmp/live-hub-py-baseline.XXXXXX); git archive HEAD | tar -x -C "$BASE"; (cd "$BASE" && /Users/yml/codes/live-hub-py/.venv/bin/pytest -q tests/test_huya_app_login.py tests/test_huya_dfp_register.py) +BASELINE literal output/result: +28 passed, 1 warning in 0.71s +BASELINE exit status: 0 + +MODIFIED exact command: +uv run pytest -q +MODIFIED literal output/result: +116 passed, 1 warning in 1.67s +MODIFIED exit status: 0 + +ROLLBACK exact command: +set -e; SRC=$(mktemp /tmp/rollback-source.XXXXXX); TGT=$(mktemp /tmp/rollback-target.XXXXXX); cp MODIFIED_FILE "$SRC"; printf '\\ncorrupt\\n' >> "$TGT"; ./ROLLBACK.sh "$SRC" "$TGT"; cmp -s "$SRC" "$TGT" +ROLLBACK literal output/result: +rollback_cmp=restored; source_sha256=e15f95ce30ca0e0faa6e3c8c6cdf4725e4984434df0f39cf237ee0b7197375b1; target_sha256=e15f95ce30ca0e0faa6e3c8c6cdf4725e4984434df0f39cf237ee0b7197375b1 +ROLLBACK exit status: 0 +restored behavior/status: independent target copy equals MODIFIED_FILE byte-for-byte; MODIFIED_FILE remains changed. + +Validation command: +uv run python scripts/validate.py -> file not found (exit status 2; no validator exists in this repository) diff --git a/docs/HUYA_精英宝典-8.31-PY重新分析.md b/docs/HUYA_精英宝典-8.31-PY重新分析.md new file mode 100644 index 0000000..b08d006 --- /dev/null +++ b/docs/HUYA_精英宝典-8.31-PY重新分析.md @@ -0,0 +1,128 @@ +# 虎牙精英宝典 HAR 重新分析(Python) + +## 1. 样本与结论 + +| HAR | SHA-256 | entries | 结论 | +|---|---|---:|---| +| `手机扫码登录-pc直播间-1.har` | `bcb1e830bfc368c9029f85f90458b708dcf086993a79b833e8e544c65e5c679f` | 1947 | 扫码登录、PC 直播间初始化、登录态建立 | +| `pc直播间-精英宝典1.har` | `f8d13a837c477aa4dc55b27df6828ffb7840bbdcccf7c078f840164d62a00e07` | 580 | 精英宝典初始化、绑定、任务、奖品、积分、记录、商城前置 | + +页面地址为 `https://zt.huya.com/b02faae1/pc/index.html`。活动固定业务参数为 `sid=2203`、`actId=25135`、绑定活动 `bActId=9271`、任务模块 `moduleId=20051`、游戏 `cjm`、组件 `gid/componentId=3203`。 + +核心结论:协议是 WSS 二进制通道承载的 WUP/TAF RPC,HTTP `cdnws.api.huya.com` 可作为现有 Python 客户端的兜底。样本没有捕获积分兑换成功请求、兑换详情响应或支付完成闭环,因此这些字段不能从 HAR 推测。 + +## 2. 传输层与帧解码 + +- 活动页主通道:`wss://wsapi.huya.com/?baseinfo=<动态值>`。 +- 直播间/绑定入口还使用 `wss://d35bf35c-ws.va.huya.com/?baseinfo=<动态值>`。 +- HTTP 兜底:`POST https://cdnws.api.huya.com/?baseinfo=<动态值>`,请求体为 WUP 包。 +- HAR 的业务消息在 entry 的 `_webSocketMessages`,不是普通 HTTP entry body。 +- WebSocket 帧前 6 字节是通道头(command、sequence),后续 body 再提取 WUP;WUP body 从 `0x10 0x03` 字段开始。仓库的 `WssMessage.decode()`、`frame_decoder._extract_wup()` 和 `WupResponse.decode()` 可以复用。 +- `launch.wsLaunch`、`mobileui.getConfig` 完成连接初始化;业务 WUP `requestId` 在同一会话中递增,不能写死 HAR 值。 + +## 3. 请求链 + +精英宝典 WebSocket entry 75 的可复现顺序如下: + +1. `launch.wsLaunch` -> `mobileui.getConfig`。 +2. `GameBizUI.getQiWeiRelationByUid`(页面辅助信息)。 +3. `webActUI.checkUserBindGameAccount(bActId=9271)`,返回和平精英、微信/QQ 平台、角色绑定状态。 +4. 并行查询:`getActPrizeList(sid=2203)`、`getActInfo(actId=25135)`、`getActTaskDetail(actId=25135)`。 +5. `getActUserTaskDetail(userId, actId=25135)`、`getUserScore(sid=2203)`。 +6. 绑定流程:再次检查绑定 -> `huyauserui.getUserProfileBatch` -> `getLiveLinkParam(bActId=9271)` -> `confirmBindActAccount(bActId=9271)` -> 再次检查绑定。 +7. 记录页:`getUserPrizeRecords(sid=2203)` -> `getEntityPrizeFieldMap` -> `getModuleAddress(moduleId=20051)`。 +8. 商城前置:`shopMiddleUI.getGoodsInfoV5`;确认页还调用 `getSupplierInfoV5`、`listPayChannelV5`、`checkHyProtocolV5`、`PlayMallUI.getMyPromotion`、`calcOrderPromotion`。 +9. 页面会重复刷新 `getActPrizeList` 与 `getUserScore`;轮询间隔由工作台自行配置,建议 10-30 秒,写操作后只做一次短轮询。 + +第一份 HAR 的扫码登录链为: + +```text +GET /web/middle/2.6/.../https/... +POST /qrLgn/getQrId +GET /qrLgn/getQrImg?k=&appId=5002 +POST /qrLgn/tryQrLogin # stage=0 -> stage=1 -> success +POST /web/cookie/verify +``` + +成功登录后得到的 UID、Cookie、`baseinfo` 必须传入同一个 WSS/HTTP 会话。二维码 ID、requestId、context、biztoken、guid、`udb_cred`、`sdid`、时间戳和签名均为动态值,报告不固化样本机密值。 + +### 参数分类 + +| 参数 | 类型 | Python 处理 | +|---|---|---| +| `sid/actId/bActId/moduleId/gid` | 业务参数 | 配置项,默认值只对应本次页面 | +| `uid`、游戏账号/角色 | 用户输入或前序响应 | 从当前登录态或绑定响应读取 | +| Cookie、`baseinfo`、WSS session | Token/Session | `HuyaHttpClient` 运行时构造并保持 | +| `qrId/requestId/context/traceId` | 随机/请求序号 | 登录和 RPC 运行时生成 | +| `t/code/sig`、`biztoken`、`udb_cred`、`sdid` | 动态签名/认证 | 只转发前序响应或 Cookie;算法未闭合,不重算 | +| `startTime/endTime/leftNum/isCanExchange` | 服务端状态 | 每次查询覆盖本地快照,不写死 | + +HTTP 请求保留 `User-Agent`、`Origin`、`Referer`、`Content-Type: application/octet-stream` 和 `Accept-Language`;`Host`、`Content-Length`、`Connection` 由 HTTP 库生成。WSS 的 `baseinfo` query 和二进制 body 由会话客户端生成,不复制浏览器历史头。 + +## 4. 业务响应复核 + +### 活动信息 + +`getActInfo`:`status=200`,`actId=25135`,名称“活动+购买任务”,时间戳 `1782662400..1790783999`,任务模块 `20051`,外部任务活动 `17096`,游戏 `cjm`,组件 `3203`。`17096` 与绑定用的 `9271` 是不同字段,代码中必须分开命名。 + +### 奖品列表 + +`getActPrizeList` 在 entry 75 的多次响应均返回 **29 项**、2 个分类:`91=限量返场`(11 项)和 `83=精英专享`(18 项)。示例: + +| pid | 名称 | 积分 | 分类 | +|---:|---|---:|---:| +| 13161 | 破片手榴弹-粉粉猫爪大礼包 | 3600 | 91 | +| 13163 | 烟雾弹-猫咪绅士大礼包 | 3600 | 91 | +| 13167 | 套装-幸运萌趣趣 | 1980 | 91 | +| 13165 | 套装-奇妙萌趣趣 | 688 | 91 | +| 13169 | 套装-幸福萌趣趣 | 1680 | 91 | +| 12873 | 套装-青涩年华 | 488 | 83 | +| 12867 | 套装-雪山精英 | 600 | 83 | +| 12879 | 套装-浪漫波比 | 1280 | 83 | +| 12865 | 幸运币礼包(大) | 900 | 83 | + +完整结构还包含 `id/sid/name/score/type/img/sort/frequency/frequencyLimit/num/status/percent/newScore/tags/leftNum/isShowNum/usedNum/goodType/detailImg/detailDesc/whiteUser/commonPrizeId/updateTime/isCanExchange/exchangeStartTime/exchangeEndTime/startTimeSlot/endTimeSlot/isTodayLimit/isUserLimit`。库存、排序和可兑换状态在重复轮询中变化,不能硬编码;工作台应以最新响应覆盖快照。 + +### 任务与用户进度 + +`getActTaskDetail` 返回 6 个任务: + +- 购买精英令 *100,奖励 10000 分,SPU `hy-5874270`。 +- 购买精英令 *10,奖励 1000 分,SPU `hy-5874269`。 +- 购买精英令 *1,奖励 100 分,SPU `hy-5879346`。 +- 购买精英徽章,奖励 10 分,SPU `hy-5872824`。 +- 每天观看和平精英直播 10 分钟,奖励 2 分,任务类型 56。 +- 订阅和平精英福利提醒,奖励 5 分,任务类型 68。 + +`getActUserTaskDetail` 的每项至少有 `actId/taskId/taskStatus/prizeStatus/taskValue/taskCount/prizeCount/subTaskDetail`,它是展示任务进度、完成和领取状态的来源。 + +### 积分 + +响应字段顺序为 `status/msg/gainScore/usedScore/newGainScore/newUsedScore/score/isOpenExchangeTip`。样本值为 `4200,4200,4200,4200,0,0`;页面逻辑优先使用 `max(0, newGainScore-newUsedScore)`,所以当时可用积分为 **0**,不能把任意一个累计字段直接当余额。 + +### 绑定、记录与地址 + +- 绑定响应显示游戏“和平精英”,账号平台可为微信或 QQ,`isBindAccount`、`isBindRole`、`isNeedActCheck` 和 `changeBindTime` 随状态变化。 +- `getLiveLinkParam` 返回 `actId=17096`、`gameIdList=cjm`、`livePlatId=huya`、动态 `t/code/sig`。样本不足以闭合 `code/sig` 算法,不实现猜测签名。 +- `confirmBindActAccount` 样本为 `status=200,msg=请求成功`。 +- `getUserPrizeRecords` 返回 2 条历史记录,字段包括 `id/name/score/createTime/status/type/isReal/newScore/account/code/commonPrizeType/commonPrizeStatus/orderId/img/goodType/prizeId/recordId/commonPrizeExchangeStatus/commonPrizeId/prizeExtraParam/ymd/commonPrizeMsg`。 +- `getEntityPrizeFieldMap` 给出实体奖品字段映射:模块 1 为姓名/手机/地址,模块 38 为 QQ,模块 39 为姓名/手机;`getModuleAddress(moduleId=20051)` 返回 `status=201,msg=找不到地址`。 + +## 5. Python 实现边界 + +仓库现有 `core/huya/http_client.py` 已实现 WUP/TAF、会话 Cookie、积分、奖品列表、记录、任务、绑定、直播链接和商城方法;新增示例见 [`scripts/huya_elite_client.py`](../scripts/huya_elite_client.py)。示例不读取 HAR,运行时从环境变量读取 UID/Cookie,调用顺序为:活动信息/任务/用户任务/绑定/奖品/积分/记录,写操作前重新查询奖品详情。 + +推荐状态机: + +```text +登录态 -> getActInfo/getActTaskDetail + -> checkBind/getActPrizeList/getActUserTaskDetail/getUserScore + -> (用户确认后) getActPrizeDetail -> scoreExchangePrize + -> getUserScore/getActPrizeList/getUserPrizeRecords 回查 +``` + +兑换必须二次校验 `isCanExchange`、活动时间、库存、频次、今日/用户限制和可用积分。`getActPrizeDetail`、`scoreExchangePrize` 成功响应以及地址写入尚未出现在样本中,客户端应把它们作为显式待补接口,而不是猜测字段。 + +## 6. 尚缺证据与补抓清单 + +当前 HAR 未闭合:`getActPrizeDetail` 响应、`scoreExchangePrize` 成功请求/响应、`addModuleAddress`、`createOrderV5`、`payOrderSubmitV5`、支付完成后的订单状态和宝典开通字段。需要重新抓“点击兑换”和“点击开通宝典至支付完成”的完整操作,并保留 Initiator、页面 JavaScript 和同一登录态。 diff --git a/tests/test_huya_workbench_scopes.py b/tests/test_huya_workbench_scopes.py new file mode 100644 index 0000000..12a0015 --- /dev/null +++ b/tests/test_huya_workbench_scopes.py @@ -0,0 +1,91 @@ +import pytest +from sqlalchemy import create_engine, event +from sqlalchemy.orm import sessionmaker + +from web.backend.database import Base +from web.backend.models import HuyaAccount, HuyaWorkbench, HuyaWorkbenchAccount, User +from web.backend.routers.huya import ( + delete_account, + list_huya_workbench_accounts, + list_tasks, + update_huya_workbench_accounts, +) +from web.backend.schemas import HuyaTaskBatchRequest, HuyaWorkbenchAccountsUpdate +from web.backend.services.huya_service import create_planned_tasks + + +class TestHuyaWorkbenchScope: + def setup_method(self): + self.engine = create_engine("sqlite://") + + @event.listens_for(self.engine, "connect") + def enable_foreign_keys(connection, _): + connection.execute("PRAGMA foreign_keys=ON") + + Base.metadata.create_all(self.engine) + self.session = sessionmaker(bind=self.engine)() + self.user = User(username="operator", password_hash="hash", role="super_admin") + self.account = HuyaAccount(uid="100", yyuid="100", cookie="yyuid=100; udb_uid=100") + self.session.add_all([self.user, self.account]) + self.session.commit() + + def teardown_method(self): + self.session.close() + Base.metadata.drop_all(self.engine) + self.engine.dispose() + + def test_accounts_and_tasks_are_scoped(self): + update_huya_workbench_accounts( + HuyaWorkbenchAccountsUpdate(handbook_scope="elite", account_ids=[self.account.id]), + db=self.session, + current=self.user, + ) + result = list_huya_workbench_accounts("elite", db=self.session, current=self.user) + assert result == {"account_ids": [self.account.id], "configured": True} + assert self.session.query(HuyaWorkbench).count() == 1 + + elite_batch, count = create_planned_tasks( + self.session, + [self.account.id], + "query_act_tasks", + self.user.id, + handbook_scope="elite", + ) + assert count == 1 + tasks = list_tasks( + handbook_scope="elite", + include_images=False, + db=self.session, + current=self.user, + ) + assert [task.batch_id for task in tasks] == [elite_batch] + assert tasks[0].handbook_scope == "elite" + + with pytest.raises(ValueError, match="无效的工作台"): + create_planned_tasks( + self.session, + [self.account.id], + "query_points", + self.user.id, + handbook_scope="invalid", + ) + + def test_account_delete_cleans_workbench_membership(self): + self.session.add( + HuyaWorkbenchAccount( + user_id=self.user.id, + handbook_scope="elite", + account_id=self.account.id, + ) + ) + self.session.commit() + + result = delete_account(self.account.id, db=self.session, current=self.user) + assert result["success"] is True + assert self.session.query(HuyaAccount).count() == 0 + assert self.session.query(HuyaWorkbenchAccount).count() == 0 + + +def test_huya_batch_schema_defaults_to_legacy(): + request = HuyaTaskBatchRequest(account_ids=[1], task_type="query_points") + assert request.handbook_scope == "legacy" diff --git a/tests/test_migrations.py b/tests/test_migrations.py index e602f5a..55df97b 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -22,7 +22,7 @@ TESTS_DIR = Path(__file__).resolve().parent PROJECT_ROOT = TESTS_DIR.parent VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions" -HEAD_REVISION = "20260830_0031" +HEAD_REVISION = "20260831_0032" # 迁移新增、但不声明在模型里的复合查询索引。 EXTRA_INDEXES = ( diff --git a/web/backend/migrations/versions/20260831_0032_huya_elite_workbench.py b/web/backend/migrations/versions/20260831_0032_huya_elite_workbench.py new file mode 100644 index 0000000..337fbeb --- /dev/null +++ b/web/backend/migrations/versions/20260831_0032_huya_elite_workbench.py @@ -0,0 +1,64 @@ +"""增加虎牙精英宝典工作台范围与账号集合。""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260831_0032" +down_revision: str | None = "20260830_0031" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _columns(bind, table: str) -> set[str]: + return {item["name"] for item in sa.inspect(bind).get_columns(table)} + + +def upgrade() -> None: + bind = op.get_bind() + if "handbook_scope" not in _columns(bind, "huya_tasks"): + op.add_column( + "huya_tasks", + sa.Column("handbook_scope", sa.String(length=16), nullable=False, server_default="legacy"), + ) + op.create_index("ix_huya_tasks_handbook_scope", "huya_tasks", ["handbook_scope"]) + + inspector = sa.inspect(bind) + if not inspector.has_table("huya_workbench_accounts"): + op.create_table( + "huya_workbench_accounts", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("handbook_scope", sa.String(length=16), nullable=False), + sa.Column("account_id", sa.Integer(), sa.ForeignKey("huya_accounts.id"), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.UniqueConstraint("user_id", "handbook_scope", "account_id", name="uq_huya_workbench_account"), + ) + op.create_index("ix_huya_workbench_accounts_user_id", "huya_workbench_accounts", ["user_id"]) + op.create_index("ix_huya_workbench_accounts_handbook_scope", "huya_workbench_accounts", ["handbook_scope"]) + op.create_index("ix_huya_workbench_accounts_account_id", "huya_workbench_accounts", ["account_id"]) + + if not inspector.has_table("huya_workbenches"): + op.create_table( + "huya_workbenches", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("handbook_scope", sa.String(length=16), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.UniqueConstraint("user_id", "handbook_scope", name="uq_huya_workbench"), + ) + op.create_index("ix_huya_workbenches_user_id", "huya_workbenches", ["user_id"]) + op.create_index("ix_huya_workbenches_handbook_scope", "huya_workbenches", ["handbook_scope"]) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if inspector.has_table("huya_workbenches"): + op.drop_table("huya_workbenches") + if inspector.has_table("huya_workbench_accounts"): + op.drop_table("huya_workbench_accounts") + if "handbook_scope" in _columns(bind, "huya_tasks"): + op.drop_index("ix_huya_tasks_handbook_scope", table_name="huya_tasks") + op.drop_column("huya_tasks", "handbook_scope") diff --git a/web/backend/models.py b/web/backend/models.py index 0159c47..314f67c 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -498,6 +498,10 @@ class HuyaTask(Base): ForeignKey("huya_accounts.id"), nullable=False ) task_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + # 任务归属工作台;legacy 保留旧虎牙任务,elite 隔离精英宝典任务。 + handbook_scope: Mapped[str] = mapped_column( + String(16), nullable=False, default="legacy", index=True + ) status: Mapped[str] = mapped_column(String(32), default="pending") message: Mapped[str] = mapped_column(String(512), default="") result: Mapped[dict | None] = mapped_column(JSON, nullable=True) @@ -508,6 +512,42 @@ class HuyaTask(Base): account: Mapped[HuyaAccount] = relationship("HuyaAccount", back_populates="tasks") +class HuyaWorkbenchAccount(Base): + """用户在虎牙指定工作台中启用的账号。""" + + __tablename__ = "huya_workbench_accounts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True) + handbook_scope: Mapped[str] = mapped_column(String(16), nullable=False, index=True) + account_id: Mapped[int] = mapped_column(ForeignKey("huya_accounts.id"), nullable=False, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow) + + __table_args__ = ( + UniqueConstraint( + "user_id", "handbook_scope", "account_id", + name="uq_huya_workbench_account", + ), + ) + + +class HuyaWorkbench(Base): + """工作台配置哨兵,令空账号集合也能跨浏览器同步。""" + + __tablename__ = "huya_workbenches" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True) + handbook_scope: Mapped[str] = mapped_column(String(16), nullable=False, index=True) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=_utcnow, onupdate=_utcnow + ) + + __table_args__ = ( + UniqueConstraint("user_id", "handbook_scope", name="uq_huya_workbench"), + ) + + class HuyaConfig(Base): """虎牙业务配置""" diff --git a/web/backend/routers/huya.py b/web/backend/routers/huya.py index 377d078..3136766 100644 --- a/web/backend/routers/huya.py +++ b/web/backend/routers/huya.py @@ -50,6 +50,8 @@ from ..models import ( HuyaRegisterItem, HuyaRegisterSuccessLog, HuyaTask, + HuyaWorkbench, + HuyaWorkbenchAccount, ProxyConfig, User, ) @@ -79,6 +81,7 @@ from ..schemas import ( HuyaSmsLoginRequest, HuyaTaskBatchRequest, HuyaTaskOut, + HuyaWorkbenchAccountsUpdate, ) from ..services.audit_service import record_audit from ..services.huya_register_runner import ( @@ -281,6 +284,18 @@ def _require_huya_task_account_access( raise HTTPException(status_code=403, detail="包含无权操作的虎牙账号") +def _elite_workbench_account_ids(db: Session, current: User) -> list[int]: + query = db.query(HuyaWorkbenchAccount.account_id).filter( + HuyaWorkbenchAccount.user_id == current.id, + HuyaWorkbenchAccount.handbook_scope == "elite", + ) + if not _can_view_huya_all(current): + query = query.join(HuyaAccount, HuyaAccount.id == HuyaWorkbenchAccount.account_id).filter( + HuyaAccount.assigned_to == current.id + ) + return [account_id for (account_id,) in query.order_by(HuyaWorkbenchAccount.id.asc()).all()] + + def _require_huya_batch_owner(db: Session, current: User, batch_id: str) -> None: """客服只能停止或订阅自己创建的任务批次。""" if _can_view_huya_all(current): @@ -385,6 +400,7 @@ def _task_out(task: HuyaTask, *, include_images: bool = False) -> HuyaTaskOut: account_uid=account.uid if account else "", account_nickname=account.nickname if account else "", task_type=task.task_type, + handbook_scope=getattr(task, "handbook_scope", "legacy") or "legacy", status=task.status or "", message=task.message or "", result=_sanitize_task_result( @@ -1282,6 +1298,9 @@ def delete_accounts_batch( db.query(HuyaTask).filter(HuyaTask.account_id.in_(ids)).delete( synchronize_session=False ) + db.query(HuyaWorkbenchAccount).filter( + HuyaWorkbenchAccount.account_id.in_(ids) + ).delete(synchronize_session=False) _clear_huya_account_references(db, ids) deleted = ( db.query(HuyaAccount) @@ -1310,6 +1329,9 @@ def delete_accounts_batch_selection( db.query(HuyaTask).filter(HuyaTask.account_id.in_(ids)).delete( synchronize_session=False ) + db.query(HuyaWorkbenchAccount).filter( + HuyaWorkbenchAccount.account_id.in_(ids) + ).delete(synchronize_session=False) _clear_huya_account_references(db, ids) deleted = ( db.query(HuyaAccount) @@ -1339,6 +1361,9 @@ def delete_account( db.query(HuyaTask).filter(HuyaTask.account_id == account_id).delete( synchronize_session=False ) + db.query(HuyaWorkbenchAccount).filter( + HuyaWorkbenchAccount.account_id == account_id + ).delete(synchronize_session=False) _clear_huya_account_references(db, [account_id]) db.delete(account) db.commit() @@ -1743,6 +1768,55 @@ def update_config( return _config_out(config) +@router.get("/workbench-accounts") +def list_huya_workbench_accounts( + handbook_scope: str = Query(..., pattern="^elite$"), + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:task")), +): + """读取当前用户的虎牙精英宝典账号集合。""" + ids = _elite_workbench_account_ids(db, current) + configured = db.query(HuyaWorkbench.id).filter( + HuyaWorkbench.user_id == current.id, + HuyaWorkbench.handbook_scope == handbook_scope, + ).first() is not None + return {"account_ids": ids, "configured": configured} + + +@router.put("/workbench-accounts") +def update_huya_workbench_accounts( + req: HuyaWorkbenchAccountsUpdate, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:task")), +): + """覆盖虎牙精英宝典账号集合,支持跨浏览器同步。""" + ids = sorted(set(req.account_ids)) + if any(account_id < 1 for account_id in ids): + raise HTTPException(status_code=400, detail="无效的账号 ID") + if ids: + _require_huya_task_account_access(db, current, ids) + workbench = db.query(HuyaWorkbench).filter( + HuyaWorkbench.user_id == current.id, + HuyaWorkbench.handbook_scope == req.handbook_scope, + ).first() + if workbench is None: + db.add(HuyaWorkbench(user_id=current.id, handbook_scope=req.handbook_scope)) + else: + workbench.updated_at = datetime.now(UTC) + db.query(HuyaWorkbenchAccount).filter( + HuyaWorkbenchAccount.user_id == current.id, + HuyaWorkbenchAccount.handbook_scope == req.handbook_scope, + ).delete(synchronize_session=False) + db.add_all([ + HuyaWorkbenchAccount( + user_id=current.id, handbook_scope=req.handbook_scope, account_id=account_id + ) + for account_id in ids + ]) + db.commit() + return {"account_ids": ids, "success": True} + + @router.get("/goods", response_model=list[HuyaGoodsOut]) def list_goods( db: Session = Depends(get_db), @@ -1794,6 +1868,7 @@ async def create_task_batch( req.task_type, current.id, req.payload, + req.handbook_scope, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -1838,6 +1913,7 @@ async def create_task_batch( @router.get("/tasks", response_model=list[HuyaTaskOut]) def list_tasks( batch_id: str | None = None, + handbook_scope: str | None = Query(None, pattern="^(legacy|elite)$"), include_images: bool = Query( False, description="是否返回 base64 小程序码(默认否,轮询请保持 false)" ), @@ -1848,6 +1924,8 @@ def list_tasks( query = _visible_huya_tasks_query(db, current) if batch_id: query = query.filter(HuyaTask.batch_id == batch_id) + if handbook_scope: + query = query.filter(HuyaTask.handbook_scope == handbook_scope) tasks = query.order_by(HuyaTask.id.desc()).limit(300).all() return [_task_out(task, include_images=include_images) for task in tasks] diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 692f383..0cd21ca 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -503,10 +503,16 @@ class HuyaConfigUpdate(BaseModel): class HuyaTaskBatchRequest(BaseModel): account_ids: list[int] task_type: str + handbook_scope: str = Field("legacy", pattern="^(legacy|elite)$") concurrency: int = 3 payload: dict[str, Any] = Field(default_factory=dict) +class HuyaWorkbenchAccountsUpdate(BaseModel): + handbook_scope: str = Field(..., pattern="^elite$") + account_ids: list[int] = Field(default_factory=list) + + class HuyaTaskOut(BaseModel): id: int batch_id: str @@ -514,6 +520,7 @@ class HuyaTaskOut(BaseModel): account_uid: str = "" account_nickname: str = "" task_type: str + handbook_scope: str = "legacy" status: str message: str = "" result: dict[str, Any] | None = None @@ -532,6 +539,7 @@ class HuyaTaskOut(BaseModel): "account_uid": self.account_uid, "account_nickname": self.account_nickname, "task_type": self.task_type, + "handbook_scope": self.handbook_scope, "status": self.status, "message": self.message, "result": self.result, diff --git a/web/backend/services/huya_runner.py b/web/backend/services/huya_runner.py index 85c9636..6173e6d 100644 --- a/web/backend/services/huya_runner.py +++ b/web/backend/services/huya_runner.py @@ -60,6 +60,7 @@ class HuyaBatchRunner( self._push_log("info", f"[{current}/{total}] 开始虎牙任务: {name}") if self.task_type not in { + "query_act_tasks", "query_points", "get_bind_qr", "confirm_bind", @@ -77,7 +78,11 @@ class HuyaBatchRunner( return try: - if self.task_type == "query_points": + if self.task_type == "query_act_tasks": + self._execute_query_act_tasks( + worker_db, task, account, account_info, config_info + ) + elif self.task_type == "query_points": self._execute_query_points( worker_db, task, account, account_info, config_info ) diff --git a/web/backend/services/huya_runner_goods.py b/web/backend/services/huya_runner_goods.py index 3070122..334d569 100644 --- a/web/backend/services/huya_runner_goods.py +++ b/web/backend/services/huya_runner_goods.py @@ -31,6 +31,52 @@ class GoodsMixin: def _wait_until(self, when: datetime, uid: int) -> bool: ... def _parse_scheduled_time(self, value: Any) -> datetime | None: ... + def _execute_query_act_tasks( + self, + worker_db: Session, + task: HuyaTask, + account: HuyaAccount, + account_info: dict, + config_info: dict, + ): + """读取精英宝典任务详情,供专用工作台展示购买/观看任务。""" + uid = self._resolve_uid(account_info) + cookie = account_info.get("cookie") or "" + if not uid or not cookie: + self._mark_task(worker_db, task, "failed", "账号 UID 或 Cookie 为空") + return + act_id = self._to_int(self.payload.get("act_id") or 25135) + if not act_id: + self._mark_task(worker_db, task, "failed", "精英宝典活动 ID 无效") + return + client: Any = HuyaHttpClient( + logger=lambda msg: self._push_log("info", f"[{uid}] {msg}") + ) + response = client.get_act_task_detail(uid=uid, cookie=cookie, act_id=act_id) + if response is None: + self._mark_task(worker_db, task, "error", "虎牙活动任务接口无响应") + return + result = response.to_dict() + result["act_id"] = act_id + if response.status != 200: + self._mark_task( + worker_db, + task, + "failed", + response.msg or f"虎牙活动任务查询失败: {response.status}", + result, + ) + return + account.status = "tasks_queried" + account.updated_at = datetime.now(UTC) + self._mark_task( + worker_db, + task, + "success", + f"已读取 {len(result.get('tasks', []))} 项精英宝典任务", + result, + ) + def _execute_query_points( self, worker_db: Session, diff --git a/web/backend/services/huya_service.py b/web/backend/services/huya_service.py index f442c85..060c1e4 100644 --- a/web/backend/services/huya_service.py +++ b/web/backend/services/huya_service.py @@ -14,6 +14,7 @@ from ..huya_defaults import HUYA_CONFIG_DEFAULTS, HUYA_CONFIG_FIELDS from ..models import HuyaAccount, HuyaConfig, HuyaTask SUPPORTED_TASK_TYPES = { + "query_act_tasks": "查询精英宝典任务", "get_bind_qr": "获取绑定二维码", "query_points": "一键查询积分", "query_game_name": "一键查询游戏名", @@ -25,6 +26,19 @@ SUPPORTED_TASK_TYPES = { "create_recharge_order": "生成支付二维码", } +HUYA_HANDBOOK_TASK_TYPES = { + "query_act_tasks", + "get_bind_qr", + "confirm_bind", + "query_points", + "query_game_name", + "refresh_goods", + "exchange_goods", + "query_exchange_records", + "refresh_recharge_goods", + "create_recharge_order", +} + def huya_config_value(field: str, value: str | None) -> str: """读取配置值;空值自动回退到当前活动默认配置。""" @@ -371,10 +385,15 @@ def create_planned_tasks( task_type: str, created_by: int, payload: dict | None = None, + handbook_scope: str = "legacy", ) -> tuple[str, int]: """创建虎牙任务记录,等待后台执行器消费。""" if task_type not in SUPPORTED_TASK_TYPES: raise ValueError("不支持的任务类型") + if handbook_scope not in {"legacy", "elite"}: + raise ValueError("无效的工作台") + if handbook_scope == "elite" and task_type not in HUYA_HANDBOOK_TASK_TYPES: + raise ValueError("该任务不属于精英宝典工作台") batch_id = uuid.uuid4().hex[:12] payload = payload or {} @@ -392,6 +411,7 @@ def create_planned_tasks( batch_id=batch_id, account_id=account.id, task_type=task_type, + handbook_scope=handbook_scope, status="planned", message="任务已创建,等待执行", result={"payload": payload} if payload else None, diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index 238512c..a0b28d0 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -25,6 +25,7 @@ const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage')); const HuyaRegisterPage = lazy(() => import('./pages/HuyaRegisterPage')); const HuyaDeviceBindingsPage = lazy(() => import('./pages/HuyaDeviceBindingsPage')); const HuyaTasksPage = lazy(() => import('./pages/HuyaTasksPage')); +const HuyaElitePage = lazy(() => import('./pages/HuyaElitePage')); const YybRechargePage = lazy(() => import('./pages/YybRechargePage')); const AuditLogsPage = lazy(() => import('./pages/AuditLogsPage')); @@ -94,6 +95,7 @@ function AppContent() { )} /> )} /> )} /> + )} /> )} /> )} /> )} /> diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index ea9d410..3076eb4 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -118,12 +118,24 @@ export const huyaApi = { updateConfig: (data: Partial) => api.put('/huya/config', data), listGoods: () => api.get('/huya/goods'), listRechargeGoods: () => api.get('/huya/recharge-goods'), + listWorkbenchAccounts: (scope: 'elite' = 'elite') => + api.get<{ account_ids: number[]; configured: boolean }, { account_ids: number[]; configured: boolean }>( + '/huya/workbench-accounts', { params: { handbook_scope: scope } }, + ), + updateWorkbenchAccounts: (accountIds: number[], scope: 'elite' = 'elite') => + api.put<{ account_ids: number[]; success: boolean }, { account_ids: number[]; success: boolean }>( + '/huya/workbench-accounts', { handbook_scope: scope, account_ids: accountIds }, + ), createTasks: (data: HuyaTaskBatchRequest) => api.post('/huya/tasks/batch', data), - listTasks: (batchId?: string) => + listTasks: (batchId?: string, scope?: 'legacy' | 'elite') => api.get('/huya/tasks', { // 列表轮询默认不带 base64 小程序码,避免每次 1MB+ 流量。 - params: batchId ? { batch_id: batchId, include_images: false } : { include_images: false }, + params: { + ...(batchId ? { batch_id: batchId } : {}), + ...(scope ? { handbook_scope: scope } : {}), + include_images: false, + }, }), tasksSummary: () => api.get('/huya/tasks/summary'), getTask: (taskId: number) => diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index bdd99ed..29ac640 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -682,6 +682,7 @@ export interface HuyaConfig { export interface HuyaTaskBatchRequest { account_ids: number[]; task_type: string; + handbook_scope?: 'legacy' | 'elite'; concurrency?: number; payload?: Record; } @@ -699,6 +700,7 @@ export interface HuyaTaskItem { account_uid: string; account_nickname: string; task_type: string; + handbook_scope: 'legacy' | 'elite'; status: string; message: string; result: Record | null; diff --git a/web/frontend/src/layouts/MainLayout.tsx b/web/frontend/src/layouts/MainLayout.tsx index ef6d61b..f4d03e1 100644 --- a/web/frontend/src/layouts/MainLayout.tsx +++ b/web/frontend/src/layouts/MainLayout.tsx @@ -95,7 +95,8 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) { huyaItems.push({ key: '/huya/cookies', label: 'Cookie 管理', icon: }); } if (can('huya:task')) { - huyaItems.push({ key: '/huya/tasks', label: '任务操作台', icon: }); + huyaItems.push({ key: '/huya/elite', label: '精英宝典', icon: }); + huyaItems.push({ key: '/huya/tasks', label: '旧版任务台', icon: }); } if (canAny(['yyb:session', 'yyb:history'])) { diff --git a/web/frontend/src/pages/HuyaElitePage.tsx b/web/frontend/src/pages/HuyaElitePage.tsx new file mode 100644 index 0000000..b333f5e --- /dev/null +++ b/web/frontend/src/pages/HuyaElitePage.tsx @@ -0,0 +1,209 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tag, Tooltip, Typography, +} from 'antd'; +import type { TableProps } from 'antd'; +import { + CheckCircleOutlined, ColumnWidthOutlined, GiftOutlined, ImportOutlined, QrcodeOutlined, + ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined, +} from '@ant-design/icons'; +import { huyaApi, type HuyaAccountItem, type HuyaConfig, type HuyaGoodsItem, type HuyaTaskItem } from '../api/modules'; +import RealtimeLogPanel from '../components/RealtimeLogPanel'; +import { usePermissions } from '../hooks/usePermissions'; +import { useWebSocketLogs } from '../hooks/useWebSocketLogs'; +import { getErrorMessage } from '../utils/error'; +import { message } from '../utils/antdMessage'; + +const { Text } = Typography; +const SCOPE = 'elite' as const; +const ACT_ID = 25135; +const DEFAULT_SID = '2203'; +const LAYOUT_KEY = 'huya_elite_layout_mode'; + +const TASK_LABELS: Record = { + query_act_tasks: '读取宝典任务', get_bind_qr: '获取绑定二维码', query_game_name: '查询游戏角色', + confirm_bind: '确认绑定角色', query_points: '查询积分', refresh_goods: '刷新商品', + exchange_goods: '兑换商品', query_exchange_records: '查询兑换记录', create_recharge_order: '生成开通支付码', +}; +const TASK_COLORS: Record = { + planned: 'default', pending: 'default', running: 'processing', success: 'success', + failed: 'error', error: 'error', stopped: 'warning', +}; +const TASK_STATUS_LABELS: Record = { + planned: '已计划', pending: '等待中', running: '执行中', success: '成功', + failed: '失败', error: '异常', stopped: '已停止', +}; + +function savedInterval(): number { + const value = Number(localStorage.getItem('huya_elite_refresh_interval')); + return Number.isInteger(value) && value >= 5 && value <= 60 ? value : 15; +} + +function resultText(task: HuyaTaskItem | undefined, key: string): string { + const value = task?.result?.[key]; + return value == null ? '' : String(value); +} + +function latestTask(tasks: HuyaTaskItem[], accountId: number): HuyaTaskItem | undefined { + return tasks.filter((task) => task.account_id === accountId).sort((a, b) => b.id - a.id)[0]; +} + +export default function HuyaElitePage() { + const { can } = usePermissions(); + const canConfig = can('huya:config'); + const [pool, setPool] = useState([]); + const [accounts, setAccounts] = useState([]); + const [selectedIds, setSelectedIds] = useState([]); + const [tasks, setTasks] = useState([]); + const [goods, setGoods] = useState([]); + const [selectedGoodsId, setSelectedGoodsId] = useState(''); + const [search, setSearch] = useState(''); + const [loading, setLoading] = useState(false); + const [starting, setStarting] = useState(false); + const [concurrency, setConcurrency] = useState(3); + const [refreshInterval, setRefreshInterval] = useState(savedInterval); + const [layoutMode, setLayoutMode] = useState<'split' | 'stack'>(() => localStorage.getItem(LAYOUT_KEY) === 'stack' ? 'stack' : 'split'); + const [activeBatches, setActiveBatches] = useState([]); + const [importOpen, setImportOpen] = useState(false); + const [importSearch, setImportSearch] = useState(''); + const [importTag, setImportTag] = useState(''); + const [importTags, setImportTags] = useState([]); + const [importSelected, setImportSelected] = useState([]); + const [qrTask, setQrTask] = useState(null); + const [config, setConfig] = useState(null); + const [configDraft, setConfigDraft] = useState(null); + const [configOpen, setConfigOpen] = useState(false); + const [configLoading, setConfigLoading] = useState(false); + const [configSaving, setConfigSaving] = useState(false); + const tasksLoading = useRef(false); + const logs = useWebSocketLogs(); + + const load = useCallback(async () => { + setLoading(true); + try { + const [poolResult, workbenchResult, goodsResult, taskResult] = await Promise.all([ + huyaApi.listAccounts({ include_cookie: false }), huyaApi.listWorkbenchAccounts(SCOPE), + huyaApi.listGoods(), huyaApi.listTasks(undefined, SCOPE), + ]); + setPool(poolResult); + const byId = new Map(poolResult.map((item) => [item.id, item])); + let storedIds: number[] = workbenchResult.account_ids; + if (!workbenchResult.configured) { + try { storedIds = JSON.parse(localStorage.getItem('huya_elite_workbench_account_ids') || '[]') as number[]; } catch { storedIds = []; } + } + setAccounts(storedIds.map((id) => byId.get(Number(id))).filter((item): item is HuyaAccountItem => Boolean(item))); + setSelectedIds((prev) => prev.filter((id) => storedIds.includes(id))); + setGoods(goodsResult); + setTasks(taskResult); + } catch (error) { message.error(getErrorMessage(error)); } finally { setLoading(false); } + }, []); + + const loadTasks = useCallback(async () => { + if (tasksLoading.current) return; + tasksLoading.current = true; + try { setTasks(await huyaApi.listTasks(undefined, SCOPE)); } catch { /* 下一轮刷新 */ } finally { tasksLoading.current = false; } + }, []); + + useEffect(() => { void load(); }, [load]); + useEffect(() => { if (importOpen) void huyaApi.listTags().then(setImportTags).catch(() => {}); }, [importOpen]); + useEffect(() => { + const active = tasks.some((task) => ['planned', 'pending', 'running'].includes(task.status)); + const timer = window.setInterval(() => { void loadTasks(); }, (active ? 3 : refreshInterval) * 1000); + return () => window.clearInterval(timer); + }, [loadTasks, refreshInterval, tasks]); + + const saveAccounts = async (ids: number[]) => { + const saved = await huyaApi.updateWorkbenchAccounts(ids, SCOPE); + localStorage.setItem('huya_elite_workbench_account_ids', JSON.stringify(saved.account_ids)); + const next = saved.account_ids.map((id) => pool.find((item) => item.id === id)).filter((item): item is HuyaAccountItem => Boolean(item)); + setAccounts(next); + setSelectedIds((prev) => prev.filter((id) => saved.account_ids.includes(id))); + }; + + const startTask = async (taskType: string) => { + if (!selectedIds.length) { message.warning('请先勾选账号'); return; } + if (taskType === 'exchange_goods' && !selectedGoodsId) { message.warning('请先选择兑换商品'); return; } + setStarting(true); + try { + const payload = taskType === 'exchange_goods' + ? { sid: Number(config?.sid || DEFAULT_SID), product_id: Number(selectedGoodsId), act_id: ACT_ID } + : taskType === 'query_act_tasks' ? { act_id: ACT_ID } : {}; + const created = await huyaApi.createTasks({ account_ids: selectedIds, task_type: taskType, handbook_scope: SCOPE, concurrency, payload }); + setActiveBatches((prev) => [...new Set([...prev, created.batch_id])]); + logs.connectBatch(created.batch_id, `/api/huya/ws/${created.batch_id}`, { + clear: false, + onTask: (raw) => setTasks((prev) => [raw as unknown as HuyaTaskItem, ...prev.filter((item) => item.id !== Number((raw as { id?: number }).id))].slice(0, 300)), + onResult: () => { setActiveBatches((prev) => prev.filter((id) => id !== created.batch_id)); void loadTasks(); }, + }); + message.success(`已创建 ${created.count} 个任务`); + window.setTimeout(() => { void loadTasks(); }, 400); + } catch (error) { message.error(getErrorMessage(error)); } finally { setStarting(false); } + }; + + const openSettings = async () => { + if (!canConfig) return; + setConfigOpen(true); + if (config) { setConfigDraft({ ...config }); return; } + setConfigLoading(true); + try { const current = await huyaApi.getConfig(); setConfig(current); setConfigDraft({ ...current }); } + catch (error) { setConfigOpen(false); message.error(getErrorMessage(error)); } + finally { setConfigLoading(false); } + }; + + const saveConfig = async () => { + if (!configDraft) return; + setConfigSaving(true); + try { const saved = await huyaApi.updateConfig(configDraft); setConfig(saved); setConfigDraft({ ...saved }); setConfigOpen(false); message.success('配置已保存'); } + catch (error) { message.error(getErrorMessage(error)); } finally { setConfigSaving(false); } + }; + + const stop = async () => { + try { await Promise.all(activeBatches.map((batchId) => huyaApi.stopBatch(batchId))); setActiveBatches([]); message.success('已停止当前批次'); } + catch (error) { message.error(getErrorMessage(error)); } + }; + + const openQrTask = async (task: HuyaTaskItem) => { + setQrTask(task); + try { setQrTask(await huyaApi.getTask(task.id)); } catch { /* 使用列表结果 */ } + }; + + const filteredAccounts = useMemo(() => { + const value = search.trim().toLowerCase(); + return accounts.filter((item) => !value || [item.uid, item.nickname, item.username, item.tag, item.game_name].some((v) => String(v || '').toLowerCase().includes(value))); + }, [accounts, search]); + const filteredGoods = useMemo(() => goods.map((item) => ({ ...item, label: `${item.name} / ${item.price ?? '-'}分 / ${item.remain_text || '动态库存'}` })), [goods]); + const exchangeCount = tasks.filter((task) => task.task_type === 'exchange_goods').length; + + const accountColumns: TableProps['columns'] = [ + { title: '#', width: 45, render: (_value, _row, index) => {index + 1} }, + { title: '账号', width: 190, render: (_value, row) => {row.nickname || row.username || row.uid}UID {row.uid || '-'} }, + { title: '游戏名', width: 180, render: (_value, row) => row.game_name || row.game_channel || 未查询 }, + { title: '积分', dataIndex: 'points', width: 80, render: (value: number | null) => value ?? - }, + { title: '最近操作', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); return task ? {TASK_LABELS[task.task_type] || task.task_type}{task.message || '-'} : 暂无; } }, + { title: '状态', width: 90, render: (_value, row) => { const task = latestTask(tasks, row.id); const status = task?.status || row.status || ''; return {TASK_STATUS_LABELS[status] || status || '待操作'}; } }, + ]; + + const operationBody = ( + +
绑定与查询
+ +
+
兑换
} placeholder="搜索账号/昵称/游戏名" value={search} onChange={(event) => setSearch(event.target.value)} style={{ width: 240 }} />{selectedIds.length > 0 && }
setSelectedIds(keys.map(Number)) }} columns={accountColumns} dataSource={filteredAccounts} pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }} scroll={{ x: 760, y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (task?.task_type === 'get_bind_qr') void openQrTask(task); } })} />; + + const configField = (key: Exclude, label: string) => configDraft && {label} setConfigDraft({ ...configDraft, [key]: event.target.value })} style={{ width: 300 }} />; + + return
+

虎牙精英宝典工作台

绑定、开通、积分与兑换任务
{canConfig && }{!!activeBatches.length && }
+ {layoutMode === 'split' ?
已选 {selectedIds.length}/{accounts.length}} 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}并发 setConcurrency(value || 1)} style={{ width: 58 }} />} style={{ width: 360, flexShrink: 0, minHeight: 0, overflow: 'hidden' }} styles={{ body: { padding: 8, overflowY: 'auto', height: 'calc(100% - 38px)' } }}>{operationBody}
: <>并发 setConcurrency(value || 1)} style={{ width: 58 }} />} style={{ flexShrink: 0, marginBottom: 12 }} styles={{ body: { padding: 8 } }}>{operationBody}已选 {selectedIds.length}/{accounts.length}} 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}} + 兑换任务 {exchangeCount} 条;勾选账号后可批量执行。 + setQrTask(null)} centered>{qrTask &&
{resultText(qrTask, 'mini_qrcode_image') ? 绑定二维码 : }

{qrTask.message || qrTask.status}

}
+ 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="取消"> setImportSearch(event.target.value)} style={{ marginBottom: 8 }} />
!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)) }} /> + setConfigOpen(false)} onOk={() => void saveConfig()} confirmLoading={configSaving} okText="保存" cancelText="取消" width={500}>{configLoading ?
加载中...
: configDraft && {configField('sid', '活动 SID')}{configField('bind_act_id', '绑定活动 ID')}{configField('outer_act_id', '外部活动 ID')}{configField('room_pid', '直播间 PID')}{configField('pay_channel', '支付渠道')}空闲轮询间隔(秒) { const next = Math.min(60, Math.max(5, value || 15)); setRefreshInterval(next); localStorage.setItem('huya_elite_refresh_interval', String(next)); }} style={{ width: 300 }} />}
+ ; +}