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:
yml2213
2026-10-15 17:14:20 +08:00
parent 369361a62c
commit f61284e808
4 changed files with 307 additions and 10 deletions
+153
View File
@@ -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]]
+57
View File
@@ -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,
+32 -7
View File
@@ -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)}"
+65 -3
View File
@@ -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(); }} />