优化双平台后台界面

This commit is contained in:
yml2213
2026-07-05 12:25:18 +08:00
parent 32065c75a3
commit 8f48b96fdc
7 changed files with 545 additions and 363 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "douyu-login-py"
version = "0.2.0"
description = "斗鱼批量登录 Web 后台"
description = "直播账号运营 Web 后台"
readme = "README.md"
requires-python = ">=3.12,<3.13"
dependencies = [
+1 -1
View File
@@ -29,7 +29,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="斗鱼批量登录后台",
title="直播运营后台",
version=get_app_version(),
lifespan=lifespan,
)
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>直播运营后台</title>
</head>
<body>
<div id="root"></div>
+28 -24
View File
@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react';
import { Layout, Menu, Avatar, Space, Typography, Button, Modal, Dropdown } from 'antd';
import type { MenuProps } from 'antd';
import {
DashboardOutlined, UserOutlined, LogoutOutlined,
CloudServerOutlined, TeamOutlined, ApiOutlined, KeyOutlined,
MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined,
SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ShoppingCartOutlined,
SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ShoppingCartOutlined, ApartmentOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import { getUser, clearAuth, type AuthUser } from '../store/auth';
@@ -44,49 +45,52 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
if (!user) return null;
const menuItems: { key: string; label: string; icon: React.ReactNode }[] = [];
const menuItems: MenuProps['items'] = [];
const douyuItems: NonNullable<MenuProps['items']> = [];
const huyaItems: NonNullable<MenuProps['items']> = [];
const systemItems: NonNullable<MenuProps['items']> = [];
// Dashboard - 所有人可见
menuItems.push({ key: '/', label: '概览', icon: <DashboardOutlined /> });
// 账号管理
// 斗鱼
if (canAny(['account:view_all', 'account:view_assigned'])) {
menuItems.push({ key: '/accounts', label: '账号管理', icon: <UserOutlined /> });
douyuItems.push({ key: '/accounts', label: '账号管理', icon: <UserOutlined /> });
}
// 分配管理
if (can('account:assign')) {
menuItems.push({ key: '/assignments', label: '分配管理', icon: <SwapOutlined /> });
douyuItems.push({ key: '/assignments', label: '分配管理', icon: <SwapOutlined /> });
}
// 登录任务
if (canAny(['login:batch', 'login:view_all'])) {
menuItems.push({ key: '/login-tasks', label: '登录任务', icon: <ApiOutlined /> });
douyuItems.push({ key: '/login-tasks', label: '登录任务', icon: <ApiOutlined /> });
}
// Cookie 管理
if (can('cookie:view')) {
menuItems.push({ key: '/cookies', label: 'Cookie 管理', icon: <KeyOutlined /> });
douyuItems.push({ key: '/cookies', label: 'Cookie 管理', icon: <KeyOutlined /> });
}
// 虎牙 CK 管理
// 虎牙
if (can('huya:account')) {
menuItems.push({ key: '/huya/accounts', label: '虎牙 CK', icon: <GiftOutlined /> });
huyaItems.push({ key: '/huya/accounts', label: '账号管理', icon: <GiftOutlined /> });
}
// 虎牙兑换与充值
if (can('huya:task')) {
menuItems.push({ key: '/huya/tasks', label: '虎牙任务', icon: <ShoppingCartOutlined /> });
huyaItems.push({ key: '/huya/tasks', label: '任务操作台', icon: <ShoppingCartOutlined /> });
}
// 代理配置
if (douyuItems.length > 0) {
menuItems.push({ key: 'group-douyu', type: 'group', label: '斗鱼', children: douyuItems });
}
if (huyaItems.length > 0) {
menuItems.push({ key: 'group-huya', type: 'group', label: '虎牙', children: huyaItems });
}
// 系统
if (can('proxy:manage')) {
menuItems.push({ key: '/proxy', label: '代理配置', icon: <CloudServerOutlined /> });
systemItems.push({ key: '/proxy', label: '代理配置', icon: <CloudServerOutlined /> });
}
// 用户管理
if (can('user:view')) {
menuItems.push({ key: '/users', label: '用户管理', icon: <TeamOutlined /> });
systemItems.push({ key: '/users', label: '用户管理', icon: <TeamOutlined /> });
}
if (systemItems.length > 0) {
menuItems.push({ key: 'group-system', type: 'group', label: '系统', children: systemItems });
}
const handleLogout = () => {
@@ -129,7 +133,7 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
color: siderTextColor,
}}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{collapsed ? '鱼' : '斗鱼登录后台'}
{collapsed ? <ApartmentOutlined /> : '直播运营后台'}
</span>
<Button
type="text"
+180 -37
View File
@@ -1,48 +1,191 @@
import { Card, Col, Row, Statistic, theme } from 'antd';
import { useEffect, useState } from 'react';
import { accountApi, loginApi, type LoginTaskItem } from '../api/modules';
import { Card, Col, Row, Space, Statistic, Tag, Typography, theme } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import {
accountApi,
cookieApi,
huyaApi,
loginApi,
type HuyaTaskItem,
type LoginTaskItem,
} from '../api/modules';
import { usePermissions } from '../hooks/usePermissions';
const { Title, Text } = Typography;
interface PlatformStats {
accounts: number;
tasks: number;
success: number;
failed: number;
}
interface DashboardStats {
douyu: PlatformStats & {
cookies: number;
};
huya: PlatformStats & {
goods: number;
rechargeGoods: number;
};
}
const EMPTY_STATS: DashboardStats = {
douyu: {
accounts: 0,
tasks: 0,
success: 0,
failed: 0,
cookies: 0,
},
huya: {
accounts: 0,
tasks: 0,
success: 0,
failed: 0,
goods: 0,
rechargeGoods: 0,
},
};
function countFailed<T extends { status: string }>(items: T[]) {
return items.filter((item) => ['failed', 'error'].includes(item.status)).length;
}
function StatCard({
title,
value,
color,
loading,
}: {
title: string;
value: number;
color?: string;
loading?: boolean;
}) {
return (
<Col xs={24} sm={12} lg={6}>
<Card size="small" loading={loading}>
<Statistic title={title} value={value} styles={{ content: color ? { color } : undefined }} />
</Card>
</Col>
);
}
export default function DashboardPage() {
const { token } = theme.useToken();
const [stats, setStats] = useState({ accounts: 0, tasks: 0, success: 0, failed: 0 });
const { can, canAny } = usePermissions();
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<DashboardStats>(EMPTY_STATS);
const canViewDouyuAccounts = canAny(['account:view_all', 'account:view_assigned']);
const canViewDouyuTasks = canAny(['login:batch', 'login:view_all', 'login:view_assigned']);
const canViewCookies = can('cookie:view');
const canViewHuyaAccounts = can('huya:account');
const canViewHuyaTasks = can('huya:task');
useEffect(() => {
Promise.all([accountApi.list(), loginApi.listTasks()])
.then(([accounts, tasks]) => {
setStats({
accounts: accounts.length,
tasks: tasks.length,
success: tasks.filter((t: LoginTaskItem) => t.status === 'success').length,
failed: tasks.filter((t: LoginTaskItem) => ['failed', 'error'].includes(t.status)).length,
});
})
.catch(() => {});
}, []);
let ignore = false;
async function loadDashboard() {
setLoading(true);
const [
douyuAccountsResult,
douyuTasksResult,
cookiesResult,
huyaAccountsResult,
huyaTasksResult,
huyaGoodsResult,
huyaRechargeGoodsResult,
] = await Promise.allSettled([
canViewDouyuAccounts ? accountApi.list() : Promise.resolve([]),
canViewDouyuTasks ? loginApi.listTasks() : Promise.resolve([]),
canViewCookies ? cookieApi.list() : Promise.resolve([]),
canViewHuyaAccounts ? huyaApi.listAccounts() : Promise.resolve([]),
canViewHuyaTasks ? huyaApi.listTasks() : Promise.resolve([]),
canViewHuyaTasks ? huyaApi.listGoods() : Promise.resolve([]),
canViewHuyaTasks ? huyaApi.listRechargeGoods() : Promise.resolve([]),
]);
if (ignore) return;
const douyuAccounts = douyuAccountsResult.status === 'fulfilled' ? douyuAccountsResult.value : [];
const douyuTasks = douyuTasksResult.status === 'fulfilled' ? douyuTasksResult.value : [];
const cookies = cookiesResult.status === 'fulfilled' ? cookiesResult.value : [];
const huyaAccounts = huyaAccountsResult.status === 'fulfilled' ? huyaAccountsResult.value : [];
const huyaTasks = huyaTasksResult.status === 'fulfilled' ? huyaTasksResult.value : [];
const huyaGoods = huyaGoodsResult.status === 'fulfilled' ? huyaGoodsResult.value : [];
const huyaRechargeGoods = huyaRechargeGoodsResult.status === 'fulfilled' ? huyaRechargeGoodsResult.value : [];
setStats({
douyu: {
accounts: douyuAccounts.length,
tasks: douyuTasks.length,
success: douyuTasks.filter((task: LoginTaskItem) => task.status === 'success').length,
failed: countFailed(douyuTasks),
cookies: cookies.length,
},
huya: {
accounts: huyaAccounts.length,
tasks: huyaTasks.length,
success: huyaTasks.filter((task: HuyaTaskItem) => task.status === 'success').length,
failed: countFailed(huyaTasks),
goods: huyaGoods.length,
rechargeGoods: huyaRechargeGoods.length,
},
});
setLoading(false);
}
void loadDashboard();
return () => {
ignore = true;
};
}, [canViewCookies, canViewDouyuAccounts, canViewDouyuTasks, canViewHuyaAccounts, canViewHuyaTasks]);
const totalStats = useMemo(() => ({
accounts: stats.douyu.accounts + stats.huya.accounts,
tasks: stats.douyu.tasks + stats.huya.tasks,
success: stats.douyu.success + stats.huya.success,
failed: stats.douyu.failed + stats.huya.failed,
}), [stats]);
return (
<div>
<h2></h2>
<Row gutter={16}>
<Col span={6}>
<Card>
<Statistic title="账号总数" value={stats.accounts} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="登录任务总数" value={stats.tasks} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="成功" value={stats.success} styles={{ content: { color: token.colorSuccess } }} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="失败" value={stats.failed} styles={{ content: { color: token.colorError } }} />
</Card>
</Col>
<div style={{ marginBottom: 16 }}>
<Space align="center" wrap>
<Title level={2} style={{ margin: 0 }}></Title>
<Tag color="blue"></Tag>
<Tag color="orange"></Tag>
</Space>
</div>
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
<StatCard title="账号总数" value={totalStats.accounts} loading={loading} />
<StatCard title="任务总数" value={totalStats.tasks} loading={loading} />
<StatCard title="成功任务" value={totalStats.success} color={token.colorSuccess} loading={loading} />
<StatCard title="失败任务" value={totalStats.failed} color={token.colorError} loading={loading} />
</Row>
<div style={{ marginBottom: 10 }}>
<Title level={4} style={{ margin: 0 }}></Title>
<Text type="secondary">Cookie </Text>
</div>
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
<StatCard title="斗鱼账号" value={stats.douyu.accounts} loading={loading} />
<StatCard title="登录任务" value={stats.douyu.tasks} loading={loading} />
<StatCard title="成功 Cookie" value={stats.douyu.cookies} color={token.colorSuccess} loading={loading} />
<StatCard title="失败/异常" value={stats.douyu.failed} color={token.colorError} loading={loading} />
</Row>
<div style={{ marginBottom: 10 }}>
<Title level={4} style={{ margin: 0 }}></Title>
<Text type="secondary"> Cookie</Text>
</div>
<Row gutter={[12, 12]}>
<StatCard title="虎牙账号" value={stats.huya.accounts} loading={loading} />
<StatCard title="虎牙任务" value={stats.huya.tasks} loading={loading} />
<StatCard title="兑换商品" value={stats.huya.goods} loading={loading} />
<StatCard title="充值商品" value={stats.huya.rechargeGoods} loading={loading} />
</Row>
</div>
);
+333 -298
View File
@@ -735,10 +735,31 @@ export default function HuyaTasksPage() {
},
];
const taskSummary = (
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 8, color: token.colorTextSecondary, flexWrap: 'wrap' }}>
<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>
);
const renderTaskTable = (pageSize = 12) => (
<Table
columns={taskColumns}
dataSource={tasks}
rowKey="id"
loading={loading}
size="small"
pagination={{ pageSize, showTotal: (total) => `${total}` }}
scroll={{ x: 920 }}
/>
);
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>
<h2 style={{ margin: 0 }}></h2>
<Space wrap>
<Button icon={<ReloadOutlined />} onClick={loadAll} loading={loading}>
@@ -748,313 +769,327 @@ export default function HuyaTasksPage() {
</div>
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', overflowX: 'hidden', 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="默认 1199650619883" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="SID" name="sid">
<Input placeholder="默认 2203" />
</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="绑定 bActId" name="bind_act_id">
<Input placeholder="默认 9271" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="支付渠道" name="pay_channel">
<Tabs
defaultActiveKey="workbench"
items={[
{
key: 'workbench',
label: <Space><PlayCircleOutlined /></Space>,
children: (
<>
<Card size="small" title="批量动作" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<Select
options={[
{ value: 'Weixin', label: '微信' },
{ value: 'Zfb', label: '支付宝' },
]}
mode="multiple"
showSearch
placeholder="选择虎牙账号"
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}
</>
)}
/>
</Form.Item>
</Col>
</Row>
</Form>
</Card>
<Card
size="small"
title={<Space><ShoppingOutlined /></Space>}
extra={(
<Space size={6}>
<Button
size="small"
icon={<ReloadOutlined />}
onClick={() => startTask('refresh_goods')}
disabled={!canTask || selectedIds.length === 0 || wsConnected}
>
</Button>
<Button
size="small"
type="primary"
icon={<CheckCircleOutlined />}
onClick={() => startTask('exchange_goods')}
disabled={!canTask || selectedIds.length === 0 || wsConnected}
>
</Button>
</Space>
)}
style={{ marginBottom: 12 }}
>
<Space style={{ marginBottom: 8 }} wrap>
<Select
showSearch
allowClear
placeholder="选择兑换商品"
value={selectedExchangeGoodsId || undefined}
onChange={(value) => setSelectedExchangeGoodsId(value || '')}
options={goodsOptions}
style={{ minWidth: 220 }}
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
/>
<DatePicker
showTime
allowClear
value={exchangeAt}
onChange={setExchangeAt}
placeholder="立即兑换"
style={{ width: 190 }}
/>
</Space>
{goodsCategories.length > 0 && (
<Tabs
size="small"
activeKey={selectedGoodsCategory}
onChange={setSelectedGoodsCategory}
items={
goodsCategories.map((item) => ({
key: item.key,
label: `${item.label} (${item.count})`,
}))
}
/>
)}
<Table
columns={goodsColumns}
dataSource={filteredGoods}
rowKey="id"
size="small"
pagination={false}
scroll={{ y: 220 }}
locale={{ emptyText: '暂无兑换商品,请先刷新商品列表' }}
onRow={(record) => ({
onClick: () => setSelectedExchangeGoodsId(record.product_id),
})}
rowClassName={(record) => record.product_id === selectedExchangeGoodsId ? 'ant-table-row-selected' : ''}
/>
</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>
<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([])}>
<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>
{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 }}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{QUICK_ACTIONS.map((item) => (
<Tooltip
key={item.key}
title={
item.key === 'refresh_goods'
? '使用一个选中的账号刷新当前 SID 兑换商品'
: item.key === 'refresh_recharge_goods'
? '使用一个选中的账号刷新充值商品列表'
: item.key === 'exchange_goods'
? '按商品页选择商品,支持立即或定时兑换'
: item.key === 'create_recharge_order'
? '按商品页选择生成扫码支付二维码'
: 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>
</div>
</Card>
{taskSummary}
{renderTaskTable(12)}
<RealtimeLogPanel
logs={logs}
connected={wsConnected}
title="虎牙实时日志"
emptyText="暂无虎牙任务日志"
collapsible
spinWhenEmpty
style={{ marginTop: 8 }}
/>
<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'
? '使用一个选中的 CK 刷新当前 SID 兑换商品'
: item.key === 'refresh_recharge_goods'
? '使用一个选中的 CK 刷新充值商品列表'
: item.key === 'exchange_goods'
? '按左侧选择商品,支持立即或定时兑换'
: item.key === 'create_recharge_order'
? '按左侧选择生成扫码支付二维码'
: undefined
}
</>
),
},
{
key: 'goods',
label: <Space><ShoppingOutlined /></Space>,
children: (
<Row gutter={12}>
<Col xs={24} xl={12}>
<Card
size="small"
title={<Space><ShoppingOutlined /></Space>}
extra={(
<Space size={6}>
<Button
size="small"
icon={<ReloadOutlined />}
onClick={() => startTask('refresh_goods')}
disabled={!canTask || selectedIds.length === 0 || wsConnected}
>
</Button>
<Button
size="small"
type="primary"
icon={<CheckCircleOutlined />}
onClick={() => startTask('exchange_goods')}
disabled={!canTask || selectedIds.length === 0 || wsConnected}
>
</Button>
</Space>
)}
style={{ marginBottom: 12 }}
>
<Button
icon={item.icon}
<Space style={{ marginBottom: 8 }} wrap>
<Select
showSearch
allowClear
placeholder="选择兑换商品"
value={selectedExchangeGoodsId || undefined}
onChange={(value) => setSelectedExchangeGoodsId(value || '')}
options={goodsOptions}
style={{ minWidth: 220 }}
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
/>
<DatePicker
showTime
allowClear
value={exchangeAt}
onChange={setExchangeAt}
placeholder="立即兑换"
style={{ width: 190 }}
/>
</Space>
{goodsCategories.length > 0 && (
<Tabs
size="small"
activeKey={selectedGoodsCategory}
onChange={setSelectedGoodsCategory}
items={goodsCategories.map((item) => ({
key: item.key,
label: `${item.label} (${item.count})`,
}))}
/>
)}
<Table
columns={goodsColumns}
dataSource={filteredGoods}
rowKey="id"
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 }}>
</Text>
</div>
</Card>
pagination={false}
scroll={{ y: 360 }}
locale={{ emptyText: '暂无兑换商品,请先刷新商品列表' }}
onRow={(record) => ({
onClick: () => setSelectedExchangeGoodsId(record.product_id),
})}
rowClassName={(record) => record.product_id === selectedExchangeGoodsId ? 'ant-table-row-selected' : ''}
/>
</Card>
</Col>
<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>
<Col xs={24} xl={12}>
<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: 410 }}
locale={{ emptyText: '暂无充值商品,请先刷新充值商品列表' }}
onRow={(record) => ({
onClick: () => setSelectedRechargeGoodsId(record.spu_id),
})}
rowClassName={(record) => record.spu_id === selectedRechargeGoodsId ? 'ant-table-row-selected' : ''}
/>
</Card>
</Col>
</Row>
),
},
{
key: 'config',
label: <Space><SettingOutlined /></Space>,
children: (
<Card
size="small"
title={<Space><SettingOutlined /></Space>}
extra={canConfig && (
<Button size="small" type="primary" onClick={saveConfig} loading={savingConfig}>
</Button>
)}
style={{ maxWidth: 760 }}
>
<Form form={form} layout="vertical" disabled={!canConfig}>
<Row gutter={8}>
<Col xs={24} sm={12}>
<Form.Item label="直播间 ID" name="room_pid">
<Input placeholder="默认 1199650619883" />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item label="SID" name="sid">
<Input placeholder="默认 2203" />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item label="兑换活动 ID" name="outer_act_id">
<Input placeholder="默认 9504" />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item label="绑定 bActId" name="bind_act_id">
<Input placeholder="默认 9271" />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item label="支付渠道" name="pay_channel">
<Select
options={[
{ value: 'Weixin', label: '微信' },
{ value: 'Zfb', label: '支付宝' },
]}
/>
</Form.Item>
</Col>
</Row>
</Form>
</Card>
),
},
{
key: 'records',
label: <Space><FieldTimeOutlined /></Space>,
children: (
<>
{taskSummary}
{renderTaskTable(20)}
</>
),
},
]}
/>
</div>
<RealtimeLogPanel
logs={logs}
connected={wsConnected}
title="虎牙实时日志"
emptyText="暂无虎牙任务日志"
collapsible
spinWhenEmpty
style={{ marginTop: 4 }}
/>
<Modal
title="兑换记录"
open={!!exchangeRecordsTask}
+1 -1
View File
@@ -50,7 +50,7 @@ export default function LoginPage({ onLogin }: { onLogin?: () => void }) {
}}>
<Card style={{ width: 380, boxShadow: isDark ? '0 8px 24px rgba(0,0,0,0.4)' : '0 8px 24px rgba(0,0,0,0.15)' }}>
<Title level={3} style={{ textAlign: 'center', marginBottom: 32 }}>
</Title>
<Text type="secondary" style={{
display: 'block',