新增虎牙账号和任务基础功能

This commit is contained in:
yml2213
2026-07-04 16:31:12 +08:00
parent 3df247e4e5
commit e1d47a85be
26 changed files with 4513 additions and 3 deletions
+4
View File
@@ -11,6 +11,8 @@ import LoginTasksPage from './pages/LoginTasksPage';
import ProxyPage from './pages/ProxyPage';
import UsersPage from './pages/UsersPage';
import CookiePage from './pages/CookiePage';
import HuyaAccountsPage from './pages/HuyaAccountsPage';
import HuyaTasksPage from './pages/HuyaTasksPage';
import { getUser } from './store/auth';
import { ThemeProvider } from './store/theme';
import { useTheme } from './store/useTheme';
@@ -44,6 +46,8 @@ function AppContent() {
<Route path="assignments" element={<AssignmentsPage />} />
<Route path="login-tasks" element={<LoginTasksPage />} />
<Route path="cookies" element={<CookiePage />} />
<Route path="huya/accounts" element={<HuyaAccountsPage />} />
<Route path="huya/tasks" element={<HuyaTasksPage />} />
<Route path="proxy" element={<ProxyPage />} />
<Route path="users" element={<UsersPage />} />
</Route>
+30
View File
@@ -0,0 +1,30 @@
import api from './client';
import type {
HuyaAccountItem,
HuyaConfig,
HuyaCookieImportResult,
HuyaGoodsItem,
HuyaTaskBatchRequest,
HuyaTaskBatchResult,
HuyaTaskItem,
MessageDeletedResponse,
MessageResponse,
} from './types';
export const huyaApi = {
taskTypes: () => api.get<Record<string, string>, Record<string, string>>('/huya/task-types'),
listAccounts: (params?: { tag?: string }) =>
api.get<HuyaAccountItem[], HuyaAccountItem[]>('/huya/accounts', { params }),
importCookies: (text: string, tag: string = '') =>
api.post<HuyaCookieImportResult, HuyaCookieImportResult>('/huya/accounts/import-cookies', { text, tag }),
deleteAccount: (id: number) => api.delete<MessageResponse, MessageResponse>(`/huya/accounts/${id}`),
deleteAccounts: (accountIds: number[]) =>
api.delete<MessageDeletedResponse, MessageDeletedResponse>('/huya/accounts/batch', { params: { account_ids: accountIds.join(',') } }),
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'),
createTasks: (data: HuyaTaskBatchRequest) =>
api.post<HuyaTaskBatchResult, HuyaTaskBatchResult>('/huya/tasks/batch', data),
listTasks: (batchId?: string) =>
api.get<HuyaTaskItem[], HuyaTaskItem[]>('/huya/tasks', { params: batchId ? { batch_id: batchId } : {} }),
};
+1
View File
@@ -3,6 +3,7 @@ export { accountApi } from './accounts';
export { appApi } from './app';
export { authApi } from './auth';
export { cookieApi } from './cookies';
export { huyaApi } from './huya';
export { loginApi } from './login';
export { proxyApi } from './proxy';
export { userApi } from './users';
+74
View File
@@ -109,6 +109,80 @@ export interface CookieItem {
account_password: string;
}
// ==================== Huya ====================
export interface HuyaAccountItem {
id: number;
uid: string;
yyuid: string;
username: string;
nickname: string;
cookie: string;
cookie_preview: string;
tag: string;
remark: string;
status: string;
points: number | null;
game_name: string;
game_channel: string;
game_phone: string;
assigned_to: number | null;
assigned_username: string | null;
created_at: string | null;
updated_at: string | null;
}
export interface HuyaCookieImportResult extends MessageCountResponse {
skipped: number;
}
export interface HuyaConfig {
room_pid: string;
sid: string;
outer_act_id: string;
bind_act_id: string;
pay_channel: string;
updated_at: string | null;
}
export interface HuyaTaskBatchRequest {
account_ids: number[];
task_type: string;
concurrency?: number;
payload?: Record<string, unknown>;
}
export interface HuyaTaskBatchResult {
batch_id: string;
count: number;
success: boolean;
}
export interface HuyaTaskItem {
id: number;
batch_id: string;
account_id: number;
account_uid: string;
account_nickname: string;
task_type: string;
status: string;
message: string;
result: Record<string, unknown> | null;
created_by: number;
created_at: string | null;
finished_at: string | null;
}
export interface HuyaGoodsItem {
id: number;
product_id: string;
name: string;
price: number | null;
remain_text: string;
raw: Record<string, unknown> | null;
updated_at: string | null;
}
// ==================== Proxy ====================
export interface ProxyConfig {
+11 -1
View File
@@ -4,7 +4,7 @@ import {
DashboardOutlined, UserOutlined, LogoutOutlined,
CloudServerOutlined, TeamOutlined, ApiOutlined, KeyOutlined,
MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined,
SunOutlined, MoonOutlined, DesktopOutlined,
SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ShoppingCartOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import { getUser, clearAuth, type AuthUser } from '../store/auth';
@@ -69,6 +69,16 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
menuItems.push({ key: '/cookies', label: 'Cookie 管理', icon: <KeyOutlined /> });
}
// 虎牙 CK 管理
if (can('huya:account')) {
menuItems.push({ key: '/huya/accounts', label: '虎牙 CK', icon: <GiftOutlined /> });
}
// 虎牙兑换与充值
if (can('huya:task')) {
menuItems.push({ key: '/huya/tasks', label: '虎牙任务', icon: <ShoppingCartOutlined /> });
}
// 代理配置
if (can('proxy:manage')) {
menuItems.push({ key: '/proxy', label: '代理配置', icon: <CloudServerOutlined /> });
+315
View File
@@ -0,0 +1,315 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Button, Card, Col, Input, message, Modal, Popconfirm, Row, Space, Statistic, Table, Tag, Typography,
} from 'antd';
import type { TableProps } from 'antd';
import { DeleteOutlined, ImportOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { huyaApi, type HuyaAccountItem } from '../api/modules';
import { usePermissions } from '../hooks/usePermissions';
import { formatTime } from '../utils/time';
import { getErrorMessage } from '../utils/error';
const { Text, Paragraph } = Typography;
const { TextArea } = Input;
const STATUS_LABELS: Record<string, string> = {
imported: '已导入',
updated: '已更新',
active: '正常',
invalid: '失效',
};
const STATUS_COLORS: Record<string, string> = {
imported: 'blue',
updated: 'cyan',
active: 'success',
invalid: 'error',
};
export default function HuyaAccountsPage() {
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
const [loading, setLoading] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importText, setImportText] = useState('');
const [importTag, setImportTag] = useState('');
const [importing, setImporting] = useState(false);
const [searchText, setSearchText] = useState('');
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [pageSize, setPageSize] = useState(() => {
const v = localStorage.getItem('huya_account_page_size');
return v ? Number(v) || 20 : 20;
});
const [currentPage, setCurrentPage] = useState(1);
const { can } = usePermissions();
const canManage = can('huya:account');
const loadAccounts = useCallback(async () => {
setLoading(true);
try {
const data = await huyaApi.listAccounts();
setAccounts(data);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadAccounts();
}, [loadAccounts]);
const tags = useMemo(() => {
return [...new Set(accounts.map((item) => item.tag.trim()).filter(Boolean))].sort();
}, [accounts]);
const filteredAccounts = useMemo(() => {
const s = searchText.trim().toLowerCase();
if (!s) return accounts;
return accounts.filter((item) => (
item.uid.toLowerCase().includes(s) ||
item.yyuid.toLowerCase().includes(s) ||
item.username.toLowerCase().includes(s) ||
item.nickname.toLowerCase().includes(s) ||
item.tag.toLowerCase().includes(s) ||
item.game_name.toLowerCase().includes(s) ||
item.game_phone.toLowerCase().includes(s)
));
}, [accounts, searchText]);
const handleImport = async () => {
if (!importText.trim()) {
message.warning('请先粘贴虎牙 CK');
return;
}
setImporting(true);
try {
const result = await huyaApi.importCookies(importText, importTag);
message.success(result.message);
setImportOpen(false);
setImportText('');
setImportTag('');
loadAccounts();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setImporting(false);
}
};
const handleDelete = async (id: number) => {
try {
await huyaApi.deleteAccount(id);
message.success('已删除');
setSelectedRowKeys((prev) => prev.filter((key) => key !== id));
loadAccounts();
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
const handleDeleteSelected = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择虎牙 CK');
return;
}
try {
const result = await huyaApi.deleteAccounts(selectedRowKeys.map((key) => Number(key)));
message.success(result.message);
setSelectedRowKeys([]);
loadAccounts();
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
const boundCount = accounts.filter((item) => item.game_name || item.game_channel || item.game_phone).length;
const pointCount = accounts.filter((item) => item.points !== null && item.points !== undefined).length;
const columns: TableProps<HuyaAccountItem>['columns'] = [
{ title: 'ID', dataIndex: 'id', width: 70, align: 'center' },
{
title: '虎牙账号',
width: 180,
render: (_: unknown, record) => (
<Space direction="vertical" size={0}>
<Text strong>{record.nickname || record.username || record.uid || '-'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
UID {record.uid || record.yyuid || '-'}
</Text>
</Space>
),
},
{
title: '标签',
dataIndex: 'tag',
width: 110,
render: (tag: string) => tag ? <Tag color="blue">{tag}</Tag> : <Text type="secondary">-</Text>,
},
{
title: '积分',
dataIndex: 'points',
width: 90,
align: 'center',
render: (points: number | null) => points ?? <Text type="secondary"></Text>,
},
{
title: '游戏名',
dataIndex: 'game_name',
width: 160,
ellipsis: true,
render: (value: string) => value || <Text type="secondary"></Text>,
},
{
title: '手机号',
dataIndex: 'game_phone',
width: 140,
ellipsis: true,
render: (value: string) => value || <Text type="secondary">-</Text>,
},
{
title: 'Cookie',
dataIndex: 'cookie_preview',
ellipsis: true,
render: (value: string) => (
<Text code style={{ fontSize: 12 }}>
{value || '-'}
</Text>
),
},
{
title: '状态',
dataIndex: 'status',
width: 100,
align: 'center',
render: (status: string) => (
<Tag color={STATUS_COLORS[status] || 'default'}>{STATUS_LABELS[status] || status || '-'}</Tag>
),
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 170,
render: (value: string | null) => value ? formatTime(value) : <Text type="secondary">-</Text>,
},
{
title: '操作',
width: 90,
fixed: 'right',
align: 'center',
render: (_: unknown, record) => (
<Popconfirm title="确认删除这条虎牙 CK" onConfirm={() => handleDelete(record.id)}>
<Button danger size="small" icon={<DeleteOutlined />} />
</Popconfirm>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<h2 style={{ margin: 0 }}> CK </h2>
<Space wrap>
<Button icon={<ReloadOutlined />} onClick={loadAccounts} loading={loading}>
</Button>
{selectedRowKeys.length > 0 && (
<Popconfirm title={`确认删除选中的 ${selectedRowKeys.length} 条虎牙 CK`} onConfirm={handleDeleteSelected}>
<Button danger icon={<DeleteOutlined />}>
({selectedRowKeys.length})
</Button>
</Popconfirm>
)}
{canManage && (
<Button type="primary" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>
CK
</Button>
)}
</Space>
</div>
<Row gutter={12} style={{ marginBottom: 16 }}>
<Col xs={24} sm={8}>
<Card size="small"><Statistic title="CK 总数" value={accounts.length} /></Card>
</Col>
<Col xs={24} sm={8}>
<Card size="small"><Statistic title="已查积分" value={pointCount} /></Card>
</Col>
<Col xs={24} sm={8}>
<Card size="small"><Statistic title="已绑定信息" value={boundCount} /></Card>
</Col>
</Row>
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Input.Search
placeholder="搜索 UID、昵称、标签、游戏名、手机号"
allowClear
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 300 }}
prefix={<SearchOutlined />}
/>
{tags.map((tag) => (
<Tag key={tag} color="blue" onClick={() => setSearchText(tag)} style={{ cursor: 'pointer' }}>
{tag}
</Tag>
))}
</div>
<Table
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys),
}}
columns={columns}
dataSource={filteredAccounts}
rowKey="id"
loading={loading}
size="small"
pagination={{
current: currentPage,
pageSize,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (page, size) => {
setCurrentPage(page);
if (size !== pageSize) {
setPageSize(size);
localStorage.setItem('huya_account_page_size', String(size));
setCurrentPage(1);
}
},
}}
scroll={{ x: 1120 }}
/>
<Modal
title="粘贴虎牙 CK"
open={importOpen}
onCancel={() => setImportOpen(false)}
onOk={handleImport}
okText="导入"
confirmLoading={importing}
width={720}
>
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Input
placeholder="标签,可选"
value={importTag}
onChange={(e) => setImportTag(e.target.value)}
/>
<TextArea
rows={12}
value={importText}
onChange={(e) => setImportText(e.target.value)}
placeholder="每行一条,支持纯 CK、账号----密码----CK、CK----手机号"
/>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
UIDYYUID CK
</Paragraph>
</Space>
</Modal>
</div>
);
}
+485
View File
@@ -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>
);
}
+3 -1
View File
@@ -1,6 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
const backendTarget = process.env.VITE_BACKEND_TARGET || 'http://127.0.0.1:8000'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
@@ -9,7 +11,7 @@ export default defineConfig({
allowedHosts: ["www.u499731.nyat.app"],
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
target: backendTarget,
changeOrigin: true,
ws: true,
},