feat(huya): 开通支付码复用未支付旧单 + 二维码改用支付宝收银台直达地址
- 限购商品(精英宝典 buy_limit=1)挂着未支付旧单时,真正先失败的是购买权限 校验 checkUserBuyAuth(真机 703「已超过该商品的购买上限,下单失败~」), createOrderV5 根本不会执行;现在 checkUserBuyAuth 与 createOrderV5 两处失败 都会回退 find_reusable_order,拿同一商品的未支付旧单继续 payOrderSubmitV5。 - 新增支付宝收银台直达地址解析(resolve_pay_entry_url):跟随网关 302 后的 excashier.alipay.com/standard/auth.htm?payOrderId=…(93 字符,原始网关地址 1616 字符)写入结果 pay_cashier_url;解析失败回落本站短链 /api/huya/pay/<task_id>?k=<hmac>,后端跑在 localhost 时客户也扫得开。 - 前端 QRCode 显式 color="#000" bgColor="#fff":antd v6 暗色主题默认 color=token.colorText、bgColor=transparent,白码落在白底容器里根本看不见。 - 支付成功后列表「二维码」列不再展示二维码(qrDoneLabel → 已支付/已开通)。 测试:pytest 767 passed / 6 skipped;typecheck、eslint、ruff 通过。
This commit is contained in:
@@ -5,8 +5,8 @@ import {
|
||||
} from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined, ColumnWidthOutlined, GiftOutlined, ImportOutlined, QrcodeOutlined,
|
||||
ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined,
|
||||
CheckCircleOutlined, ColumnWidthOutlined, CopyOutlined, GiftOutlined, ImportOutlined, QrcodeOutlined,
|
||||
ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined, ZoomInOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { huyaApi, type HuyaAccountItem, type HuyaConfig, type HuyaGoodsItem, type HuyaRechargeGoodsItem, type HuyaTaskItem } from '../api/modules';
|
||||
import HuyaExternalCkImportModal from '../components/HuyaExternalCkImportModal';
|
||||
@@ -65,13 +65,47 @@ function needsScanPay(task: HuyaTaskItem): boolean {
|
||||
return !(result.payment_paid === true || result.elite_opened === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表「二维码」列的占位文案:扫码这件事做完后就别再挂二维码了(真机反馈:
|
||||
* 支付成功后那一格还挂着码,容易被当成待支付)。返回 null 表示仍要展示二维码。
|
||||
*
|
||||
* 只针对「开通支付码」:绑定二维码那类任务 status=success 只代表码生成成功,
|
||||
* 客户还没扫,必须继续展示。
|
||||
*/
|
||||
function qrDoneLabel(task: HuyaTaskItem): string | null {
|
||||
if (task.task_type !== 'create_recharge_order') return null;
|
||||
if (needsScanPay(task)) return null;
|
||||
return task.result?.elite_opened === true ? '已开通' : '已支付';
|
||||
}
|
||||
|
||||
/**
|
||||
* 二维码里放**短地址**:支付宝网关地址 1600+ 字符,直接编成二维码有 140+ 个模块,
|
||||
* 手机根本扫不上(生产实测)。优先级:
|
||||
* 1. `pay_cashier_url` —— 支付宝收银台直达地址(约 74 字符、公网域名,任意手机可扫码支付);
|
||||
* 2. `pay_qr_path` —— 本站短链 `/api/huya/pay/<id>?k=<签名>`,手机打开后 302 跳到真实支付地址,
|
||||
* 但要求当前页面域名手机能访问到(localhost 不行);
|
||||
* 3. `pay_url` —— 原始网关地址,兜底。
|
||||
*/
|
||||
function payQrValue(task: HuyaTaskItem): string {
|
||||
const cashier = resultText(task, 'pay_cashier_url');
|
||||
if (cashier) return cashier;
|
||||
const path = resultText(task, 'pay_qr_path');
|
||||
return path ? `${window.location.origin}${path}` : resultText(task, 'pay_url');
|
||||
}
|
||||
|
||||
/** 二维码内容指向本机时手机一定扫不开(localhost / 127.0.0.1 / 内网回环)。 */
|
||||
function isLoopbackUrl(url: string): boolean {
|
||||
return /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(:|\/|$)/i.test(url);
|
||||
}
|
||||
|
||||
function latestTask(tasks: HuyaTaskItem[], accountId: number): HuyaTaskItem | undefined {
|
||||
return tasks.filter((task) => task.account_id === accountId).sort((a, b) => b.id - a.id)[0];
|
||||
}
|
||||
|
||||
function taskQrUrl(task: HuyaTaskItem | undefined): string {
|
||||
if (!task) return '';
|
||||
return resultText(task, 'bind_redirect_url') || resultText(task, 'pay_url') || resultText(task, 'url');
|
||||
if (task.task_type === 'create_recharge_order') return payQrValue(task);
|
||||
return resultText(task, 'bind_redirect_url') || resultText(task, 'url');
|
||||
}
|
||||
|
||||
export default function HuyaElitePage() {
|
||||
@@ -102,6 +136,8 @@ export default function HuyaElitePage() {
|
||||
// 开通支付码:多选账号时可以一次生成多个码,弹窗里逐个展示(按批次收集)
|
||||
const [payBatchIds, setPayBatchIds] = useState<string[]>([]);
|
||||
const [payOpen, setPayOpen] = useState(false);
|
||||
// 放大扫码用(二维码在弹窗里开到底,手机才好扫)
|
||||
const [zoomPayTask, setZoomPayTask] = useState<HuyaTaskItem | null>(null);
|
||||
const [ckImportOpen, setCkImportOpen] = useState(false);
|
||||
const [config, setConfig] = useState<HuyaConfig | null>(null);
|
||||
const [configDraft, setConfigDraft] = useState<HuyaConfig | null>(null);
|
||||
@@ -279,6 +315,11 @@ export default function HuyaElitePage() {
|
||||
setPayOpen(true);
|
||||
};
|
||||
const payTaskLabel = (task: HuyaTaskItem) => task.account_nickname || task.account_uid || `#${task.account_id}`;
|
||||
const copyPayLink = async (text: string, label: string) => {
|
||||
if (!text) { message.warning('没有可复制的链接'); return; }
|
||||
try { await navigator.clipboard.writeText(text); message.success(`${label}已复制`); }
|
||||
catch { message.error('复制失败,请手动选中复制'); }
|
||||
};
|
||||
|
||||
// 列宽按比例分配(percent),表头与单元格统一居中,避免左右跳着看
|
||||
const accountColumns: TableProps<HuyaAccountItem>['columns'] = [
|
||||
@@ -289,7 +330,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); 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="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); const url = taskQrUrl(task); const image = resultText(task, 'mini_qrcode_image'); if (!url && !image) return <Text type="secondary">-</Text>; return <div onClick={(event) => { event.stopPropagation(); if (task) void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0 }} title="点击查看二维码">{image ? <img src={`data:image/png;base64,${image}`} alt="二维码" style={{ width: 44, height: 44, objectFit: 'contain' }} /> : <QRCode value={url} size={44} bordered={false} />}</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 (!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: '7%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); const status = task?.status || row.status || ''; return <Tag color={TASK_COLORS[status] || 'default'}>{TASK_STATUS_LABELS[status] || status || '待操作'}</Tag>; } },
|
||||
];
|
||||
|
||||
@@ -322,7 +363,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} />}<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)} 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={payOpen}
|
||||
title={payTasks.length ? `开通支付码(${payDoneTasks.length}/${payTasks.length} 已完成)` : '开通支付码'}
|
||||
@@ -333,18 +374,26 @@ export default function HuyaElitePage() {
|
||||
>
|
||||
{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) => (
|
||||
{payPendingTasks.map((task) => {
|
||||
const qrValue = payQrValue(task);
|
||||
return (
|
||||
<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>
|
||||
<div style={{ background: '#fff', padding: 8, borderRadius: 6, lineHeight: 0 }}>{resultText(task, 'mini_qrcode_image') ? <img src={`data:image/png;base64,${resultText(task, 'mini_qrcode_image')}`} alt="支付码" style={{ width: 180, height: 180 }} /> : <QRCode value={qrValue} size={180} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</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>
|
||||
{isLoopbackUrl(qrValue) && <Text type="warning" style={{ fontSize: 12 }}>二维码是本机地址(localhost),手机扫不开;请用公网域名打开本页后重新生成</Text>}
|
||||
<Space size={6}>
|
||||
<Button size="small" icon={<ZoomInOutlined />} onClick={() => setZoomPayTask(task)}>放大扫码</Button>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => void copyPayLink(qrValue, '支付链接')}>复制链接</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Space>}
|
||||
{payDoneTasks.length > 0 && <div style={{ marginTop: payPendingTasks.length ? 12 : 0, textAlign: 'left' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>已完成(二维码已收起)</Text>
|
||||
@@ -356,6 +405,14 @@ export default function HuyaElitePage() {
|
||||
</div>}
|
||||
</div>}
|
||||
</Modal>
|
||||
<Modal open={zoomPayTask !== null} title={`扫码支付 · ${zoomPayTask ? payTaskLabel(zoomPayTask) : ''}`} footer={null} onCancel={() => setZoomPayTask(null)} width={680} centered>
|
||||
{zoomPayTask && <div style={{ textAlign: 'center' }}>
|
||||
<div style={{ display: 'inline-block', background: '#fff', padding: 16, borderRadius: 8, lineHeight: 0 }}><QRCode value={payQrValue(zoomPayTask)} size={520} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" /></div>
|
||||
<p><Space size={8}><Tag color={TASK_COLORS[zoomPayTask.status] || 'default'}>{resultText(zoomPayTask, 'payment_status_label') || zoomPayTask.status}</Tag><Text type="secondary">{resultText(zoomPayTask, 'amount_text')}</Text></Space></p>
|
||||
<Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-all' }}>{payQrValue(zoomPayTask)}</Text>
|
||||
<div style={{ marginTop: 10 }}><Space><Button size="small" icon={<CopyOutlined />} onClick={() => void copyPayLink(payQrValue(zoomPayTask), '支付链接')}>复制支付链接</Button><Button size="small" onClick={() => void copyPayLink(resultText(zoomPayTask, 'pay_url'), '原始支付链接')}>复制原始链接</Button></Space></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