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:
2026-06-15 15:08:05 +08:00
parent 4472d406d9
commit 06e6486aa3
7 changed files with 984 additions and 6 deletions

View 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);
// 轮询定时器 MaporderId → 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>
);
}