新增虎牙账号和任务基础功能
This commit is contained in:
@@ -0,0 +1,485 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Col, Form, Input, InputNumber, message, Row, Select, Space, Table, Tag, Tooltip, Typography, theme,
|
||||
} from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import {
|
||||
AppstoreOutlined, CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined, GiftOutlined,
|
||||
LinkOutlined, PlayCircleOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
huyaApi,
|
||||
type HuyaAccountItem,
|
||||
type HuyaConfig,
|
||||
type HuyaGoodsItem,
|
||||
type HuyaTaskItem,
|
||||
} from '../api/modules';
|
||||
import RealtimeLogPanel from '../components/RealtimeLogPanel';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useWebSocketLogs } from '../hooks/useWebSocketLogs';
|
||||
import { formatTime } from '../utils/time';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
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: '刷新商品列表',
|
||||
};
|
||||
|
||||
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 /> },
|
||||
];
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
planned: 'default',
|
||||
pending: 'default',
|
||||
running: 'processing',
|
||||
success: 'success',
|
||||
failed: 'error',
|
||||
error: 'error',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
planned: '已计划',
|
||||
pending: '等待中',
|
||||
running: '执行中',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
error: '异常',
|
||||
};
|
||||
|
||||
function accountLabel(account: HuyaAccountItem): string {
|
||||
const name = account.nickname || account.username || account.uid || `#${account.id}`;
|
||||
const tag = account.tag ? ` [${account.tag}]` : '';
|
||||
const phone = account.game_phone ? ` / ${account.game_phone}` : '';
|
||||
return `${name}${tag}${phone}`;
|
||||
}
|
||||
|
||||
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 [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 [rechargeCount, setRechargeCount] = useState(1);
|
||||
const [concurrency, setConcurrency] = useState(() => {
|
||||
const v = localStorage.getItem('huya_task_concurrency');
|
||||
return v ? Math.max(1, Math.min(10, Number(v) || 3)) : 3;
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [savingConfig, setSavingConfig] = useState(false);
|
||||
const [batchId, setBatchId] = useState<string | null>(null);
|
||||
const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs();
|
||||
const { can } = usePermissions();
|
||||
|
||||
const canTask = can('huya:task');
|
||||
const canConfig = can('huya:config');
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('huya_task_concurrency', String(concurrency));
|
||||
}, [concurrency]);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [accountResult, taskResult, goodsResult, configResult, taskTypeResult] = await Promise.allSettled([
|
||||
huyaApi.listAccounts(),
|
||||
huyaApi.listTasks(),
|
||||
huyaApi.listGoods(),
|
||||
canConfig ? huyaApi.getConfig() : Promise.resolve(null),
|
||||
huyaApi.taskTypes(),
|
||||
]);
|
||||
|
||||
if (accountResult.status === 'fulfilled') setAccounts(accountResult.value);
|
||||
if (taskResult.status === 'fulfilled') setTasks(taskResult.value);
|
||||
if (goodsResult.status === 'fulfilled') setGoods(goodsResult.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)}` : '',
|
||||
configResult.status === 'rejected' ? `虎牙配置: ${getErrorMessage(configResult.reason)}` : '',
|
||||
taskTypeResult.status === 'rejected' ? `任务类型: ${getErrorMessage(taskTypeResult.reason)}` : '',
|
||||
].filter(Boolean);
|
||||
if (failedLabels.length > 0) {
|
||||
message.warning(`部分数据加载失败:${failedLabels.join(';')}`);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canConfig, form]);
|
||||
|
||||
const loadTasks = useCallback(async () => {
|
||||
try {
|
||||
const data = await huyaApi.listTasks();
|
||||
setTasks(data);
|
||||
} catch {
|
||||
// 轮询失败不打扰操作,下一轮继续刷新。
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
}, [loadAll]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(loadTasks, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [loadTasks]);
|
||||
|
||||
const accountOptions = useMemo(() => {
|
||||
return accounts.map((account) => ({ value: account.id, label: accountLabel(account) }));
|
||||
}, [accounts]);
|
||||
|
||||
const goodsOptions = useMemo(() => {
|
||||
return goods.map((item) => ({
|
||||
value: item.product_id,
|
||||
label: `${item.name || item.product_id}${item.price ? ` / ${item.price}积分` : ''}`,
|
||||
}));
|
||||
}, [goods]);
|
||||
|
||||
const selectedGoods = useMemo(() => {
|
||||
return goods.find((item) => item.product_id === selectedGoodsId) || null;
|
||||
}, [goods, selectedGoodsId]);
|
||||
|
||||
const createPayload = (taskType: string) => {
|
||||
if (taskType !== 'recharge_points') return {};
|
||||
return {
|
||||
product_id: selectedGoods?.product_id || selectedGoodsId,
|
||||
product_name: selectedGoods?.name || '',
|
||||
count: rechargeCount,
|
||||
};
|
||||
};
|
||||
|
||||
const startTask = async (taskType = selectedTaskType) => {
|
||||
if (selectedIds.length === 0) {
|
||||
message.warning('请先选择虎牙 CK');
|
||||
return;
|
||||
}
|
||||
if (taskType === 'recharge_points' && !selectedGoodsId) {
|
||||
message.warning('请先选择充值商品');
|
||||
return;
|
||||
}
|
||||
|
||||
setStarting(true);
|
||||
try {
|
||||
const result = await huyaApi.createTasks({
|
||||
account_ids: selectedIds,
|
||||
task_type: taskType,
|
||||
concurrency,
|
||||
payload: createPayload(taskType),
|
||||
});
|
||||
setBatchId(result.batch_id);
|
||||
message.success(`已创建 ${taskTypes[taskType] || taskType},共 ${result.count} 个账号`);
|
||||
await loadTasks();
|
||||
connectLogs(`/api/huya/ws/${result.batch_id}`, {
|
||||
onClose: () => { setBatchId(null); setStarting(false); loadTasks(); },
|
||||
onResult: () => { setBatchId(null); setStarting(false); loadTasks(); },
|
||||
onError: () => { setBatchId(null); setStarting(false); loadTasks(); },
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveConfig = async () => {
|
||||
setSavingConfig(true);
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const result = await huyaApi.updateConfig(values);
|
||||
form.setFieldsValue(result);
|
||||
message.success('虎牙配置已保存');
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setSavingConfig(false);
|
||||
}
|
||||
};
|
||||
|
||||
const successCount = tasks.filter((task) => task.status === 'success').length;
|
||||
const plannedCount = tasks.filter((task) => task.status === 'planned').length;
|
||||
const failedCount = tasks.filter((task) => ['failed', 'error'].includes(task.status)).length;
|
||||
|
||||
const taskColumns: TableProps<HuyaTaskItem>['columns'] = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70, align: 'center' },
|
||||
{
|
||||
title: '任务',
|
||||
dataIndex: 'task_type',
|
||||
width: 150,
|
||||
render: (value: string) => taskTypes[value] || value,
|
||||
},
|
||||
{
|
||||
title: '账号',
|
||||
width: 160,
|
||||
render: (_: unknown, record) => record.account_nickname || record.account_uid || record.account_id,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (status: string) => (
|
||||
<Tag color={STATUS_COLORS[status] || 'default'}>{STATUS_LABELS[status] || status}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '消息', dataIndex: 'message', ellipsis: true },
|
||||
{
|
||||
title: '结果',
|
||||
dataIndex: 'result',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (value: Record<string, unknown> | null) => (
|
||||
value ? <Text code style={{ fontSize: 12 }}>{JSON.stringify(value)}</Text> : <Text type="secondary">-</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 170,
|
||||
render: (value: string | null) => value ? formatTime(value) : <Text type="secondary">-</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
const goodsColumns: TableProps<HuyaGoodsItem>['columns'] = [
|
||||
{ title: '商品ID', dataIndex: 'product_id', width: 120, ellipsis: true },
|
||||
{ title: '名称', dataIndex: 'name', ellipsis: true },
|
||||
{
|
||||
title: '价格',
|
||||
dataIndex: 'price',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
render: (value: number | null) => value ?? <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: '库存',
|
||||
dataIndex: 'remain_text',
|
||||
width: 100,
|
||||
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 }}>
|
||||
<h2 style={{ margin: 0 }}>虎牙兑换与充值</h2>
|
||||
<Space wrap>
|
||||
<Button icon={<ReloadOutlined />} onClick={loadAll} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
{batchId && <Tag color="processing">批次 {batchId}</Tag>}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', paddingRight: 2 }}>
|
||||
<Row gutter={12}>
|
||||
<Col xs={24} xl={10}>
|
||||
<Card
|
||||
size="small"
|
||||
title={<Space><SettingOutlined />虎牙配置</Space>}
|
||||
extra={canConfig && (
|
||||
<Button size="small" type="primary" onClick={saveConfig} loading={savingConfig}>
|
||||
保存
|
||||
</Button>
|
||||
)}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<Form form={form} layout="vertical" disabled={!canConfig}>
|
||||
<Row gutter={8}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="直播间 ID" name="room_pid">
|
||||
<Input placeholder="roomPid / pid" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="SID" name="sid">
|
||||
<Input placeholder="活动 sid" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="兑换活动 ID" name="outer_act_id">
|
||||
<Input placeholder="默认 9504" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="绑定活动 ID" name="bind_act_id">
|
||||
<Input placeholder="默认 17096" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="支付渠道" name="pay_channel">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'Zfb', label: '支付宝' },
|
||||
{ value: 'Wx', label: '微信' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</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>
|
||||
<Table
|
||||
columns={goodsColumns}
|
||||
dataSource={goods}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={{ x: 620, y: 220 }}
|
||||
locale={{ emptyText: '暂无商品快照,后续接入刷新商品列表后写入' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} xl={14}>
|
||||
<Card size="small" title="批量动作" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
placeholder="选择虎牙 CK"
|
||||
value={selectedIds}
|
||||
onChange={setSelectedIds}
|
||||
options={accountOptions}
|
||||
maxTagCount="responsive"
|
||||
style={{ width: '100%' }}
|
||||
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
|
||||
dropdownRender={(menu) => (
|
||||
<>
|
||||
<div style={{ padding: '4px 8px', borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', gap: 8 }}>
|
||||
<Button size="small" type="link" onClick={() => setSelectedIds(accounts.map((item) => item.id))}>
|
||||
全选 ({accounts.length})
|
||||
</Button>
|
||||
<Button size="small" type="link" onClick={() => setSelectedIds([])}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
{menu}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Select
|
||||
value={selectedTaskType}
|
||||
onChange={setSelectedTaskType}
|
||||
options={Object.entries(taskTypes).map(([value, label]) => ({ value, label }))}
|
||||
style={{ flex: '1 1 220px', minWidth: 180 }}
|
||||
/>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={10}
|
||||
value={concurrency}
|
||||
onChange={(value) => setConcurrency(value || 1)}
|
||||
addonBefore="并发"
|
||||
style={{ width: 130, flexShrink: 0 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
loading={starting}
|
||||
disabled={!canTask || selectedIds.length === 0 || wsConnected}
|
||||
onClick={() => startTask()}
|
||||
>
|
||||
创建任务
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
{QUICK_ACTIONS.map((item) => (
|
||||
<Tooltip key={item.key} title={item.key === 'refresh_goods' ? '当前阶段创建计划任务,真实拉取逻辑后续接入' : undefined}>
|
||||
<Button
|
||||
icon={item.icon}
|
||||
size="small"
|
||||
onClick={() => startTask(item.key)}
|
||||
disabled={!canTask || selectedIds.length === 0 || wsConnected}
|
||||
>
|
||||
{taskTypes[item.key] || item.key}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
当前阶段只创建 planned 任务并打通日志通道,真实 WSS/HTTP 执行器后续接入。
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 8, color: token.colorTextSecondary }}>
|
||||
<span>共 <b>{tasks.length}</b> 个任务</span>
|
||||
<span>已计划 <b>{plannedCount}</b></span>
|
||||
<span>成功 <b style={{ color: token.colorSuccess }}>{successCount}</b></span>
|
||||
<span>失败 <b style={{ color: token.colorError }}>{failedCount}</b></span>
|
||||
</div>
|
||||
<Table
|
||||
columns={taskColumns}
|
||||
dataSource={tasks}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{ pageSize: 12, showTotal: (total) => `共 ${total} 条` }}
|
||||
scroll={{ x: 920 }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<RealtimeLogPanel
|
||||
logs={logs}
|
||||
connected={wsConnected}
|
||||
title="虎牙实时日志"
|
||||
emptyText="暂无虎牙任务日志"
|
||||
collapsible
|
||||
spinWhenEmpty
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user