实现虎牙充值商品列表与支付二维码

This commit is contained in:
yml2213
2026-07-04 23:48:53 +08:00
parent fa0d49ff8c
commit 5513b5a313
12 changed files with 1209 additions and 61 deletions
+2
View File
@@ -4,6 +4,7 @@ import type {
HuyaConfig,
HuyaCookieImportResult,
HuyaGoodsItem,
HuyaRechargeGoodsItem,
HuyaTaskBatchRequest,
HuyaTaskBatchResult,
HuyaTaskItem,
@@ -23,6 +24,7 @@ export const huyaApi = {
getConfig: () => api.get<HuyaConfig, HuyaConfig>('/huya/config'),
updateConfig: (data: Partial<HuyaConfig>) => api.put<HuyaConfig, HuyaConfig>('/huya/config', data),
listGoods: () => api.get<HuyaGoodsItem[], HuyaGoodsItem[]>('/huya/goods'),
listRechargeGoods: () => api.get<HuyaRechargeGoodsItem[], HuyaRechargeGoodsItem[]>('/huya/recharge-goods'),
createTasks: (data: HuyaTaskBatchRequest) =>
api.post<HuyaTaskBatchResult, HuyaTaskBatchResult>('/huya/tasks/batch', data),
listTasks: (batchId?: string) =>
+16
View File
@@ -183,6 +183,22 @@ export interface HuyaGoodsItem {
updated_at: string | null;
}
export interface HuyaRechargeGoodsItem {
id: number;
spu_id: string;
sku_id: string;
name: string;
price: number | null;
stock: number | null;
buy_limit: number | null;
icon: string;
description: string;
task_id: string;
task_name: string;
raw: Record<string, unknown> | null;
updated_at: string | null;
}
// ==================== Proxy ====================
export interface ProxyConfig {
+265 -51
View File
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Button, Card, Col, Form, Input, InputNumber, message, Modal, Row, Select, Space, Table, Tabs, Tag, Tooltip, Typography, theme,
Button, Card, Col, Form, Input, InputNumber, message, Modal, QRCode, Row, Select, Space, Table, Tabs, Tag, Tooltip, Typography, theme,
} from 'antd';
import type { TableProps } from 'antd';
import {
AppstoreOutlined, CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined, GiftOutlined,
AppstoreOutlined, CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined,
LinkOutlined, PlayCircleOutlined, QrcodeOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined,
} from '@ant-design/icons';
import {
@@ -12,6 +12,7 @@ import {
type HuyaAccountItem,
type HuyaConfig,
type HuyaGoodsItem,
type HuyaRechargeGoodsItem,
type HuyaTaskItem,
} from '../api/modules';
import RealtimeLogPanel from '../components/RealtimeLogPanel';
@@ -26,22 +27,22 @@ const FALLBACK_TASK_TYPES: Record<string, string> = {
get_bind_qr: '获取绑定二维码',
confirm_bind: '确认绑定',
query_points: '一键查询积分',
open_elite_book: '开通精英宝典',
recharge_points: '充值积分',
query_game_name: '一键查询游戏名',
query_exchange_records: '一键查询兑换记录',
refresh_goods: '刷新商品列表',
refresh_recharge_goods: '刷新充值商品列表',
create_recharge_order: '生成支付二维码',
};
const QUICK_ACTIONS = [
{ key: 'get_bind_qr', icon: <LinkOutlined /> },
{ key: 'confirm_bind', icon: <CheckCircleOutlined /> },
{ key: 'query_points', icon: <SearchOutlined /> },
{ key: 'open_elite_book', icon: <GiftOutlined /> },
{ key: 'recharge_points', icon: <CreditCardOutlined /> },
{ key: 'query_game_name', icon: <AppstoreOutlined /> },
{ key: 'query_exchange_records', icon: <FieldTimeOutlined /> },
{ key: 'refresh_goods', icon: <ReloadOutlined /> },
{ key: 'refresh_recharge_goods', icon: <ShoppingOutlined /> },
{ key: 'create_recharge_order', icon: <CreditCardOutlined /> },
];
const STATUS_COLORS: Record<string, string> = {
@@ -115,22 +116,33 @@ function formatRemainText(value: string): string {
return /^-?\d+(\.\d+)?$/.test(text) ? `${text}%` : text;
}
function formatPriceText(value: number | null | undefined): string {
if (!value) return '';
return `¥${(value / 100).toFixed(2)}`;
}
function hasMiniQrcode(task: HuyaTaskItem): boolean {
return task.task_type === 'get_bind_qr' && Boolean(resultText(task.result, 'mini_qrcode_image'));
}
function hasPaymentQrcode(task: HuyaTaskItem): boolean {
return task.task_type === 'create_recharge_order' && Boolean(resultText(task.result, 'pay_url'));
}
export default function HuyaTasksPage() {
const { token } = theme.useToken();
const [form] = Form.useForm<HuyaConfig>();
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
const [tasks, setTasks] = useState<HuyaTaskItem[]>([]);
const [goods, setGoods] = useState<HuyaGoodsItem[]>([]);
const [rechargeGoods, setRechargeGoods] = useState<HuyaRechargeGoodsItem[]>([]);
const [taskTypes, setTaskTypes] = useState<Record<string, string>>(FALLBACK_TASK_TYPES);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [selectedTaskType, setSelectedTaskType] = useState('query_points');
const [selectedGoodsId, setSelectedGoodsId] = useState<string>('');
const [selectedGoodsCategory, setSelectedGoodsCategory] = useState('');
const [selectedRechargeGoodsId, setSelectedRechargeGoodsId] = useState<string>('');
const [rechargeCount, setRechargeCount] = useState(1);
const [rechargePayChannel, setRechargePayChannel] = useState('Weixin');
const [concurrency, setConcurrency] = useState(() => {
const v = localStorage.getItem('huya_task_concurrency');
return v ? Math.max(1, Math.min(10, Number(v) || 3)) : 3;
@@ -140,8 +152,11 @@ export default function HuyaTasksPage() {
const [savingConfig, setSavingConfig] = useState(false);
const [batchId, setBatchId] = useState<string | null>(null);
const [qrTask, setQrTask] = useState<HuyaTaskItem | null>(null);
const [payTask, setPayTask] = useState<HuyaTaskItem | null>(null);
const autoOpenedQrTaskIds = useRef<Set<number>>(new Set());
const autoOpenQrReady = useRef(false);
const autoOpenedPayTaskIds = useRef<Set<number>>(new Set());
const autoOpenPayReady = useRef(false);
const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs();
const { can } = usePermissions();
@@ -158,18 +173,30 @@ export default function HuyaTasksPage() {
autoOpenQrReady.current = true;
}, []);
const rememberExistingPaymentQrcodes = useCallback((items: HuyaTaskItem[]) => {
if (autoOpenPayReady.current) return;
items.filter(hasPaymentQrcode).forEach((task) => autoOpenedPayTaskIds.current.add(task.id));
autoOpenPayReady.current = true;
}, []);
const openQrTask = useCallback((task: HuyaTaskItem) => {
autoOpenedQrTaskIds.current.add(task.id);
setQrTask(task);
}, []);
const openPayTask = useCallback((task: HuyaTaskItem) => {
autoOpenedPayTaskIds.current.add(task.id);
setPayTask(task);
}, []);
const loadAll = useCallback(async () => {
setLoading(true);
try {
const [accountResult, taskResult, goodsResult, configResult, taskTypeResult] = await Promise.allSettled([
const [accountResult, taskResult, goodsResult, rechargeGoodsResult, configResult, taskTypeResult] = await Promise.allSettled([
huyaApi.listAccounts(),
huyaApi.listTasks(),
huyaApi.listGoods(),
huyaApi.listRechargeGoods(),
canConfig ? huyaApi.getConfig() : Promise.resolve(null),
huyaApi.taskTypes(),
]);
@@ -177,16 +204,19 @@ export default function HuyaTasksPage() {
if (accountResult.status === 'fulfilled') setAccounts(accountResult.value);
if (taskResult.status === 'fulfilled') {
rememberExistingQrcodes(taskResult.value);
rememberExistingPaymentQrcodes(taskResult.value);
setTasks(taskResult.value);
}
if (goodsResult.status === 'fulfilled') setGoods(goodsResult.value);
if (rechargeGoodsResult.status === 'fulfilled') setRechargeGoods(rechargeGoodsResult.value);
if (configResult.status === 'fulfilled' && configResult.value) form.setFieldsValue(configResult.value);
if (taskTypeResult.status === 'fulfilled') setTaskTypes({ ...FALLBACK_TASK_TYPES, ...taskTypeResult.value });
const failedLabels = [
accountResult.status === 'rejected' ? `CK 列表: ${getErrorMessage(accountResult.reason)}` : '',
taskResult.status === 'rejected' ? `任务记录: ${getErrorMessage(taskResult.reason)}` : '',
goodsResult.status === 'rejected' ? `商品快照: ${getErrorMessage(goodsResult.reason)}` : '',
goodsResult.status === 'rejected' ? `兑换商品: ${getErrorMessage(goodsResult.reason)}` : '',
rechargeGoodsResult.status === 'rejected' ? `充值商品: ${getErrorMessage(rechargeGoodsResult.reason)}` : '',
configResult.status === 'rejected' ? `虎牙配置: ${getErrorMessage(configResult.reason)}` : '',
taskTypeResult.status === 'rejected' ? `任务类型: ${getErrorMessage(taskTypeResult.reason)}` : '',
].filter(Boolean);
@@ -196,17 +226,18 @@ export default function HuyaTasksPage() {
} finally {
setLoading(false);
}
}, [canConfig, form, rememberExistingQrcodes]);
}, [canConfig, form, rememberExistingPaymentQrcodes, rememberExistingQrcodes]);
const loadTasks = useCallback(async () => {
try {
const data = await huyaApi.listTasks();
rememberExistingQrcodes(data);
rememberExistingPaymentQrcodes(data);
setTasks(data);
} catch {
// 轮询失败不打扰操作,下一轮继续刷新。
}
}, [rememberExistingQrcodes]);
}, [rememberExistingPaymentQrcodes, rememberExistingQrcodes]);
useEffect(() => {
loadAll();
@@ -225,6 +256,14 @@ export default function HuyaTasksPage() {
if (nextQrTask) openQrTask(nextQrTask);
}, [openQrTask, qrTask, tasks]);
useEffect(() => {
if (!autoOpenPayReady.current || payTask) return;
const nextPayTask = tasks
.filter((task) => hasPaymentQrcode(task) && !autoOpenedPayTaskIds.current.has(task.id))
.sort((a, b) => b.id - a.id)[0];
if (nextPayTask) openPayTask(nextPayTask);
}, [openPayTask, payTask, tasks]);
const accountOptions = useMemo(() => {
return accounts.map((account) => ({ value: account.id, label: accountLabel(account) }));
}, [accounts]);
@@ -237,16 +276,34 @@ export default function HuyaTasksPage() {
));
}, [goods]);
const goodsOptions = useMemo(() => {
return sortedGoods.map((item) => ({
value: item.product_id,
label: `${item.name || item.product_id}${item.price ? ` / ${item.price}积分` : ''}`,
}));
}, [sortedGoods]);
const sortedRechargeGoods = useMemo(() => {
return [...rechargeGoods].sort((a, b) => {
const aOrder = goodsRawNumber({ raw: a.raw } as HuyaGoodsItem, 'raw_order');
const bOrder = goodsRawNumber({ raw: b.raw } as HuyaGoodsItem, 'raw_order');
return aOrder - bOrder || a.id - b.id;
});
}, [rechargeGoods]);
const selectedGoods = useMemo(() => {
return sortedGoods.find((item) => item.product_id === selectedGoodsId) || null;
}, [sortedGoods, selectedGoodsId]);
const rechargeGoodsOptions = useMemo(() => {
return sortedRechargeGoods.map((item) => ({
value: item.spu_id,
label: `${item.name || item.spu_id}${item.price ? ` / ${formatPriceText(item.price)}` : ''}`,
}));
}, [sortedRechargeGoods]);
const selectedRechargeGoods = useMemo(() => {
return sortedRechargeGoods.find((item) => item.spu_id === selectedRechargeGoodsId) || null;
}, [sortedRechargeGoods, selectedRechargeGoodsId]);
useEffect(() => {
if (sortedRechargeGoods.length === 0) {
if (selectedRechargeGoodsId) setSelectedRechargeGoodsId('');
return;
}
if (!sortedRechargeGoods.some((item) => item.spu_id === selectedRechargeGoodsId)) {
setSelectedRechargeGoodsId(sortedRechargeGoods[0].spu_id);
}
}, [selectedRechargeGoodsId, sortedRechargeGoods]);
const goodsCategories = useMemo(() => {
const map = new Map<string, { key: string; label: string; sort: number; count: number }>();
@@ -283,11 +340,13 @@ export default function HuyaTasksPage() {
}, [sortedGoods, selectedGoodsCategory]);
const createPayload = (taskType: string) => {
if (taskType !== 'recharge_points') return {};
if (taskType !== 'create_recharge_order') return {};
return {
product_id: selectedGoods?.product_id || selectedGoodsId,
product_name: selectedGoods?.name || '',
spu_id: selectedRechargeGoods?.spu_id || selectedRechargeGoodsId,
sku_id: selectedRechargeGoods?.sku_id || '',
product_name: selectedRechargeGoods?.name || '',
count: rechargeCount,
pay_channel: rechargePayChannel,
};
};
@@ -296,7 +355,7 @@ export default function HuyaTasksPage() {
message.warning('请先选择虎牙 CK');
return;
}
if (taskType === 'recharge_points' && !selectedGoodsId) {
if (taskType === 'create_recharge_order' && !selectedRechargeGoodsId) {
message.warning('请先选择充值商品');
return;
}
@@ -312,7 +371,7 @@ export default function HuyaTasksPage() {
const finishTask = () => {
setBatchId(null);
setStarting(false);
if (taskType === 'refresh_goods') {
if (taskType === 'refresh_goods' || taskType === 'refresh_recharge_goods') {
void loadAll();
} else {
void loadTasks();
@@ -354,6 +413,15 @@ export default function HuyaTasksPage() {
const qrAccountName = qrTask
? resultProfileNick(qrResult) || qrTask.account_nickname || qrTask.account_uid || `#${qrTask.account_id}`
: '';
const payResult = payTask?.result || null;
const payUrl = resultText(payResult, 'pay_url');
const payProductName = resultText(payResult, 'product_name');
const payAmountText = resultText(payResult, 'amount_text');
const payChannelLabel = resultText(payResult, 'pay_channel_label');
const payOrderId = payResult?.order_id;
const payAccountName = payTask
? payTask.account_nickname || payTask.account_uid || `#${payTask.account_id}`
: '';
const renderTaskResult = (value: Record<string, unknown> | null, record: HuyaTaskItem) => {
const bindQrImage = resultText(value, 'mini_qrcode_image');
@@ -400,6 +468,29 @@ export default function HuyaTasksPage() {
if (typeof goodsCount === 'number') return <Tag color="green"> {goodsCount} </Tag>;
return <Text type="secondary">-</Text>;
}
if (record.task_type === 'refresh_recharge_goods') {
const goodsCount = value?.goods_count;
const failedCount = value?.failed_count;
if (typeof goodsCount === 'number') {
return (
<Space size={6}>
<Tag color="green"> {goodsCount} </Tag>
{typeof failedCount === 'number' && failedCount > 0 ? <Tag color="orange"> {failedCount}</Tag> : null}
</Space>
);
}
return <Text type="secondary">-</Text>;
}
if (record.task_type === 'create_recharge_order') {
if (resultText(value, 'pay_url')) {
return (
<Button size="small" icon={<QrcodeOutlined />} onClick={() => openPayTask(record)}>
</Button>
);
}
return <Text type="secondary">-</Text>;
}
const availableScore = value?.available_score;
if (typeof availableScore === 'number') {
@@ -479,6 +570,39 @@ export default function HuyaTasksPage() {
},
];
const rechargeGoodsColumns: TableProps<HuyaRechargeGoodsItem>['columns'] = [
{ title: 'SPU', dataIndex: 'spu_id', width: 120, ellipsis: true },
{ title: 'SKU', dataIndex: 'sku_id', width: 100, ellipsis: true },
{ title: '名称', dataIndex: 'name', ellipsis: true },
{
title: '单价',
dataIndex: 'price',
width: 90,
align: 'center',
render: (value: number | null) => formatPriceText(value) || <Text type="secondary">-</Text>,
},
{
title: '库存',
dataIndex: 'stock',
width: 90,
align: 'center',
render: (value: number | null) => value ?? <Text type="secondary">-</Text>,
},
{
title: '来源',
dataIndex: 'task_name',
width: 140,
ellipsis: true,
render: (value: string) => value || <Text type="secondary"></Text>,
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 160,
render: (value: string | null) => value ? formatTime(value) : <Text type="secondary">-</Text>,
},
];
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ flexShrink: 0, marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
@@ -530,8 +654,8 @@ export default function HuyaTasksPage() {
<Form.Item label="支付渠道" name="pay_channel">
<Select
options={[
{ value: 'Weixin', label: '微信' },
{ value: 'Zfb', label: '支付宝' },
{ value: 'Wx', label: '微信' },
]}
/>
</Form.Item>
@@ -540,27 +664,7 @@ export default function HuyaTasksPage() {
</Form>
</Card>
<Card size="small" title={<Space><ShoppingOutlined /></Space>} style={{ marginBottom: 12 }}>
<Space style={{ marginBottom: 8 }} wrap>
<Select
showSearch
allowClear
placeholder="选择充值商品"
value={selectedGoodsId || undefined}
onChange={(value) => setSelectedGoodsId(value || '')}
options={goodsOptions}
style={{ minWidth: 240 }}
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
/>
<InputNumber
min={1}
max={99}
value={rechargeCount}
onChange={(value) => setRechargeCount(value || 1)}
addonAfter="份"
style={{ width: 120 }}
/>
</Space>
<Card size="small" title={<Space><ShoppingOutlined /></Space>} style={{ marginBottom: 12 }}>
{goodsCategories.length > 0 && (
<Tabs
size="small"
@@ -580,8 +684,78 @@ export default function HuyaTasksPage() {
rowKey="id"
size="small"
pagination={false}
scroll={{ x: 760, y: 220 }}
locale={{ emptyText: '暂无商品快照,请先刷新商品列表' }}
scroll={{ y: 220 }}
locale={{ emptyText: '暂无兑换商品,请先刷新商品列表' }}
/>
</Card>
<Card
size="small"
title={<Space><CreditCardOutlined /></Space>}
extra={(
<Space size={6}>
<Button
size="small"
icon={<ReloadOutlined />}
onClick={() => startTask('refresh_recharge_goods')}
disabled={!canTask || selectedIds.length === 0 || wsConnected}
>
</Button>
<Button
size="small"
type="primary"
icon={<QrcodeOutlined />}
onClick={() => startTask('create_recharge_order')}
disabled={!canTask || selectedIds.length === 0 || wsConnected}
>
</Button>
</Space>
)}
style={{ marginBottom: 12 }}
>
<Space style={{ marginBottom: 8 }} wrap>
<Select
showSearch
allowClear
placeholder="选择充值商品"
value={selectedRechargeGoodsId || undefined}
onChange={(value) => setSelectedRechargeGoodsId(value || '')}
options={rechargeGoodsOptions}
style={{ minWidth: 220 }}
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
/>
<InputNumber
min={1}
max={999}
value={rechargeCount}
onChange={(value) => setRechargeCount(value || 1)}
addonAfter="份"
style={{ width: 120 }}
/>
<Select
value={rechargePayChannel}
onChange={setRechargePayChannel}
options={[
{ value: 'Weixin', label: '微信' },
{ value: 'Zfb', label: '支付宝' },
]}
style={{ width: 110 }}
/>
</Space>
<Table
columns={rechargeGoodsColumns}
dataSource={sortedRechargeGoods}
rowKey="id"
size="small"
pagination={false}
scroll={{ y: 220 }}
locale={{ emptyText: '暂无充值商品,请先刷新充值商品列表' }}
onRow={(record) => ({
onClick: () => setSelectedRechargeGoodsId(record.spu_id),
})}
rowClassName={(record) => record.spu_id === selectedRechargeGoodsId ? 'ant-table-row-selected' : ''}
/>
</Card>
</Col>
@@ -640,7 +814,18 @@ export default function HuyaTasksPage() {
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{QUICK_ACTIONS.map((item) => (
<Tooltip key={item.key} title={item.key === 'refresh_goods' ? '使用一个选中的 CK 刷新当前 SID 商品快照' : undefined}>
<Tooltip
key={item.key}
title={
item.key === 'refresh_goods'
? '使用一个选中的 CK 刷新当前 SID 兑换商品'
: item.key === 'refresh_recharge_goods'
? '使用一个选中的 CK 刷新充值商品列表'
: item.key === 'create_recharge_order'
? '按左侧选择生成扫码支付二维码'
: undefined
}
>
<Button
icon={item.icon}
size="small"
@@ -653,7 +838,7 @@ export default function HuyaTasksPage() {
))}
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
</div>
</Card>
@@ -725,6 +910,35 @@ export default function HuyaTasksPage() {
</div>
)}
</Modal>
<Modal
title="支付二维码"
open={!!payTask}
onCancel={() => setPayTask(null)}
footer={null}
width={380}
>
{payTask && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12, padding: '8px 0 12px' }}>
<Space direction="vertical" size={2} style={{ width: '100%', textAlign: 'center' }}>
<Text strong>{payProductName || '充值商品'}</Text>
<Text type="secondary">
{payChannelLabel || '支付'}{payAmountText ? ` / ${payAmountText}` : ''}{payAccountName ? ` / ${payAccountName}` : ''}
</Text>
{typeof payOrderId === 'number' || typeof payOrderId === 'string' ? (
<Text type="secondary" style={{ fontSize: 12 }}> {String(payOrderId)}</Text>
) : null}
</Space>
{payUrl ? (
<div style={{ padding: 14, borderRadius: 10, background: '#fff', lineHeight: 0 }}>
<QRCode value={payUrl} size={260} bordered={false} color="#000" bgColor="#fff" />
</div>
) : (
<Text type="secondary"></Text>
)}
</div>
)}
</Modal>
</div>
);
}