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

This commit is contained in:
yml2213
2026-11-09 21:01:42 +08:00
parent 7282787398
commit 5f1d78e1e4
16 changed files with 446 additions and 80 deletions
+1
View File
@@ -1184,6 +1184,7 @@ export interface HuyaConfig {
room_pid: string;
sid: string;
outer_act_id: string;
bind_gid: string;
bind_act_id: string;
pay_channel: string;
updated_at: string | null;
+58 -8
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { MouseEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tag, Tooltip, Typography,
Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Spin, Table, Tag, Tooltip, Typography,
} from 'antd';
import type { TableProps } from 'antd';
import {
@@ -107,6 +107,11 @@ function resultText(task: HuyaTaskItem | undefined, key: string): string {
return value == null ? '' : String(value);
}
/** mini_qrcode_image 可能存裸 base64,也可能存 livelink 返回的 data URI;统一成 img src。 */
function miniQrcodeSrc(image: string): string {
return image.startsWith('data:image/') ? image : `data:image/png;base64,${image}`;
}
/** 开通支付码是否还需要扫码:已支付或已确认开通就不用再展示了 */
function needsScanPay(task: HuyaTaskItem): boolean {
const result = task.result || {};
@@ -203,6 +208,9 @@ export default function HuyaElitePage() {
const [importTags, setImportTags] = useState<string[]>([]);
const [importSelected, setImportSelected] = useState<number[]>([]);
const [qrTask, setQrTask] = useState<HuyaTaskItem | null>(null);
const [qrTaskLoading, setQrTaskLoading] = useState(false);
// 小程序码图片独立保存:任务轮询只会更新轻量 tasks,不允许覆盖这张大图。
const [qrImage, setQrImage] = useState('');
// 开通支付码:多选账号时可以一次生成多个码,弹窗里逐个展示(按批次收集)
const [payBatchIds, setPayBatchIds] = useState<string[]>([]);
const [payOpen, setPayOpen] = useState(false);
@@ -216,6 +224,8 @@ export default function HuyaElitePage() {
const [configSaving, setConfigSaving] = useState(false);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; accountIds: number[] } | null>(null);
const tasksLoading = useRef(false);
const qrTaskFetchId = useRef<number | null>(null);
const qrImageCacheRef = useRef<Map<number, string>>(new Map());
const logs = useWebSocketLogs();
const load = useCallback(async () => {
@@ -368,10 +378,31 @@ export default function HuyaElitePage() {
onTask: (raw) => {
const next = raw as unknown as HuyaTaskItem;
setTasks((prev) => [next, ...prev.filter((item) => item.id !== next.id)].slice(0, 300));
const result = next.result || {};
const result = (next.result || {}) as Record<string, unknown>;
// 支付弹窗只在「生成开通支付码」和点账号行时打开:多选时每个码都会回包,
// 这里再自动弹会把用户刚关掉的弹窗反复顶出来(码会在弹窗里自动补齐)。
if (next.task_type === 'get_bind_qr' && (result.mini_qrcode_image || result.bind_redirect_url)) setQrTask(next);
if (next.task_type === 'get_bind_qr') {
if (result.mini_qrcode_image) {
const directImage = String(result.mini_qrcode_image);
qrImageCacheRef.current.set(next.id, directImage);
setQrImage(directImage);
setQrTask(next);
} else if (result.has_mini_qrcode && qrTaskFetchId.current !== next.id) {
// WS 只送 has_mini_qrcode,真实小程序码体积大,必须按详情接口取。
qrTaskFetchId.current = next.id;
setQrImage('');
setQrTaskLoading(true);
setQrTask(next);
void huyaApi.getTask(next.id).then((detail) => {
const image = resultText(detail, 'mini_qrcode_image');
if (image) {
qrImageCacheRef.current.set(detail.id, image);
setQrImage(image);
}
setQrTask((current) => (current && current.id === detail.id ? detail : current));
}).catch(() => {}).finally(() => setQrTaskLoading(false));
} else if (result.bind_redirect_url) setQrTask(next);
}
},
onResult: () => { setActiveBatches((prev) => prev.filter((id) => id !== created.batch_id)); void load(); },
});
@@ -420,8 +451,27 @@ export default function HuyaElitePage() {
};
const openQrTask = async (task: HuyaTaskItem) => {
const currentImage = resultText(task, 'mini_qrcode_image');
const cachedImage = currentImage || qrImageCacheRef.current.get(task.id) || '';
// 图片是独立 state,轮询/任务对象刷新不会把它清掉。
setQrImage(cachedImage);
setQrTask(task);
try { setQrTask(await huyaApi.getTask(task.id)); } catch { /* 使用列表结果 */ }
if (currentImage) {
qrImageCacheRef.current.set(task.id, currentImage);
return;
}
if (cachedImage) return;
if (task.result?.has_mini_qrcode !== true) return;
setQrTaskLoading(true);
try {
const detail = await huyaApi.getTask(task.id);
const image = resultText(detail, 'mini_qrcode_image');
if (image) {
qrImageCacheRef.current.set(detail.id, image);
setQrImage((current) => current || image);
}
setQrTask((current) => (current && current.id === detail.id ? detail : current));
} catch { /* 使用列表结果 */ } finally { setQrTaskLoading(false); }
};
const filteredAccounts = useMemo(() => {
@@ -491,7 +541,7 @@ export default function HuyaElitePage() {
{ title: '精英宝典', width: '13%', align: 'center', ellipsis: true, render: (_value, row) => <Space direction="vertical" align="center" size={0} style={{ width: '100%' }}><Tag color={row.elite_opened === true ? 'success' : row.elite_opened === false ? 'default' : undefined}>{row.elite_opened === true ? '已开通' : row.elite_opened === false ? '未开通' : '未查询'}</Tag>{row.elite_opened === true && row.elite_open_time ? <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{`开通 ${formatUnixSeconds(row.elite_open_time)}`}</Text> : null}{row.elite_expire_time ? <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{`有效期至 ${formatUnixSeconds(row.elite_expire_time)}`}</Text> : null}</Space> },
{ title: '积分', dataIndex: 'points', width: '7%', align: 'center', render: (value: number | null) => value ?? <Text type="secondary">-</Text> },
{ title: '最近操作', width: '25%', align: 'center', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); const expired = ckExpired(task, row); return task ? <Space direction="vertical" align="center" size={1} style={{ width: '100%' }}><Text strong ellipsis>{TASK_LABELS[task.task_type] || task.task_type}</Text><Text type={expired ? 'danger' : 'secondary'} ellipsis title={task.message}>{task.message || '-'}</Text><Text type="secondary" style={{ fontSize: 12 }}>{formatTime(task.finished_at || task.created_at)}</Text></Space> : <Text type="secondary">暂无操作</Text>; } },
{ title: '二维码', width: '6%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); if (!task) return <Text type="secondary">-</Text>; const url = taskQrUrl(task); const image = resultText(task, 'mini_qrcode_image'); if (!url && !image) return <Text type="secondary">-</Text>; const doneLabel = qrDoneLabel(task); if (doneLabel) return <Text type="secondary">{doneLabel}</Text>; return <div onClick={(event) => { event.stopPropagation(); if (!task) return; if (task.task_type === 'create_recharge_order') setZoomPayTask(task); else void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 3, borderRadius: 4 }} title="点击放大扫码">{image ? <img src={`data:image/png;base64,${image}`} alt="二维码" style={{ width: 52, height: 52, objectFit: 'contain' }} /> : <QRCode value={url} size={52} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</div>; } },
{ title: '二维码', width: '6%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); if (!task) return <Text type="secondary">-</Text>; const url = taskQrUrl(task); const image = resultText(task, 'mini_qrcode_image'); if (task.task_type === 'get_bind_qr' && !image) { const hasMini = task.result?.has_mini_qrcode === true; if (!hasMini) return <Text type="secondary">-</Text>; return <div onClick={(event) => { event.stopPropagation(); void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 6, borderRadius: 4 }} title="点击查看小程序码"><QrcodeOutlined style={{ fontSize: 26, color: '#000' }} /></div>; } if (!url && !image) return <Text type="secondary">-</Text>; const doneLabel = qrDoneLabel(task); if (doneLabel) return <Text type="secondary">{doneLabel}</Text>; return <div onClick={(event) => { event.stopPropagation(); if (!task) return; if (task.task_type === 'create_recharge_order') setZoomPayTask(task); else void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 3, borderRadius: 4 }} title="点击放大扫码">{image ? <img src={miniQrcodeSrc(image)} alt="二维码" style={{ width: 52, height: 52, objectFit: 'contain' }} /> : <QRCode value={url} size={52} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</div>; } },
{ title: '状态', width: '7%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); if (ckExpired(task, row)) return <Tooltip title={`${resultText(task, 'ck_expired_reason') || '角色信息查不到'} —— 请重新登录或重新导入 Cookie`}><Tag color="error">CK 失效</Tag></Tooltip>; const status = task?.status || row.status || ''; return <Tag color={TASK_COLORS[status] || 'default'}>{TASK_STATUS_LABELS[status] || status || '待操作'}</Tag>; } },
];
@@ -524,7 +574,7 @@ export default function HuyaElitePage() {
{layoutMode === 'split' ? <div style={{ flex: 1, minHeight: 0, display: 'flex', gap: 12, overflow: 'hidden' }}><Card size="small" title="账号" extra={<Tag color="blue">已选 {selectedIds.length}/{accounts.length}</Tag>} style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}>{accountsBody}</Card><Card size="small" title="精英宝典操作" extra={<Space size={4}><Text type="secondary">并发</Text><InputNumber size="small" min={1} max={10} value={concurrency} onChange={(value) => setConcurrency(value || 1)} style={{ width: 58 }} /></Space>} style={{ width: 360, flexShrink: 0, minHeight: 0, overflow: 'hidden' }} styles={{ body: { padding: 8, overflowY: 'auto', height: 'calc(100% - 38px)' } }}>{operationBody}</Card></div> : <><Card size="small" title="精英宝典操作" extra={<Space size={4}><Text type="secondary">并发</Text><InputNumber size="small" min={1} max={10} value={concurrency} onChange={(value) => setConcurrency(value || 1)} style={{ width: 58 }} /></Space>} style={{ flexShrink: 0, marginBottom: 12 }} styles={{ body: { padding: 8 } }}>{operationBody}</Card><Card size="small" title="账号" extra={<Tag color="blue">已选 {selectedIds.length}/{accounts.length}</Tag>} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}>{accountsBody}</Card></>}
<Text type="secondary" style={{ fontSize: 12, marginTop: 6 }}>兑换任务 {exchangeCount} 条;勾选账号后可批量执行,账号行支持右键操作。</Text>
{contextMenu && <div style={contextMenuStyle} onClick={(event) => event.stopPropagation()}><Card size="small" title={contextMenu.accountIds.length > 1 ? '批量动作' : '账号动作'} styles={{ body: { padding: 4 } }} style={{ width: 220, boxShadow: '0 4px 12px rgba(0,0,0,0.14)' }}><Space direction="vertical" style={{ width: '100%' }}>{HUYA_QUICK_ACTIONS.map((item) => <Button key={item.key} block size="small" type="text" icon={item.icon} onClick={() => runContextAction(item.key)} disabled={starting} style={{ justifyContent: 'flex-start' }}>{TASK_LABELS[item.key] || item.key}</Button>)}</Space></Card></div>}
<Modal open={qrTask !== null} title="绑定二维码" footer={null} onCancel={() => setQrTask(null)} centered>{qrTask && <div style={{ textAlign: 'center' }}>{resultText(qrTask, 'mini_qrcode_image') ? <img src={`data:image/png;base64,${resultText(qrTask, 'mini_qrcode_image')}`} alt="绑定二维码" style={{ width: 240, height: 240 }} /> : <QRCode value={resultText(qrTask, 'bind_redirect_url') || 'https://zt.huya.com/b02faae1/pc/index.html'} size={240} color="#000" bgColor="#fff" />}<p><Tag color={TASK_COLORS[qrTask.status] || 'default'}>{qrTask.message || qrTask.status}</Tag></p></div>}</Modal>
<Modal open={qrTask !== null} title="绑定二维码" footer={null} onCancel={() => { setQrTask(null); setQrImage(''); }} centered>{qrTask && <div style={{ textAlign: 'center' }}>{qrImage ? <img src={miniQrcodeSrc(qrImage)} alt="绑定二维码" style={{ width: 240, height: 240, background: '#fff', padding: 12, boxSizing: 'border-box', borderRadius: 8 }} /> : qrTask.result?.has_mini_qrcode === true ? <div style={{ width: 240, height: 240, display: 'grid', placeItems: 'center', background: '#fff', borderRadius: 8 }}>{qrTaskLoading ? <Spin size="large" /> : <Text type="secondary">正在获取小程序码…</Text>}</div> : <QRCode value={resultText(qrTask, 'bind_redirect_url') || 'https://zt.huya.com/b02faae1/pc/index.html'} size={240} color="#000" bgColor="#fff" />}<p><Tag color={TASK_COLORS[qrTask.status] || 'default'}>{qrTask.message || qrTask.status}</Tag></p></div>}</Modal>
<Modal
open={payOpen}
title={payTasks.length ? `开通支付码(${payPendingTasks.length} 个待扫码 · 已完成 ${payDoneTasks.length}/${payTasks.length})` : '开通支付码'}
@@ -540,7 +590,7 @@ export default function HuyaElitePage() {
const qrValue = payQrValue(task);
return (
<div key={task.id} style={{ border: '1px solid rgba(128,128,128,.22)', borderRadius: 6, padding: 10, textAlign: 'center' }}>
<div style={{ background: '#fff', padding: 6, borderRadius: 6, lineHeight: 0, display: 'inline-block' }}>{resultText(task, 'mini_qrcode_image') ? <img src={`data:image/png;base64,${resultText(task, 'mini_qrcode_image')}`} alt="支付码" style={{ width: 150, height: 150, objectFit: 'contain' }} /> : <QRCode value={qrValue} size={150} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</div>
<div style={{ background: '#fff', padding: 6, borderRadius: 6, lineHeight: 0, display: 'inline-block' }}>{resultText(task, 'mini_qrcode_image') ? <img src={miniQrcodeSrc(resultText(task, 'mini_qrcode_image'))} alt="支付码" style={{ width: 150, height: 150, objectFit: 'contain' }} /> : <QRCode value={qrValue} size={150} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</div>
<div><Text strong ellipsis>{payTaskLabel(task)}</Text></div>
<Text type="secondary" style={{ fontSize: 12 }}>UID {task.account_uid || '-'}</Text>
<div><Text type="secondary" style={{ fontSize: 12 }}>{[resultText(task, 'product_name') || '精英宝典', Number(resultText(task, 'count')) > 1 ? `×${resultText(task, 'count')}` : '', resultText(task, 'amount_text')].filter(Boolean).join(' ')}</Text></div>
@@ -577,7 +627,7 @@ export default function HuyaElitePage() {
</div>}
</Modal>
<Modal open={importOpen} title="导入精英宝典账号" onCancel={() => setImportOpen(false)} onOk={() => { void saveAccounts([...new Set([...accounts.map((item) => item.id), ...importSelected])]).then(() => { setImportSelected([]); setImportOpen(false); }).catch((error) => message.error(getErrorMessage(error))); }} okText="导入" cancelText="取消"><Input.Search allowClear placeholder="搜索账号" value={importSearch} onChange={(event) => setImportSearch(event.target.value)} style={{ marginBottom: 8 }} /><Select allowClear placeholder="标签" value={importTag || undefined} onChange={(value) => setImportTag(value || '')} options={importTags.map((tag) => ({ value: tag, label: tag }))} style={{ width: '100%', marginBottom: 8 }} /><Table rowKey="id" size="small" dataSource={pool.filter((item) => !accounts.some((current) => current.id === item.id) && (!importSearch || [item.uid, item.nickname, item.username].some((value) => String(value || '').includes(importSearch))) && (!importTag || item.tag === importTag))} columns={[{ title: '账号', render: (_value, row) => row.nickname || row.username || row.uid }, { title: '标签', dataIndex: 'tag' }]} pagination={{ pageSize: 8 }} rowSelection={{ selectedRowKeys: importSelected, onChange: (keys) => setImportSelected(keys.map(Number)) }} /></Modal>
<Modal open={configOpen} title="精英宝典设置" onCancel={() => setConfigOpen(false)} onOk={() => void saveConfig()} confirmLoading={configSaving} okText="保存" cancelText="取消" width={500}>{configLoading ? <div style={{ padding: 24, textAlign: 'center' }}>加载中...</div> : configDraft && <Space direction="vertical" size={10} style={{ width: '100%' }}>{configField('sid', '活动 SID')}{configField('bind_act_id', '绑定活动 ID')}{configField('outer_act_id', '外部活动 ID')}{configField('room_pid', '直播间 PID')}{configField('pay_channel', '支付渠道')}<Space style={{ width: '100%', justifyContent: 'space-between' }}><Text>空闲轮询间隔(秒)</Text><InputNumber min={5} max={60} value={refreshInterval} onChange={(value) => { const next = Math.min(60, Math.max(5, value || 15)); setRefreshInterval(next); localStorage.setItem('huya_elite_refresh_interval', String(next)); }} style={{ width: 300 }} /></Space></Space>}</Modal>
<Modal open={configOpen} title="精英宝典设置" onCancel={() => setConfigOpen(false)} onOk={() => void saveConfig()} confirmLoading={configSaving} okText="保存" cancelText="取消" width={500}>{configLoading ? <div style={{ padding: 24, textAlign: 'center' }}>加载中...</div> : configDraft && <Space direction="vertical" size={10} style={{ width: '100%' }}>{configField('sid', '活动 SID')}{configField('bind_act_id', '绑定活动 ID')}{configField('outer_act_id', '外部活动 ID')}{configField('bind_gid', '绑定 gid')}{configField('room_pid', '直播间 PID')}{configField('pay_channel', '支付渠道')}<Space style={{ width: '100%', justifyContent: 'space-between' }}><Text>空闲轮询间隔(秒)</Text><InputNumber min={5} max={60} value={refreshInterval} onChange={(value) => { const next = Math.min(60, Math.max(5, value || 15)); setRefreshInterval(next); localStorage.setItem('huya_elite_refresh_interval', String(next)); }} style={{ width: 300 }} /></Space></Space>}</Modal>
<HuyaExternalCkImportModal open={ckImportOpen} onClose={() => setCkImportOpen(false)} tags={importTags} scope={SCOPE} onImported={(result) => { localStorage.setItem('huya_elite_workbench_account_ids', JSON.stringify(result.account_ids)); void load(); }} />
</div>;
}
+5
View File
@@ -1836,6 +1836,11 @@ export default function HuyaTasksPage() {
<Input placeholder="9504" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="绑定 gid" name="bind_gid">
<Input placeholder="3203" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="绑定 bActId" name="bind_act_id">
<Input placeholder="9271" />