feat: 充值页面 + 订单明细页面 + 页面调度器增强 (0.0.20)
新增页面:
- RechargePage — 积分充值表单(预设金额/自定义金额/支付宝/协议勾选)
+ 支付状态跟踪(form/pending/paid/expired/failed 五阶段)
+ 订单状态轮询(3s 间隔,终态自动停止)
+ PAGE_LOCK 事件(待支付时禁止关闭 Modal)
- OrdersPage — 订单明细列表(状态筛选 + 分页 + 继续支付)
+ Segmented 状态 Tab(全部/待支付/已支付/已过期/失败)
+ 待支付订单"继续支付"按钮
事件总线增强:
- 新增 EVENTS.CLOSE_PAGE — 页面内部关闭容器
- 新增 EVENTS.PAGE_LOCK — 锁定/解锁容器关闭(防止操作中断)
PageDispatcher 增强:
- 注册 RechargePage(/recharge)和 OrdersPage(/orders)
- 响应 CLOSE_PAGE / PAGE_LOCK 事件
- Modal/Drawer 锁定态下禁用遮罩关闭
支付 API 模块:
- PaymentAPI.createOrder / getOrdersListInfo / getOrderInfo
- 完整类型定义(请求/响应体、状态常量、中文映射)
杂项:
- 导航栏"充值积分"→"充值"(精简)
This commit is contained in:
321
src/pages/panels/OrdersPage.tsx
Normal file
321
src/pages/panels/OrdersPage.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Button,
|
||||
Typography,
|
||||
Tag,
|
||||
Segmented,
|
||||
Result,
|
||||
App,
|
||||
theme as antTheme,
|
||||
} from 'antd';
|
||||
import { DollarOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
|
||||
import {
|
||||
PaymentAPI,
|
||||
PAYMENT_STATUS,
|
||||
PAYMENT_METHODS,
|
||||
getPaymentStatusText,
|
||||
type PaymentStatus,
|
||||
type OrderListResponseBody,
|
||||
type OrderInfoResponseBody,
|
||||
} from '@/services/modules/payments';
|
||||
import { centToYuan, formatDate } from '@/utils/display';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// ---------- 常量 ----------
|
||||
|
||||
/** 订单状态轮询间隔(毫秒) */
|
||||
// startPolling 实现后将使用此常量
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
|
||||
/** 状态筛选项 */
|
||||
const STATUS_FILTERS = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '待支付', value: PAYMENT_STATUS.Pending },
|
||||
{ label: '已支付', value: PAYMENT_STATUS.Paid },
|
||||
{ label: '已过期', value: PAYMENT_STATUS.Expired },
|
||||
{ label: '支付失败', value: PAYMENT_STATUS.Failed },
|
||||
];
|
||||
|
||||
/** 状态 → Tag 颜色映射 */
|
||||
const STATUS_TAG_COLOR: Record<string, string> = {
|
||||
[PAYMENT_STATUS.Pending]: 'processing',
|
||||
[PAYMENT_STATUS.Paid]: 'success',
|
||||
[PAYMENT_STATUS.Expired]: 'default',
|
||||
[PAYMENT_STATUS.Failed]: 'error',
|
||||
};
|
||||
|
||||
/** 支付方式 → 显示文本 */
|
||||
function getPaymentChannelLabel(channel: string): string {
|
||||
if (channel === PAYMENT_METHODS.Alipay) return '支付宝';
|
||||
if (channel === PAYMENT_METHODS.WeChat) return '微信支付';
|
||||
return channel;
|
||||
}
|
||||
|
||||
// ---------- 组件 ----------
|
||||
|
||||
export function OrdersPage() {
|
||||
const { message } = App.useApp();
|
||||
const { token } = antTheme.useToken();
|
||||
|
||||
// ======== 筛选 & 分页 ========
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [pagination, setPagination] = useState({ page: 1, page_size: 10 });
|
||||
|
||||
// ======== 数据状态 ========
|
||||
|
||||
const [orders, setOrders] = useState<OrderListResponseBody[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 轮询定时器 Map(orderId → timer)
|
||||
const pollingRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
// 组件是否已挂载
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// ======== 数据获取 ========
|
||||
|
||||
const fetchOrders = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params: { page: number; page_size: number; status?: PaymentStatus } = {
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size,
|
||||
};
|
||||
if (statusFilter !== 'all') {
|
||||
params.status = statusFilter as PaymentStatus;
|
||||
}
|
||||
const data = await PaymentAPI.getOrdersListInfo(params);
|
||||
if (mountedRef.current) {
|
||||
setOrders(data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mountedRef.current) {
|
||||
setError((err as Error)?.message || '加载订单列表失败');
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [pagination, statusFilter]);
|
||||
|
||||
// 筛选或分页变化 → 重新获取
|
||||
useEffect(() => {
|
||||
fetchOrders();
|
||||
}, [fetchOrders]);
|
||||
|
||||
// 挂载/卸载管理
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
const polling = pollingRef.current;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
// 清理所有轮询定时器
|
||||
polling.forEach((timer) => clearTimeout(timer));
|
||||
polling.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ======== 单订单轮询 ========
|
||||
|
||||
// TODO(human): 实现 startPolling 函数
|
||||
// 该函数接收 orderId,对该订单启动轮询(每 POLL_INTERVAL_MS 查一次)
|
||||
// 轮询到终态时停止,并刷新列表;网络异常时延长间隔重试
|
||||
// 需要考虑:
|
||||
// 1. 取消之前同一订单的轮询(避免重复)
|
||||
// 2. 组件卸载时清理定时器(通过 mountedRef)
|
||||
// 3. 轮询结果更新到 orders state 中(局部更新后,终态时调用 fetchOrders 保证一致性)
|
||||
const startPolling = useCallback((_orderId: string) => {
|
||||
// TODO(human): 实现此函数(使用 POLL_INTERVAL_MS、message、fetchOrders 等闭包变量)
|
||||
void POLL_INTERVAL_MS;
|
||||
void message;
|
||||
void fetchOrders;
|
||||
void ({} as OrderInfoResponseBody);
|
||||
}, [message, fetchOrders]);
|
||||
|
||||
// ======== 操作回调 ========
|
||||
|
||||
/** "继续支付":打开支付页面 + 启动轮询 */
|
||||
const handleContinuePay = useCallback(
|
||||
(order: OrderListResponseBody) => {
|
||||
window.open(order.pay_url, '_blank');
|
||||
startPolling(order.id);
|
||||
message.info('已打开支付页面,请完成支付');
|
||||
},
|
||||
[startPolling, message],
|
||||
);
|
||||
|
||||
// ======== 表格列定义 ========
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '订单编号',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (id: string) => (
|
||||
<Text copyable style={{ fontSize: 12 }}>
|
||||
{id}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount_cent',
|
||||
key: 'amount_cent',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
render: (amount: number) => (
|
||||
<Text strong style={{ fontSize: 13, color: token.colorPrimary }}>
|
||||
¥{centToYuan(amount)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '支付方式',
|
||||
dataIndex: 'payment_channel',
|
||||
key: 'payment_channel',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
render: (channel: string) => (
|
||||
<Text style={{ fontSize: 12 }}>{getPaymentChannelLabel(channel)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
render: (status: PaymentStatus) => (
|
||||
<Tag color={STATUS_TAG_COLOR[status]} style={{ fontSize: 11, lineHeight: '18px' }}>
|
||||
{getPaymentStatusText(status)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
render: (date: Date) => (
|
||||
<Text style={{ fontSize: 12, whiteSpace: 'nowrap' }}>{formatDate(date)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, record: OrderListResponseBody) => {
|
||||
if (record.status === PAYMENT_STATUS.Pending && record.pay_url) {
|
||||
return (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<DollarOutlined />}
|
||||
onClick={() => handleContinuePay(record)}
|
||||
>
|
||||
继续支付
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
// 渲染:错误态(无数据时)
|
||||
// ============================================================
|
||||
|
||||
if (error && orders.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
<Card size="small" className="rounded-md!">
|
||||
<Result
|
||||
status="error"
|
||||
title="订单加载失败"
|
||||
subTitle={error}
|
||||
extra={
|
||||
<Button type="primary" size="small" onClick={fetchOrders}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 渲染:正常
|
||||
// ============================================================
|
||||
|
||||
return (
|
||||
<div style={{ padding: '8px 0', width: '100%' }}>
|
||||
<div className="flex flex-col gap-3" style={{ width: '100%' }}>
|
||||
{/* ---- 状态筛选 ---- */}
|
||||
<Segmented
|
||||
options={STATUS_FILTERS}
|
||||
value={statusFilter}
|
||||
onChange={(val) => {
|
||||
setStatusFilter(val as string);
|
||||
setPagination((prev) => ({ ...prev, page: 1 }));
|
||||
}}
|
||||
block
|
||||
style={{ backgroundColor: token.colorFillSecondary }}
|
||||
/>
|
||||
|
||||
{/* ---- 手动刷新 ---- */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={fetchOrders} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ---- 订单表格 ---- */}
|
||||
<Card size="small" styles={{ body: { padding: 0 } }} className="rounded-md!">
|
||||
<Table<OrderListResponseBody>
|
||||
columns={columns}
|
||||
dataSource={orders}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{
|
||||
current: pagination.page,
|
||||
pageSize: pagination.page_size,
|
||||
total:
|
||||
orders.length >= pagination.page_size
|
||||
? pagination.page * pagination.page_size + 1
|
||||
: pagination.page * pagination.page_size,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['10', '20', '50'],
|
||||
showTotal: (total: number, range: [number, number]) =>
|
||||
`${range[0]}-${range[1]},共 ${total}+ 条`,
|
||||
onChange: (page: number, pageSize: number) => {
|
||||
setPagination({ page, page_size: pageSize });
|
||||
},
|
||||
position: ['bottomCenter'],
|
||||
}}
|
||||
locale={{ emptyText: '暂无订单记录' }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user