优化应用宝充值界面

This commit is contained in:
yml2213
2026-08-12 22:23:59 +08:00
parent 8e4e394c4b
commit f1a22e87e9
2 changed files with 165 additions and 47 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ export const yybApi = {
login: (id: number, provider: 'qq' | 'wechat') => login: (id: number, provider: 'qq' | 'wechat') =>
api.post<YybTask, YybTask>(`/yyb/tasks/${id}/login`, { provider, timeout: 600 }), api.post<YybTask, YybTask>(`/yyb/tasks/${id}/login`, { provider, timeout: 600 }),
getTask: (id: number) => api.get<YybTask, YybTask>(`/yyb/tasks/${id}`), getTask: (id: number) => api.get<YybTask, YybTask>(`/yyb/tasks/${id}`),
listTasks: (params?: { scope?: 'mine' | 'all'; status?: string }) => listTasks: (params?: { scope?: 'mine' | 'all'; status?: string; limit?: number }) =>
api.get<YybTask[], YybTask[]>('/yyb/tasks', { params }), api.get<YybTask[], YybTask[]>('/yyb/tasks', { params }),
options: (id: number, platform: 'android' | 'ios', points?: number, zone_id?: string) => options: (id: number, platform: 'android' | 'ios', points?: number, zone_id?: string) =>
api.get<YybSelectionOptions, YybSelectionOptions>(`/yyb/tasks/${id}/selection-options`, { api.get<YybSelectionOptions, YybSelectionOptions>(`/yyb/tasks/${id}/selection-options`, {
+164 -46
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Alert, Button, Card, Col, Collapse, Descriptions, Empty, Image, List, Popconfirm, Alert, Button, Card, Col, Collapse, Descriptions, Empty, Image, List, Modal, Popconfirm,
Radio, Row, Select, Space, Spin, Tag, Typography, message, Radio, Row, Select, Space, Spin, Tag, Typography, message,
} from 'antd'; } from 'antd';
import { import {
@@ -28,6 +28,7 @@ const STATUS_META: Record<string, { label: string; color: string }> = {
}; };
const TERMINAL_STATUSES = ['success', 'failed', 'stopped']; 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 fmtTime = (value?: string | null) => (value ? dayjs(value).format('MM-DD HH:mm') : '-');
const yuan = (fen?: number | null) => `¥${((fen ?? 0) / 100).toFixed(2)}`; const yuan = (fen?: number | null) => `¥${((fen ?? 0) / 100).toFixed(2)}`;
@@ -47,9 +48,39 @@ function OrderSummary({ task }: { task: YybTask }) {
); );
} }
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() { export default function YybRechargePage() {
const [task, setTask] = useState<YybTask | null>(null); const [task, setTask] = useState<YybTask | null>(null);
const [recentTasks, setRecentTasks] = useState<YybTask[]>([]); const [recentTasks, setRecentTasks] = useState<YybTask[]>([]);
const [historyTasks, setHistoryTasks] = useState<YybTask[]>([]);
const [options, setOptions] = useState<YybSelectionOptions | null>(null); const [options, setOptions] = useState<YybSelectionOptions | null>(null);
const [platform, setPlatform] = useState<'android' | 'ios'>('android'); const [platform, setPlatform] = useState<'android' | 'ios'>('android');
const [points, setPoints] = useState<number>(); const [points, setPoints] = useState<number>();
@@ -58,17 +89,34 @@ export default function YybRechargePage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [optionsLoading, setOptionsLoading] = useState(false); const [optionsLoading, setOptionsLoading] = useState(false);
const [logsOpen, setLogsOpen] = useState(false); const [logsOpen, setLogsOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
const { can } = usePermissions(getUser()); const { can } = usePermissions(getUser());
const optionsSeq = useRef(0); const optionsSeq = useRef(0);
const taskIdRef = useRef<number | null>(null); const taskIdRef = useRef<number | null>(null);
useEffect(() => { taskIdRef.current = task?.id ?? null; }, [task?.id]); useEffect(() => { taskIdRef.current = task?.id ?? null; }, [task?.id]);
const loadRecent = useCallback(async (scope: 'mine' | 'all') => { const loadRecent = useCallback(async () => {
try { setRecentTasks(await yybApi.listTasks({ scope })); } try { setRecentTasks(await yybApi.listTasks({ scope: 'mine', limit: RECENT_TASK_LIMIT })); }
catch (error) { message.error(error instanceof Error ? error.message : '加载任务列表失败'); } 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 refresh = useCallback(async () => {
const id = taskIdRef.current; const id = taskIdRef.current;
if (id == null) return; if (id == null) return;
@@ -88,7 +136,7 @@ export default function YybRechargePage() {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { try {
const tasks = await yybApi.listTasks({ scope: 'mine' }); const tasks = await yybApi.listTasks({ scope: 'mine', limit: RECENT_TASK_LIMIT });
if (cancelled) return; if (cancelled) return;
setRecentTasks(tasks); setRecentTasks(tasks);
const active = tasks.find(item => !TERMINAL_STATUSES.includes(item.status)); const active = tasks.find(item => !TERMINAL_STATUSES.includes(item.status));
@@ -163,17 +211,38 @@ export default function YybRechargePage() {
finally { setLoading(false); } finally { setLoading(false); }
}; };
const submitSelection = async () => { const confirmSelectionAndCreatePayment = async () => {
if (!task || !selectedProduct || !selectedZone || !selectedRole) return; if (!task || !selectedProduct || !selectedZone || !selectedRole) return;
const originalTask = task;
setLoading(true); 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 { try {
setTask(await yybApi.select(task.id, { const selectedTask = await yybApi.select(task.id, {
platform, points: selectedProduct.points, product_id: selectedProduct.product_id, platform, points: selectedProduct.points, product_id: selectedProduct.product_id,
zone_id: selectedZone.zone_id, zone_name: selectedZone.name, zone_id: selectedZone.zone_id, zone_name: selectedZone.name,
role_id: selectedRole.role_id, role_name: selectedRole.name, role_id: selectedRole.role_id, role_name: selectedRole.name,
})); });
message.success('选择已保存'); setTask({ ...selectedTask, phase: 'payment', status: 'ordering', message: '正在获取微信支付二维码' });
} catch (error) { message.error(error instanceof Error ? error.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); } finally { setLoading(false); }
}; };
@@ -211,12 +280,32 @@ export default function YybRechargePage() {
if (!task) return null; if (!task) return null;
const started = status !== 'created'; const started = status !== 'created';
return ( return (
<Card title="扫码登录"> <Card title="扫码登录" style={!started ? { minHeight: 330 } : undefined}>
<Space direction="vertical" size={12} style={{ width: '100%' }}> {!started ? (
<Radio.Group value={task.provider || undefined} disabled={started} onChange={event => void startLogin(event.target.value)}> <Space direction="vertical" size={24} style={{ width: '100%', maxWidth: 600, margin: '36px auto 0', display: 'flex', alignItems: 'center' }}>
<Radio.Button value="qq">QQ </Radio.Button> <Space direction="vertical" size={6} align="center">
<Radio.Button value="wechat"><WechatOutlined /> </Radio.Button> <Title level={4} style={{ margin: 0 }}></Title>
</Radio.Group> <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 && ( {task.login_qr_data && (
<Space direction="vertical" size={4}> <Space direction="vertical" size={4}>
<Image width={240} preview src={`data:${task.login_qr_mime_type || 'image/jpeg'};base64,${task.login_qr_data}`} /> <Image width={240} preview src={`data:${task.login_qr_mime_type || 'image/jpeg'};base64,${task.login_qr_data}`} />
@@ -230,7 +319,8 @@ export default function YybRechargePage() {
{canStop && <Popconfirm title="确定停止该任务?" onConfirm={stopTask}><Button danger icon={<StopOutlined />} loading={loading}></Button></Popconfirm>} {canStop && <Popconfirm title="确定停止该任务?" onConfirm={stopTask}><Button danger icon={<StopOutlined />} loading={loading}></Button></Popconfirm>}
</Space> </Space>
)} )}
</Space> </Space>
)}
</Card> </Card>
); );
}; };
@@ -238,8 +328,14 @@ export default function YybRechargePage() {
const renderSelectionCard = () => { const renderSelectionCard = () => {
if (!task) return null; if (!task) return null;
return ( return (
<Card title="选择充值信息" loading={optionsLoading}> <Card title="选择充值信息">
<Space direction="vertical" size={14} style={{ width: '100%' }}> <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.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="android">Android </Radio.Button>
<Radio.Button value="ios">iOS </Radio.Button> <Radio.Button value="ios">iOS </Radio.Button>
@@ -282,8 +378,8 @@ export default function YybRechargePage() {
) : <Text type="secondary"></Text>} ) : <Text type="secondary"></Text>}
</div> </div>
</div> </div>
<Button type="primary" disabled={!selectedProduct || !selectedZone || !selectedRole} onClick={() => void submitSelection()} loading={loading}> <Button type="primary" icon={<WechatOutlined />} disabled={optionsLoading || !selectedProduct || !selectedZone || !selectedRole} onClick={() => void confirmSelectionAndCreatePayment()} loading={loading}>
</Button> </Button>
</> </>
) : <Spin tip="正在查询商品信息..." />} ) : <Spin tip="正在查询商品信息..." />}
@@ -307,19 +403,12 @@ export default function YybRechargePage() {
</Button> </Button>
</> </>
)} )}
{status === 'ordering' && ( {['running', 'ordering'].includes(status) && (
<Space direction="vertical" size={10} style={{ width: '100%' }}> <Space direction="vertical" size={10} style={{ width: '100%' }}>
<PaymentQrBox loading />
<Alert type="info" showIcon <Alert type="info" showIcon
message="正在生成微信付款码" message="正在获取微信支付二维码,请耐心等待"
description="下单与付款码生成约需 30~60 秒,请勿关闭页面生成后二维码会自动显示。" /> description="订单创建和二维码生成通常需要几十秒,请勿关闭或刷新页面生成后会自动显示。" />
<div style={{
width: 280, height: 280, border: '2px dashed #d9d9d9', borderRadius: 12,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: 12, background: '#fafafa',
}}>
<Spin size="large" />
<Text type="secondary">...</Text>
</div>
{showLogs && ( {showLogs && (
<Collapse <Collapse
activeKey={logsOpen ? ['logs'] : undefined} activeKey={logsOpen ? ['logs'] : undefined}
@@ -339,13 +428,9 @@ export default function YybRechargePage() {
)} )}
{status === 'waiting_payment' && ( {status === 'waiting_payment' && (
<Space direction="vertical" size={10}> <Space direction="vertical" size={10}>
{task.payment_qr_data ? ( <PaymentQrBox qrData={task.payment_qr_data} mimeType={task.payment_qr_mime_type} loading={!task.payment_qr_data} />
<Space direction="vertical" size={4}> {task.payment_qr_data && <Tag color="blue">使</Tag>}
<Image width={280} preview src={`data:${task.payment_qr_mime_type || 'image/png'};base64,${task.payment_qr_data}`} /> {task.payment_qr_data && <Text type="secondary"> {fmtTime(task.payment_qr_created_at)} · {fmtTime(task.payment_last_checked_at)}</Text>}
<Tag color="blue">使</Tag>
<Text type="secondary"> {fmtTime(task.payment_qr_created_at)} · {fmtTime(task.payment_last_checked_at)}</Text>
</Space>
) : <Spin tip="正在生成付款码..." />}
<Text type="secondary">{task.message}</Text> <Text type="secondary">{task.message}</Text>
<Popconfirm title="确定放弃到账追踪?商城订单仍可能完成,请自行核对。" onConfirm={stopTask}> <Popconfirm title="确定放弃到账追踪?商城订单仍可能完成,请自行核对。" onConfirm={stopTask}>
<Button danger icon={<StopOutlined />} loading={loading}></Button> <Button danger icon={<StopOutlined />} loading={loading}></Button>
@@ -358,7 +443,7 @@ export default function YybRechargePage() {
message="付款码已生成,但未在时限内确认到账" message="付款码已生成,但未在时限内确认到账"
description="订单可能已支付或尚未支付。可重新检测到账(不会重复下单),或在执行记录中核对订单状态。" /> description="订单可能已支付或尚未支付。可重新检测到账(不会重复下单),或在执行记录中核对订单状态。" />
{task.payment_qr_data && ( {task.payment_qr_data && (
<Image width={280} preview src={`data:${task.payment_qr_mime_type || 'image/png'};base64,${task.payment_qr_data}`} /> <PaymentQrBox qrData={task.payment_qr_data} mimeType={task.payment_qr_mime_type} loading={false} />
)} )}
<Space> <Space>
<Button type="primary" onClick={() => void reCheck()} loading={loading}></Button> <Button type="primary" onClick={() => void reCheck()} loading={loading}></Button>
@@ -385,7 +470,7 @@ export default function YybRechargePage() {
if (!task) return null; if (!task) return null;
if (task.phase === 'login') return renderLoginCard(); if (task.phase === 'login') return renderLoginCard();
if (task.phase === 'selection') return renderSelectionCard(); if (task.phase === 'selection') return renderSelectionCard();
if (task.phase === 'payment') return renderPaymentCard(); if (task.phase === 'payment' || ['running', 'ordering', 'waiting_payment', 'payment_timeout'].includes(task.status)) return renderPaymentCard();
if (task.phase === 'completed') return renderCompletedCard(); if (task.phase === 'completed') return renderCompletedCard();
return <Card><Text type="secondary">{task.message}</Text></Card>; return <Card><Text type="secondary">{task.message}</Text></Card>;
}; };
@@ -436,15 +521,11 @@ export default function YybRechargePage() {
</Col> </Col>
<Col xs={24} lg={8}> <Col xs={24} lg={8}>
<Space direction="vertical" size={16} style={{ width: '100%' }}> <Space direction="vertical" size={16} style={{ width: '100%' }}>
<Card title="最近任务" size="small" extra={ <Card title="最近任务" size="small" extra={<Button type="link" size="small" onClick={() => void openHistory()}></Button>}>
can('yyb:history') ? (
<Button type="link" size="small" onClick={() => void loadRecent('all')}></Button>
) : undefined
}>
{recentTasks.length === 0 ? <Empty description="暂无任务" image={Empty.PRESENTED_IMAGE_SIMPLE} /> : ( {recentTasks.length === 0 ? <Empty description="暂无任务" image={Empty.PRESENTED_IMAGE_SIMPLE} /> : (
<List <List
size="small" size="small"
dataSource={recentTasks.slice(0, 12)} dataSource={recentTasks}
renderItem={item => ( renderItem={item => (
<List.Item <List.Item
style={{ cursor: 'pointer', paddingLeft: 4 }} style={{ cursor: 'pointer', paddingLeft: 4 }}
@@ -466,7 +547,7 @@ export default function YybRechargePage() {
/> />
)} )}
</Card> </Card>
{task && ( {task && (task.points || task.price_fen || task.zone_name || task.role_name) && (
<Card title="订单摘要" size="small"> <Card title="订单摘要" size="small">
<OrderSummary task={task} /> <OrderSummary task={task} />
</Card> </Card>
@@ -489,6 +570,43 @@ export default function YybRechargePage() {
)} )}
</Space> </Space>
</Col> </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> </Row>
); );
} }