feat(huya): 开通支付码支持多选批量 + 支付后自动确认开通
多选账号点「生成开通支付码」以前只有弹窗里最后一个码能被看到(每个账号一个 任务,前端只留一个 qrTask),实际只能一个账号一个账号地开。 - 前端:支付码弹窗改成列表 —— 一次列出该批次所有账号的二维码(账号/UID/ 商品金额/状态),未支付的排前面,可逐个扫也可一起扫;行点击也会打开对应批次 - 支付成功后自动确认开通:新增 EliteMixin._refresh_elite_open_after_payment, 交付有几秒延迟,tag5 还没翻成 1 时重试(默认 4 次 × 3 秒),顺带取最新积分, 并把 elite_opened/elite_open_time/elite_expire_time 写回账号 - 已支付或已开通的账号二维码自动从弹窗收起,只留一行「已开通」标签 - 开通时间优先用支付回调里的支付时间(比再去翻订单更准,也少一次请求) - 补 3 条测试:重试到已开通、重试到上限、支付成功后任务结果与账号字段齐全 全量 746 passed / 6 skipped,前端 tsc 通过
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
"""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
from core.huya.frame_decoder import decode_struct_bytes
|
||||
from core.huya.http_client import HuyaHttpClient
|
||||
@@ -117,9 +118,11 @@ class _StubClient:
|
||||
self.order_pages = order_pages or []
|
||||
self.season_payload = ALLOCATION_SEASON_PAYLOAD if season else None
|
||||
self.calls: list[tuple[str, dict]] = []
|
||||
self.redeem_calls = 0
|
||||
|
||||
def get_redeem_need_goods(self, **kwargs):
|
||||
self.calls.append(("get_redeem_need_goods", kwargs))
|
||||
self.redeem_calls += 1
|
||||
return self.redeem
|
||||
|
||||
def play_mall_query_allocation(self, **kwargs):
|
||||
@@ -252,6 +255,156 @@ def test_query_all_fills_expire_time_from_season(monkeypatch):
|
||||
assert "有效期至 2026-12-31" in message
|
||||
|
||||
|
||||
def test_refresh_elite_open_after_payment_retries_until_opened():
|
||||
"""刚支付完 tag5 可能还是 0(交付延迟)→ 重试到已开通,并写回账号。"""
|
||||
|
||||
class _FlakyClient(_StubClient):
|
||||
def get_redeem_need_goods(self, **kwargs):
|
||||
result = super().get_redeem_need_goods(**kwargs)
|
||||
if self.redeem_calls > 1:
|
||||
return _redeem(REDEEM_OPENED_HEX)
|
||||
return result
|
||||
|
||||
client = _FlakyClient(season=True)
|
||||
runner = HuyaBatchRunner(db=None, batch_id="t", task_type="create_recharge_order")
|
||||
account = HuyaAccount(uid=str(UID), cookie=COOKIE)
|
||||
|
||||
state = runner._refresh_elite_open_after_payment(
|
||||
client,
|
||||
account,
|
||||
UID,
|
||||
COOKIE,
|
||||
goods_num=8315982,
|
||||
pay_time_ms=1792053457000,
|
||||
attempts=3,
|
||||
interval=0,
|
||||
)
|
||||
|
||||
assert client.redeem_calls == 2 # 第一次未开通 → 重试一次即成功
|
||||
assert state["elite_opened"] is True
|
||||
assert state["elite_open_time"] == 1792053457
|
||||
assert state["elite_expire_time"] == 1798732799
|
||||
assert state["redeem_points"] == 280
|
||||
assert account.elite_opened is True
|
||||
assert account.elite_open_time == 1792053457
|
||||
assert account.elite_expire_time == 1798732799
|
||||
|
||||
|
||||
def test_refresh_elite_open_after_payment_gives_up_after_attempts():
|
||||
client = _StubClient(redeem=_redeem(REDEEM_NOT_OPENED_HEX))
|
||||
runner = HuyaBatchRunner(db=None, batch_id="t", task_type="create_recharge_order")
|
||||
account = HuyaAccount(uid=str(UID), cookie=COOKIE)
|
||||
|
||||
state = runner._refresh_elite_open_after_payment(
|
||||
client,
|
||||
account,
|
||||
UID,
|
||||
COOKIE,
|
||||
goods_num=8315982,
|
||||
pay_time_ms=1792053457000,
|
||||
attempts=2,
|
||||
interval=0,
|
||||
)
|
||||
|
||||
assert client.redeem_calls == 2 # 重试到上限
|
||||
assert state["elite_opened"] is False
|
||||
assert account.elite_opened is False
|
||||
assert account.elite_open_time is None
|
||||
|
||||
|
||||
def test_create_recharge_order_confirms_elite_open_after_payment(db, monkeypatch):
|
||||
"""支付成功后自动确认开通状态:任务结果与账号字段都带上宝典状态。"""
|
||||
|
||||
outcome = SimpleNamespace(
|
||||
ok=True,
|
||||
message="",
|
||||
detail={"spu_id": "hy-8328015", "sku_id": 7756811, "name": "精英宝典"},
|
||||
sku_id=7756811,
|
||||
product_name="精英宝典",
|
||||
unit_price=3000,
|
||||
order_raw={"order_id": 11399999},
|
||||
pay_raw={"pay_order_id": "pay-1"},
|
||||
amount=3000,
|
||||
order_id=11399999,
|
||||
app_order_id="app-1",
|
||||
pay_order_id="pay-1",
|
||||
pay_url="https://example.com/pay",
|
||||
supplier_name="和平精英官方小店",
|
||||
promotion={},
|
||||
promotion_info="本单无返利",
|
||||
)
|
||||
|
||||
shop_client = _StubClient(redeem=_redeem(REDEEM_OPENED_HEX), season=True)
|
||||
runner = HuyaBatchRunner(
|
||||
db=None,
|
||||
batch_id="t",
|
||||
task_type="create_recharge_order",
|
||||
payload={"spu_id": "hy-8328015", "sku_id": 7756811, "count": 1},
|
||||
)
|
||||
marks: list[tuple[str, str, dict | None]] = []
|
||||
monkeypatch.setattr(
|
||||
runner, "_shop_client", lambda uid, cookie: shop_client, raising=False
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
runner,
|
||||
"_mark_task",
|
||||
lambda _db, _task, status, message, result=None: marks.append(
|
||||
(status, message, result)
|
||||
),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
runner, "_update_task_progress", lambda *args, **kwargs: None, raising=False
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
runner, "_push_log", lambda *args, **kwargs: None, raising=False
|
||||
)
|
||||
|
||||
def fake_wait_payment(**kwargs):
|
||||
# 真实现会边轮询边写 result,这里照抄「已支付」那几个字段
|
||||
target = kwargs.get("result")
|
||||
if isinstance(target, dict):
|
||||
target.update(
|
||||
{
|
||||
"payment_status": "paid",
|
||||
"payment_status_label": "已支付",
|
||||
"payment_paid": True,
|
||||
}
|
||||
)
|
||||
return "paid", {"pay_time": 1792053457000}
|
||||
|
||||
monkeypatch.setattr(
|
||||
runner, "_wait_recharge_payment", fake_wait_payment, raising=False
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"web.backend.services.huya_runner_recharge.run_open_flow",
|
||||
lambda *args, **kwargs: outcome,
|
||||
)
|
||||
account = HuyaAccount(uid=str(UID), cookie=COOKIE)
|
||||
db.add(account)
|
||||
db.commit()
|
||||
|
||||
runner._execute_create_recharge_order(
|
||||
db,
|
||||
None,
|
||||
account,
|
||||
{"uid": UID, "cookie": COOKIE},
|
||||
{"room_pid": "1199650619883", "pay_channel": "Zfb", "sid": "2203"},
|
||||
)
|
||||
|
||||
status, _message, result = marks[-1]
|
||||
assert status == "success"
|
||||
assert result["payment_paid"] is True
|
||||
assert result["elite_opened"] is True
|
||||
assert result["elite_open_time"] == 1792053457
|
||||
assert result["elite_expire_time"] == 1798732799
|
||||
assert result["post_payment_points"] == 280
|
||||
assert account.elite_opened is True
|
||||
assert account.elite_expire_time == 1798732799
|
||||
assert account.points == 280
|
||||
assert account.status == "recharge_paid"
|
||||
|
||||
|
||||
def test_query_all_combines_points_elite_and_role(monkeypatch):
|
||||
shop_client = _StubClient(
|
||||
redeem=_redeem(REDEEM_OPENED_HEX), order_pages=[[PAID_ELITE_ORDER]]
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -168,6 +169,62 @@ class EliteMixin:
|
||||
return None
|
||||
return self._extract_elite_season(getattr(resp, "payload", None))
|
||||
|
||||
def _refresh_elite_open_after_payment(
|
||||
self,
|
||||
client: Any,
|
||||
account: HuyaAccount,
|
||||
uid: int,
|
||||
cookie: str,
|
||||
*,
|
||||
goods_num: int,
|
||||
pay_time_ms: int | None = None,
|
||||
attempts: int = 4,
|
||||
interval: float = 3.0,
|
||||
) -> dict:
|
||||
"""支付完成后确认宝典是否已开通,并写回账号。
|
||||
|
||||
交付有几秒延迟,所以 ``tag5`` 还没翻成 1 时会重试(默认 4 次 × 3 秒)。
|
||||
返回可直接并入任务结果的 dict(含 ``redeem_points``,省掉一次重复查询)。
|
||||
"""
|
||||
redeem = None
|
||||
opened: bool | None = None
|
||||
total = max(1, attempts)
|
||||
for index in range(total):
|
||||
redeem = client.get_redeem_need_goods(
|
||||
uid=uid, guid="", cookie=cookie, goods_num=int(goods_num or 0)
|
||||
)
|
||||
opened = self._elite_opened_from_redeem(redeem)
|
||||
if opened or index == total - 1:
|
||||
break
|
||||
time.sleep(interval)
|
||||
|
||||
state: dict[str, Any] = {"elite_checked_at": datetime.now(UTC).isoformat()}
|
||||
if redeem is not None and getattr(redeem, "code", 0) == 200:
|
||||
state["redeem"] = redeem.to_dict()
|
||||
state["redeem_points"] = self._to_int(redeem.value("tag2"))
|
||||
if opened is None:
|
||||
return state
|
||||
|
||||
open_time: int | None = pay_time_ms or None
|
||||
if opened and not open_time:
|
||||
open_time = self._resolve_elite_open_time(client, uid, cookie)
|
||||
season = self._resolve_elite_season(client, uid, cookie) if opened else None
|
||||
expire_time = self._to_int(season["end_ms"]) if season else None
|
||||
self._apply_elite_state(account, opened, open_time, expire_time)
|
||||
|
||||
state["elite_opened"] = opened
|
||||
state["elite_open_time"] = (open_time // 1000) if open_time else None
|
||||
state["elite_open_time_text"] = (
|
||||
self._format_local_time(open_time // 1000) if open_time else ""
|
||||
)
|
||||
if season:
|
||||
state["elite_season"] = season
|
||||
state["elite_expire_time"] = expire_time // 1000
|
||||
state["elite_expire_time_text"] = self._format_local_time(
|
||||
expire_time // 1000
|
||||
)
|
||||
return state
|
||||
|
||||
def _apply_elite_state(
|
||||
self,
|
||||
account: HuyaAccount,
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.huya.wss_client import SHOP_APP_SCENE
|
||||
from core.huya.wss_client import POINTS_REDEEM_GOODS_NUM, SHOP_APP_SCENE
|
||||
|
||||
from ..models import HuyaAccount, HuyaRechargeGoodsSnapshot, HuyaTask
|
||||
from .huya_shop_flow import run_open_flow
|
||||
@@ -91,6 +91,18 @@ class RechargeMixin:
|
||||
def _update_task_progress(self, *args: Any, **kwargs: Any) -> None: ...
|
||||
def _open_wss_session(self, uid: int, cookie: str, kind: str): ...
|
||||
def _shop_client(self, uid: int, cookie: str): ...
|
||||
def _refresh_elite_open_after_payment(
|
||||
self,
|
||||
client: Any,
|
||||
account: HuyaAccount,
|
||||
uid: int,
|
||||
cookie: str,
|
||||
*,
|
||||
goods_num: int,
|
||||
pay_time_ms: int | None = None,
|
||||
attempts: int = 4,
|
||||
interval: float = 3.0,
|
||||
) -> dict: ...
|
||||
|
||||
@staticmethod
|
||||
def _format_local_time(timestamp: int) -> str: ...
|
||||
@@ -561,12 +573,25 @@ class RechargeMixin:
|
||||
account.updated_at = datetime.now(UTC)
|
||||
if payment_status == "paid":
|
||||
account.status = "recharge_paid"
|
||||
# 支付后刷新积分:旧活动接口 getUserScore 已下线(603 活动已结束),
|
||||
# 改走积分中心 getRedeemNeedGoods(tag2 = 当前积分)。
|
||||
post_redeem = client.get_redeem_need_goods(uid=uid, guid="", cookie=cookie)
|
||||
if post_redeem is not None and post_redeem.code == 200:
|
||||
result["post_payment_points"] = self._to_int(post_redeem.value("tag2"))
|
||||
account.points = result["post_payment_points"]
|
||||
# 支付后自动确认宝典开通状态(顺带取最新积分):交付有几秒延迟,
|
||||
# _refresh_elite_open_after_payment 会重试到 tag5 变成已开通。
|
||||
elite_state = self._refresh_elite_open_after_payment(
|
||||
client,
|
||||
account,
|
||||
uid,
|
||||
cookie,
|
||||
goods_num=self._to_int(self.payload.get("goods_num"))
|
||||
or POINTS_REDEEM_GOODS_NUM,
|
||||
pay_time_ms=(
|
||||
self._to_int(payment_order.get("pay_time"))
|
||||
if payment_order
|
||||
else None
|
||||
),
|
||||
)
|
||||
result.update(elite_state)
|
||||
if "redeem_points" in elite_state:
|
||||
result["post_payment_points"] = elite_state["redeem_points"]
|
||||
account.points = elite_state["redeem_points"]
|
||||
paid_message = f"支付成功: {product_name} x{count} {result['amount_text']}"
|
||||
if payment_order and payment_order.get("pay_time"):
|
||||
paid_message += f",支付时间 {self._format_local_time(int(payment_order['pay_time']) // 1000)}"
|
||||
|
||||
@@ -59,6 +59,12 @@ function resultText(task: HuyaTaskItem | undefined, key: string): string {
|
||||
return value == null ? '' : String(value);
|
||||
}
|
||||
|
||||
/** 开通支付码是否还需要扫码:已支付或已确认开通就不用再展示了 */
|
||||
function needsScanPay(task: HuyaTaskItem): boolean {
|
||||
const result = task.result || {};
|
||||
return !(result.payment_paid === true || result.elite_opened === true);
|
||||
}
|
||||
|
||||
function latestTask(tasks: HuyaTaskItem[], accountId: number): HuyaTaskItem | undefined {
|
||||
return tasks.filter((task) => task.account_id === accountId).sort((a, b) => b.id - a.id)[0];
|
||||
}
|
||||
@@ -93,6 +99,9 @@ export default function HuyaElitePage() {
|
||||
const [importTags, setImportTags] = useState<string[]>([]);
|
||||
const [importSelected, setImportSelected] = useState<number[]>([]);
|
||||
const [qrTask, setQrTask] = useState<HuyaTaskItem | null>(null);
|
||||
// 开通支付码:多选账号时可以一次生成多个码,弹窗里逐个展示(按批次收集)
|
||||
const [payBatchIds, setPayBatchIds] = useState<string[]>([]);
|
||||
const [payOpen, setPayOpen] = useState(false);
|
||||
const [ckImportOpen, setCkImportOpen] = useState(false);
|
||||
const [config, setConfig] = useState<HuyaConfig | null>(null);
|
||||
const [configDraft, setConfigDraft] = useState<HuyaConfig | null>(null);
|
||||
@@ -176,13 +185,18 @@ export default function HuyaElitePage() {
|
||||
})() : {};
|
||||
const created = await huyaApi.createTasks({ account_ids: accountIds, task_type: taskType, handbook_scope: SCOPE, concurrency, payload });
|
||||
setActiveBatches((prev) => [...new Set([...prev, created.batch_id])]);
|
||||
if (taskType === 'create_recharge_order') {
|
||||
// 多选账号时每个账号一个支付码:收进同一列表,弹窗里一起展示
|
||||
setPayBatchIds((prev) => [...new Set([...prev, created.batch_id])]);
|
||||
setPayOpen(true);
|
||||
}
|
||||
logs.connectBatch(created.batch_id, `/api/huya/ws/${created.batch_id}`, {
|
||||
clear: false,
|
||||
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 || {};
|
||||
if (next.task_type === 'create_recharge_order' && typeof result.pay_url === 'string' && result.pay_url) setQrTask(next);
|
||||
if (next.task_type === 'create_recharge_order' && result.pay_url) setPayOpen(true);
|
||||
if (next.task_type === 'get_bind_qr' && (result.mini_qrcode_image || result.bind_redirect_url)) setQrTask(next);
|
||||
},
|
||||
onResult: () => { setActiveBatches((prev) => prev.filter((id) => id !== created.batch_id)); void load(); },
|
||||
@@ -250,6 +264,21 @@ export default function HuyaElitePage() {
|
||||
}
|
||||
}, [filteredRechargeGoods, selectedRechargeSpu]);
|
||||
const exchangeCount = tasks.filter((task) => task.task_type === 'exchange_goods').length;
|
||||
// 开通支付码:同一批次里每个账号一个码,未支付的排前面;已支付/已开通的只留一行状态
|
||||
const payTasks = useMemo(() => {
|
||||
const list = tasks.filter((task) => task.task_type === 'create_recharge_order'
|
||||
&& payBatchIds.includes(task.batch_id)
|
||||
&& Boolean(resultText(task, 'pay_url') || resultText(task, 'mini_qrcode_image')));
|
||||
return [...list].sort((a, b) => Number(needsScanPay(b)) - Number(needsScanPay(a)) || a.id - b.id);
|
||||
}, [tasks, payBatchIds]);
|
||||
const payPendingTasks = payTasks.filter(needsScanPay);
|
||||
const payDoneTasks = payTasks.filter((task) => !needsScanPay(task));
|
||||
|
||||
const openPayTasks = (batchId?: string) => {
|
||||
if (batchId) setPayBatchIds((prev) => (prev.includes(batchId) ? prev : [...prev, batchId]));
|
||||
setPayOpen(true);
|
||||
};
|
||||
const payTaskLabel = (task: HuyaTaskItem) => task.account_nickname || task.account_uid || `#${task.account_id}`;
|
||||
|
||||
// 列宽按比例分配(percent),表头与单元格统一居中,避免左右跳着看
|
||||
const accountColumns: TableProps<HuyaAccountItem>['columns'] = [
|
||||
@@ -276,7 +305,7 @@ export default function HuyaElitePage() {
|
||||
</Space>
|
||||
);
|
||||
|
||||
const accountsBody = <><Space style={{ marginBottom: 8, width: '100%', justifyContent: 'space-between' }} wrap><Input size="small" allowClear prefix={<SearchOutlined />} placeholder="搜索账号/昵称/游戏名/手机号" value={search} onChange={(event) => setSearch(event.target.value)} style={{ width: 240 }} /><Space><Button size="small" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>导入账号</Button>{canImport && <Button size="small" icon={<ImportOutlined />} onClick={() => setCkImportOpen(true)}>导入外部 CK</Button>}{selectedIds.length > 0 && <Button size="small" danger onClick={() => void saveAccounts(accounts.map((item) => item.id).filter((id) => !selectedIds.includes(id)))}>移出选中</Button>}</Space></Space><div className="huya-account-table"><Table rowKey="id" size="small" loading={loading} tableLayout="fixed" rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(Number)) }} columns={accountColumns} dataSource={filteredAccounts} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }} scroll={{ y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (task && ['get_bind_qr', 'create_recharge_order'].includes(task.task_type) && task.result) void openQrTask(task); }, onContextMenu: (event) => handleRowContextMenu(row, event), style: { cursor: 'context-menu' }, title: '右键打开账号动作' })} /></div></>;
|
||||
const accountsBody = <><Space style={{ marginBottom: 8, width: '100%', justifyContent: 'space-between' }} wrap><Input size="small" allowClear prefix={<SearchOutlined />} placeholder="搜索账号/昵称/游戏名/手机号" value={search} onChange={(event) => setSearch(event.target.value)} style={{ width: 240 }} /><Space><Button size="small" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>导入账号</Button>{canImport && <Button size="small" icon={<ImportOutlined />} onClick={() => setCkImportOpen(true)}>导入外部 CK</Button>}{selectedIds.length > 0 && <Button size="small" danger onClick={() => void saveAccounts(accounts.map((item) => item.id).filter((id) => !selectedIds.includes(id)))}>移出选中</Button>}</Space></Space><div className="huya-account-table"><Table rowKey="id" size="small" loading={loading} tableLayout="fixed" rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(Number)) }} columns={accountColumns} dataSource={filteredAccounts} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }} scroll={{ y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (!task?.result) return; if (task.task_type === 'get_bind_qr') void openQrTask(task); else if (task.task_type === 'create_recharge_order') openPayTasks(task.batch_id); }, onContextMenu: (event) => handleRowContextMenu(row, event), style: { cursor: 'context-menu' }, title: '右键打开账号动作' })} /></div></>;
|
||||
|
||||
const configField = (key: Exclude<keyof HuyaConfig, 'updated_at'>, label: string) => configDraft && <Space key={key} style={{ width: '100%', justifyContent: 'space-between' }}><Text>{label}</Text><Input value={configDraft[key] || ''} onChange={(event) => setConfigDraft({ ...configDraft, [key]: event.target.value })} style={{ width: 300 }} /></Space>;
|
||||
const contextMenuStyle = contextMenu ? {
|
||||
@@ -293,7 +322,40 @@ 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={qrTask?.task_type === 'create_recharge_order' ? '开通支付二维码' : '绑定二维码'} 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, 'pay_url') || resultText(qrTask, 'bind_redirect_url') || 'https://zt.huya.com/b02faae1/pc/index.html'} size={240} />}<p><Tag color={TASK_COLORS[qrTask.status] || 'default'}>{qrTask.message || qrTask.status}</Tag></p>{qrTask.task_type === 'create_recharge_order' && <Text type="secondary">扫码后订单状态会自动轮询更新</Text>}</div>}</Modal>
|
||||
<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} />}<p><Tag color={TASK_COLORS[qrTask.status] || 'default'}>{qrTask.message || qrTask.status}</Tag></p></div>}</Modal>
|
||||
<Modal
|
||||
open={payOpen}
|
||||
title={payTasks.length ? `开通支付码(${payDoneTasks.length}/${payTasks.length} 已完成)` : '开通支付码'}
|
||||
width={880}
|
||||
centered
|
||||
onCancel={() => setPayOpen(false)}
|
||||
footer={<Space><Text type="secondary">支付后会自动确认开通状态;已开通的码自动收起</Text><Button onClick={() => setPayOpen(false)}>关闭</Button><Button onClick={() => { setPayBatchIds([]); setPayOpen(false); }}>清空列表</Button></Space>}
|
||||
>
|
||||
{payTasks.length === 0 ? <div style={{ padding: 24, textAlign: 'center' }}><Text type="secondary">正在为选中的账号生成支付码…</Text></div> : <div style={{ maxHeight: '62vh', overflowY: 'auto' }}>
|
||||
{payPendingTasks.length > 0 && <Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
{payPendingTasks.map((task) => (
|
||||
<div key={task.id} style={{ display: 'flex', gap: 16, alignItems: 'center', border: '1px solid rgba(128,128,128,.22)', borderRadius: 6, padding: 10, textAlign: 'left' }}>
|
||||
<div style={{ lineHeight: 0 }}>{resultText(task, 'mini_qrcode_image') ? <img src={`data:image/png;base64,${resultText(task, 'mini_qrcode_image')}`} alt="支付码" style={{ width: 150, height: 150 }} /> : <QRCode value={resultText(task, 'pay_url')} size={150} bordered={false} />}</div>
|
||||
<Space direction="vertical" size={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text strong ellipsis>{payTaskLabel(task)}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>UID {task.account_uid || '-'}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{[resultText(task, 'product_name') || '精英宝典', resultText(task, 'amount_text')].filter(Boolean).join(' ')}</Text>
|
||||
<Tag color={TASK_COLORS[task.status] || 'default'}>{resultText(task, 'payment_status_label') || TASK_STATUS_LABELS[task.status] || task.status}</Tag>
|
||||
<Text type="secondary" ellipsis title={task.message}>{task.message || '-'}</Text>
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
</Space>}
|
||||
{payDoneTasks.length > 0 && <div style={{ marginTop: payPendingTasks.length ? 12 : 0, textAlign: 'left' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>已完成(二维码已收起)</Text>
|
||||
<div style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{payDoneTasks.map((task) => (
|
||||
<Tag key={task.id} color={task.result?.elite_opened === true ? 'success' : 'blue'}>{payTaskLabel(task)} · {task.result?.elite_opened === true ? '已开通' : resultText(task, 'payment_status_label') || '已支付'}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>}
|
||||
</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>
|
||||
<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(); }} />
|
||||
|
||||
Reference in New Issue
Block a user