Files
live-hub-py/web/frontend/src/pages/YybRechargePage.tsx
T
2026-08-12 22:23:59 +08:00

613 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, Button, Card, Col, Collapse, Descriptions, Empty, Image, List, Modal, Popconfirm,
Radio, Row, Select, Space, Spin, Tag, Typography, message,
} from 'antd';
import {
CheckCircleOutlined, QqOutlined, ReloadOutlined, ShoppingCartOutlined, WechatOutlined, StopOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { yybApi } from '../api/modules';
import type { YybSelectionOptions, YybTask } from '../api/types';
import { getUser } from '../store/auth';
import { usePermissions } from '../hooks/usePermissions';
const { Title, Text } = Typography;
const STATUS_META: Record<string, { label: string; color: string }> = {
created: { label: '待登录', color: 'default' },
waiting_login: { label: '等待扫码', color: 'processing' },
ready: { label: '待操作', color: 'gold' },
running: { label: '处理中', color: 'processing' },
ordering: { label: '下单中', color: 'processing' },
waiting_payment: { label: '待微信付款', color: 'blue' },
payment_timeout: { label: '确认超时', color: 'warning' },
success: { label: '已确认到账', color: 'success' },
failed: { label: '失败', color: 'error' },
stopped: { label: '已停止', color: 'default' },
};
const TERMINAL_STATUSES = ['success', 'failed', 'stopped'];
const RECENT_TASK_LIMIT = 6;
const fmtTime = (value?: string | null) => (value ? dayjs(value).format('MM-DD HH:mm') : '-');
const yuan = (fen?: number | null) => ${((fen ?? 0) / 100).toFixed(2)}`;
function OrderSummary({ task }: { task: YybTask }) {
return (
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="平台">{task.platform === 'ios' ? 'iOS 区' : 'Android 区'}</Descriptions.Item>
<Descriptions.Item label="商品">
{task.points ? `${task.points} 点券` : '-'}
{task.price_fen ? <Text type="danger">{yuan(task.price_fen)}</Text> : null}
</Descriptions.Item>
<Descriptions.Item label="区服">{task.zone_name || '-'}</Descriptions.Item>
<Descriptions.Item label="角色">{task.role_name ? `${task.role_name}${task.role_id}` : '-'}</Descriptions.Item>
<Descriptions.Item label="金额">{task.price_fen ? yuan(task.price_fen) : '-'}</Descriptions.Item>
</Descriptions>
);
}
function PaymentQrBox({ qrData, mimeType, loading }: {
qrData?: string | null;
mimeType?: string | null;
loading: boolean;
}) {
return (
<div style={{
width: 280, height: 280, border: '2px dashed #d9d9d9', borderRadius: 8,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: '#fafafa', overflow: 'hidden',
}}>
{qrData ? (
<Image
width={260}
preview
src={`data:${mimeType || 'image/png'};base64,${qrData}`}
/>
) : loading ? (
<Space direction="vertical" size={12} align="center">
<Spin size="large" />
<Text type="secondary">付款码生成中...</Text>
</Space>
) : (
<Text type="secondary">微信付款码</Text>
)}
</div>
);
}
export default function YybRechargePage() {
const [task, setTask] = useState<YybTask | null>(null);
const [recentTasks, setRecentTasks] = useState<YybTask[]>([]);
const [historyTasks, setHistoryTasks] = useState<YybTask[]>([]);
const [options, setOptions] = useState<YybSelectionOptions | null>(null);
const [platform, setPlatform] = useState<'android' | 'ios'>('android');
const [points, setPoints] = useState<number>();
const [zoneId, setZoneId] = useState<string>();
const [roleId, setRoleId] = useState<string>();
const [loading, setLoading] = useState(false);
const [optionsLoading, setOptionsLoading] = useState(false);
const [logsOpen, setLogsOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
const { can } = usePermissions(getUser());
const optionsSeq = useRef(0);
const taskIdRef = useRef<number | null>(null);
useEffect(() => { taskIdRef.current = task?.id ?? null; }, [task?.id]);
const loadRecent = useCallback(async () => {
try { setRecentTasks(await yybApi.listTasks({ scope: 'mine', limit: RECENT_TASK_LIMIT })); }
catch (error) { message.error(error instanceof Error ? error.message : '加载任务列表失败'); }
}, []);
const openHistory = async () => {
setHistoryOpen(true);
setHistoryLoading(true);
try {
setHistoryTasks(await yybApi.listTasks({
scope: can('yyb:history') ? 'all' : 'mine',
limit: 200,
}));
} catch (error) {
message.error(error instanceof Error ? error.message : '加载任务列表失败');
} finally {
setHistoryLoading(false);
}
};
const refresh = useCallback(async () => {
const id = taskIdRef.current;
if (id == null) return;
try { setTask(await yybApi.getTask(id)); } catch { /* 保留当前状态 */ }
}, []);
const currentStatus = task?.status;
const currentPhase = task?.phase;
useEffect(() => {
if (currentStatus === undefined || TERMINAL_STATUSES.includes(currentStatus)) return;
const timer = window.setInterval(() => void refresh(), 2500);
return () => window.clearInterval(timer);
}, [task?.id, currentStatus, refresh]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const tasks = await yybApi.listTasks({ scope: 'mine', limit: RECENT_TASK_LIMIT });
if (cancelled) return;
setRecentTasks(tasks);
const active = tasks.find(item => !TERMINAL_STATUSES.includes(item.status));
if (active) {
const synced = await yybApi.getTask(active.id);
if (!cancelled) setTask(synced);
}
} catch { /* 忽略列表加载失败 */ }
})();
return () => { cancelled = true; };
}, []);
const loadOptions = useCallback(async (nextPlatform: 'android' | 'ios', nextPoints?: number, nextZone?: string) => {
const id = taskIdRef.current;
if (id == null) return;
const seq = ++optionsSeq.current;
setOptionsLoading(true);
try {
const data = await yybApi.options(id, nextPlatform, nextPoints, nextZone);
if (seq !== optionsSeq.current) return;
setOptions(data);
if (nextPoints === undefined && data.default_product) setPoints(data.default_product.points);
if (nextZone === undefined && data.default_zone) setZoneId(data.default_zone.zone_id);
const firstRole = data.roles.filter(item => item.ban_status !== '1')[0];
setRoleId(firstRole?.role_id);
} catch (error) { message.error(error instanceof Error ? error.message : '查询商品失败'); }
finally { if (seq === optionsSeq.current) setOptionsLoading(false); }
}, []);
useEffect(() => {
if (currentStatus === 'ready' && currentPhase === 'selection' && !options) {
void loadOptions(platform);
}
}, [currentStatus, currentPhase, options, platform, loadOptions]);
const selectedProduct = useMemo(() => options?.products.find(item => item.points === points), [options, points]);
const selectedZone = useMemo(() => options?.zones.find(item => item.zone_id === zoneId), [options, zoneId]);
const selectedRole = useMemo(() => options?.roles.filter(item => item.ban_status !== '1').find(item => item.role_id === roleId), [options, roleId]);
const openTask = async (id: number) => {
setLoading(true);
try {
const synced = await yybApi.getTask(id);
setTask(synced);
setOptions(null);
setRoleId(undefined);
setLogsOpen(false);
if (synced.phase === 'selection') setPlatform(synced.platform || 'android');
} catch (error) { message.error(error instanceof Error ? error.message : '加载任务失败'); }
finally { setLoading(false); }
};
const createTask = async () => {
setLoading(true);
try { setTask(await yybApi.createTask()); setOptions(null); setRoleId(undefined); }
catch (error) { message.error(error instanceof Error ? error.message : '创建任务失败'); }
finally { setLoading(false); }
};
const startLogin = async (provider: 'qq' | 'wechat') => {
setLoading(true);
try {
let current = task;
if (!current) {
current = await yybApi.createTask();
setTask(current);
setOptions(null);
setRoleId(undefined);
}
setTask(await yybApi.login(current.id, provider));
} catch (error) { message.error(error instanceof Error ? error.message : '启动登录失败'); }
finally { setLoading(false); }
};
const confirmSelectionAndCreatePayment = async () => {
if (!task || !selectedProduct || !selectedZone || !selectedRole) return;
const originalTask = task;
setLoading(true);
setTask({
...task,
platform,
points: selectedProduct.points,
price_fen: selectedProduct.price_fen,
zone_id: selectedZone.zone_id,
zone_name: selectedZone.name,
role_id: selectedRole.role_id,
role_name: selectedRole.name,
phase: 'payment',
status: 'ordering',
message: '正在获取微信支付二维码',
});
try {
const selectedTask = await yybApi.select(task.id, {
platform, points: selectedProduct.points, product_id: selectedProduct.product_id,
zone_id: selectedZone.zone_id, zone_name: selectedZone.name,
role_id: selectedRole.role_id, role_name: selectedRole.name,
});
setTask({ ...selectedTask, phase: 'payment', status: 'ordering', message: '正在获取微信支付二维码' });
const paymentTask = await yybApi.payment(selectedTask.id);
setTask(paymentTask);
void loadRecent();
} catch (error) {
try { setTask(await yybApi.getTask(task.id)); }
catch { setTask(originalTask); }
message.error(error instanceof Error ? error.message : '保存选择或生成付款码失败');
}
finally { setLoading(false); }
};
const createPayment = async () => {
if (!task) return;
setLoading(true);
try { setTask(await yybApi.payment(task.id)); }
catch (error) { message.error(error instanceof Error ? error.message : '创建付款码失败'); }
finally { setLoading(false); }
};
const reCheck = async () => {
if (!task) return;
setLoading(true);
try { setTask(await yybApi.paymentCheck(task.id)); message.success('已重新检测到账'); }
catch (error) { message.error(error instanceof Error ? error.message : '检测到账失败'); }
finally { setLoading(false); }
};
const stopTask = async () => {
if (!task) return;
setLoading(true);
try { setTask(await yybApi.stop(task.id)); }
catch (error) { message.error(error instanceof Error ? error.message : '停止任务失败'); }
finally { setLoading(false); }
};
const status = task?.status ?? '';
const statusMeta = STATUS_META[status] ?? { label: status, color: 'default' };
const logs = task?.result?.logs ?? [];
const showLogs = logs.length > 0;
const canStop = task && !['waiting_payment', 'payment_timeout', 'success'].includes(status);
const renderLoginCard = () => {
if (!task) return null;
const started = status !== 'created';
return (
<Card title="扫码登录" style={!started ? { minHeight: 330 } : undefined}>
{!started ? (
<Space direction="vertical" size={24} style={{ width: '100%', maxWidth: 600, margin: '36px auto 0', display: 'flex', alignItems: 'center' }}>
<Space direction="vertical" size={6} align="center">
<Title level={4} style={{ margin: 0 }}>选择登录方式</Title>
<Text type="secondary">登录后可选择区服、角色和点券档位,再获取微信支付二维码。</Text>
</Space>
<Row gutter={[16, 16]} style={{ width: '100%' }}>
<Col xs={24} sm={12}>
<Button block size="large" icon={<QqOutlined />} onClick={() => void startLogin('qq')} loading={loading} style={{ height: 72, fontSize: 17 }}>
QQ 登录
</Button>
</Col>
<Col xs={24} sm={12}>
<Button block size="large" icon={<WechatOutlined />} onClick={() => void startLogin('wechat')} loading={loading} style={{ height: 72, fontSize: 17 }}>
微信登录
</Button>
</Col>
</Row>
</Space>
) : (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Radio.Group value={task.provider || undefined} disabled onChange={event => void startLogin(event.target.value)}>
<Radio.Button value="qq">QQ 登录</Radio.Button>
<Radio.Button value="wechat"><WechatOutlined /> 微信登录</Radio.Button>
</Radio.Group>
{task.login_qr_data && (
<Space direction="vertical" size={4}>
<Image width={240} preview src={`data:${task.login_qr_mime_type || 'image/jpeg'};base64,${task.login_qr_data}`} />
<Text type="secondary">请使用{task.provider === 'qq' ? '手机 QQ' : '微信'}扫码并确认登录</Text>
</Space>
)}
<Text type="secondary">{task.message}</Text>
{started && task.status !== 'failed' && (
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void startLogin(task.provider as 'qq' | 'wechat')} loading={loading}>刷新二维码</Button>
{canStop && <Popconfirm title="确定停止该任务?" onConfirm={stopTask}><Button danger icon={<StopOutlined />} loading={loading}>停止任务</Button></Popconfirm>}
</Space>
)}
</Space>
)}
</Card>
);
};
const renderSelectionCard = () => {
if (!task) return null;
return (
<Card title="选择充值信息">
<Space direction="vertical" size={14} style={{ width: '100%' }}>
{optionsLoading && options && (
<Space size={8}>
<Spin size="small" />
<Text type="secondary">正在更新可用角色...</Text>
</Space>
)}
<Radio.Group value={platform} onChange={event => { const value = event.target.value as 'android' | 'ios'; setPlatform(value); void loadOptions(value); }}>
<Radio.Button value="android">Android </Radio.Button>
<Radio.Button value="ios">iOS </Radio.Button>
</Radio.Group>
{options ? (
<>
<div>
<Text strong>点券档位</Text>
<div style={{ marginTop: 8 }}>
<Radio.Group
value={points}
onChange={event => { const value = event.target.value as number; setPoints(value); void loadOptions(platform, value, zoneId); }}
>
<Space direction="vertical" size={6}>
{options.products.map(item => (
<Radio key={item.product_id} value={item.points}>
<span style={{ display: 'inline-block', minWidth: 110 }}>{item.points} 点券</span>
<Text type="danger">{yuan(item.price_fen)}</Text>
</Radio>
))}
</Space>
</Radio.Group>
</div>
</div>
<div style={{ maxWidth: 360 }}>
<Text strong>区服</Text>
<Select
style={{ width: '100%', marginTop: 8 }}
placeholder="选择区服"
value={zoneId}
options={options.zones.map(item => ({ value: item.zone_id, label: `${item.name}${item.zone_id}` }))}
onChange={value => { setZoneId(value); void loadOptions(platform, points, value); }}
/>
</div>
<div>
<Text strong>角色</Text>
<div style={{ marginTop: 8 }}>
{selectedRole ? (
<Text>{selectedRole.name}{selectedRole.role_id}</Text>
) : <Text type="secondary">当前区服暂无可用角色</Text>}
</div>
</div>
<Button type="primary" icon={<WechatOutlined />} disabled={optionsLoading || !selectedProduct || !selectedZone || !selectedRole} onClick={() => void confirmSelectionAndCreatePayment()} loading={loading}>
确认充值信息并获取微信支付二维码
</Button>
</>
) : <Spin tip="正在查询商品信息..." />}
</Space>
</Card>
);
};
const renderPaymentCard = () => {
if (!task) return null;
const terminal = task.status === 'failed' || task.status === 'stopped';
return (
<Card title="微信付款">
<Space direction="vertical" size={14} style={{ width: '100%' }}>
<OrderSummary task={task} />
{status === 'ready' && (
<>
<Alert type="info" showIcon message={`确认本次充值 ${task.points} 点券(${yuan(task.price_fen)}),角色与区服见上方摘要`} />
<Button type="primary" icon={<WechatOutlined />} onClick={() => void createPayment()} disabled={!task.price_fen} loading={loading}>
生成微信付款码
</Button>
</>
)}
{['running', 'ordering'].includes(status) && (
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<PaymentQrBox loading />
<Alert type="info" showIcon
message="正在获取微信支付二维码,请耐心等待"
description="订单创建和二维码生成通常需要几十秒,请勿关闭或刷新页面,生成后会自动显示。" />
{showLogs && (
<Collapse
activeKey={logsOpen ? ['logs'] : undefined}
onChange={keys => setLogsOpen(Array.isArray(keys) && keys.includes('logs'))}
items={[{
key: 'logs',
label: <Text strong>执行记录</Text>,
children: (
<div style={{ maxHeight: 200, overflow: 'auto', fontSize: 12, fontFamily: 'monospace' }}>
{logs.map((line, index) => <div key={index} style={{ whiteSpace: 'pre-wrap' }}>{line}</div>)}
</div>
),
}]}
/>
)}
</Space>
)}
{status === 'waiting_payment' && (
<Space direction="vertical" size={10}>
<PaymentQrBox qrData={task.payment_qr_data} mimeType={task.payment_qr_mime_type} loading={!task.payment_qr_data} />
{task.payment_qr_data && <Tag color="blue">请使用微信扫码付款</Tag>}
{task.payment_qr_data && <Text type="secondary">生成于 {fmtTime(task.payment_qr_created_at)} · 最近检测 {fmtTime(task.payment_last_checked_at)}</Text>}
<Text type="secondary">{task.message}</Text>
<Popconfirm title="确定放弃到账追踪?商城订单仍可能完成,请自行核对。" onConfirm={stopTask}>
<Button danger icon={<StopOutlined />} loading={loading}>放弃追踪</Button>
</Popconfirm>
</Space>
)}
{status === 'payment_timeout' && (
<Space direction="vertical" size={10}>
<Alert type="warning" showIcon
message="付款码已生成,但未在时限内确认到账"
description="订单可能已支付或尚未支付。可重新检测到账(不会重复下单),或在执行记录中核对订单状态。" />
{task.payment_qr_data && (
<PaymentQrBox qrData={task.payment_qr_data} mimeType={task.payment_qr_mime_type} loading={false} />
)}
<Space>
<Button type="primary" onClick={() => void reCheck()} loading={loading}>重新检测到账</Button>
<Button onClick={() => setLogsOpen(true)}>查看脱敏执行记录</Button>
<Popconfirm title="确定放弃到账追踪?商城订单仍可能完成,请自行核对。" onConfirm={stopTask}>
<Button danger icon={<StopOutlined />} loading={loading}>放弃追踪</Button>
</Popconfirm>
</Space>
</Space>
)}
{terminal && <Alert type={status === 'failed' ? 'error' : 'info'} showIcon message={task.message || '任务已结束'} />}
</Space>
</Card>
);
};
const renderCompletedCard = () => (
<Alert type="success" showIcon icon={<CheckCircleOutlined />}
message="充值订单已确认到账"
description={task?.message} />
);
const renderMainCard = () => {
if (!task) return null;
if (task.phase === 'login') return renderLoginCard();
if (task.phase === 'selection') return renderSelectionCard();
if (task.phase === 'payment' || ['running', 'ordering', 'waiting_payment', 'payment_timeout'].includes(task.status)) return renderPaymentCard();
if (task.phase === 'completed') return renderCompletedCard();
return <Card><Text type="secondary">{task.message}</Text></Card>;
};
return (
<Row gutter={[16, 16]}>
<Col xs={24} lg={16}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Card size="small">
<Row justify="space-between" align="middle" gutter={[12, 8]}>
<Col>
<Space wrap>
<Title level={4} style={{ margin: 0 }}>应用宝和平精英充值</Title>
{task && <Tag color={statusMeta.color}>{statusMeta.label}</Tag>}
</Space>
</Col>
<Col>
{task && (
<Button icon={<ShoppingCartOutlined />} onClick={() => void createTask()} loading={loading}>
新建充值任务
</Button>
)}
</Col>
</Row>
{task && (
<div style={{ marginTop: 8 }}>
<Text type="secondary">
创建人:{task.created_by_username || `#${task.created_by}`}
{task.created_at ? ` · 创建于 ${fmtTime(task.created_at)}` : ''}
{task.finished_at ? ` · 结束于 ${fmtTime(task.finished_at)}` : ''}
</Text>
</div>
)}
</Card>
{!task && (
<Card title="扫码登录">
<Space direction="vertical" size={12}>
<Text>选择登录方式,点击后自动创建充值任务并生成二维码。</Text>
<Space size={12}>
<Button size="large" icon={<QqOutlined />} onClick={() => void startLogin('qq')} loading={loading}>QQ 登录</Button>
<Button size="large" icon={<WechatOutlined />} onClick={() => void startLogin('wechat')} loading={loading}>微信登录</Button>
</Space>
</Space>
</Card>
)}
{task && renderMainCard()}
</Space>
</Col>
<Col xs={24} lg={8}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Card title="最近任务" size="small" extra={<Button type="link" size="small" onClick={() => void openHistory()}>全部任务</Button>}>
{recentTasks.length === 0 ? <Empty description="暂无任务" image={Empty.PRESENTED_IMAGE_SIMPLE} /> : (
<List
size="small"
dataSource={recentTasks}
renderItem={item => (
<List.Item
style={{ cursor: 'pointer', paddingLeft: 4 }}
onClick={() => void openTask(item.id)}
>
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<Space wrap size={6}>
<Tag color={STATUS_META[item.status]?.color ?? 'default'} style={{ marginInlineEnd: 0 }}>{STATUS_META[item.status]?.label ?? item.status}</Tag>
{item.points ? <Text>{item.points} 点券</Text> : null}
{item.price_fen ? <Text type="danger">{yuan(item.price_fen)}</Text> : null}
{item.role_name ? <Text type="secondary">{item.role_name}</Text> : null}
</Space>
<Text type="secondary" style={{ fontSize: 12 }}>
{item.created_by_username || `#${item.created_by}`} · {fmtTime(item.created_at)}
</Text>
</Space>
</List.Item>
)}
/>
)}
</Card>
{task && (task.points || task.price_fen || task.zone_name || task.role_name) && (
<Card title="订单摘要" size="small">
<OrderSummary task={task} />
</Card>
)}
{showLogs && (
<Collapse
ghost
activeKey={logsOpen ? ['logs'] : undefined}
onChange={keys => setLogsOpen(Array.isArray(keys) && keys.includes('logs'))}
items={[{
key: 'logs',
label: <Text strong>执行记录{task?.status === 'failed' ? '(失败原因)' : ''}</Text>,
children: (
<div style={{ maxHeight: 280, overflow: 'auto', fontSize: 12, fontFamily: 'monospace' }}>
{logs.map((line, index) => <div key={index} style={{ whiteSpace: 'pre-wrap' }}>{line}</div>)}
</div>
),
}]}
/>
)}
</Space>
</Col>
<Modal
title={can('yyb:history') ? '全部充值任务' : '我的全部充值任务'}
open={historyOpen}
footer={null}
onCancel={() => setHistoryOpen(false)}
width={680}
>
<List
loading={historyLoading}
locale={{ emptyText: '暂无任务' }}
dataSource={historyTasks}
pagination={{
pageSize: 10,
size: 'small',
showSizeChanger: false,
showTotal: total => `共 ${total} 条`,
}}
renderItem={item => (
<List.Item
style={{ cursor: 'pointer', paddingInline: 4 }}
onClick={() => { setHistoryOpen(false); void openTask(item.id); }}
>
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<Space wrap size={6}>
<Tag color={STATUS_META[item.status]?.color ?? 'default'} style={{ marginInlineEnd: 0 }}>{STATUS_META[item.status]?.label ?? item.status}</Tag>
{item.points ? <Text>{item.points} 点券</Text> : null}
{item.price_fen ? <Text type="danger">{yuan(item.price_fen)}</Text> : null}
{item.role_name ? <Text type="secondary">{item.role_name}</Text> : null}
</Space>
<Text type="secondary" style={{ fontSize: 12 }}>
{item.created_by_username || `#${item.created_by}`} · {fmtTime(item.created_at)}
</Text>
</Space>
</List.Item>
)}
/>
</Modal>
</Row>
);
}