613 lines
27 KiB
TypeScript
613 lines
27 KiB
TypeScript
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>
|
||
);
|
||
}
|