feat: VIP会员开通 + 订单分类 + EnumResolver统一枚举
VIP 会员开通页面(VipPage)
- 四部分布局:激活码 / 套餐选择(双列:Radio + 权益图库) / 协议确认 / 支付跟踪
- 支付跟踪:仿照 RechargePage 模式,创建订单后进入待支付阶段,轮询 VipAPI.getVipOrder
- 页面锁定:待支付中禁止关闭容器(PAGE_LOCK),仅可通过 "X" 关闭
- 权益图库:双列布局右列展示,antd Image 组件支持点击放大预览
- Bug 修复:取消错误判断 name==='AbortError' → instanceof RequestError && type===CANCELLED
订单明细分类(OrdersPage)
- 新增订单类型切换(充值订单 / VIP订单),各自独立数据源与列定义
- startVipPolling:VIP 订单轮询(VipAPI.getVipOrder),与充值轮询对称
- handleContinuePay 统一:根据 orderType 分发到对应轮询函数
- Bug 修复:同 VipPage 的取消错误判断
EnumResolver 统一枚举(enum-resolver.ts)
- 新增 payments 模块:status.label/color + method.label
- 扩展 vip.orderStatus.color
- VipPage / OrdersPage 移除所有直接 Map 索引和本地映射函数,统一走 EnumResolver
VipAPI 扩展(vip-api.ts)
- 新增 getVipOrder(orderId):单订单查询端点 /api/v1/vip/orders/{order_id}
PageDispatcher
- /vip 路由注册 VipPage,label="VIP 会员开通",width=560
This commit is contained in:
@@ -17,13 +17,19 @@ 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 {
|
||||
VipAPI,
|
||||
VipOrderStatus,
|
||||
type VipOrderStatusValue,
|
||||
type VipOrderRead,
|
||||
} from '@/services/modules/vip-api';
|
||||
import { centToYuan, formatDate } from '@/utils/display';
|
||||
import { EnumResolver } from '@/utils/enum-resolver';
|
||||
import { RequestError, RequestErrorType } from '@/services/request';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -41,20 +47,13 @@ const STATUS_FILTERS = [
|
||||
{ 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',
|
||||
};
|
||||
/** 订单类型 */
|
||||
type OrderType = 'recharge' | 'vip';
|
||||
|
||||
/** 支付方式 → 显示文本 */
|
||||
function getPaymentChannelLabel(channel: string): string {
|
||||
if (channel === PAYMENT_METHODS.Alipay) return '支付宝';
|
||||
if (channel === PAYMENT_METHODS.WeChat) return '微信支付';
|
||||
return channel;
|
||||
}
|
||||
const ORDER_TYPE_OPTIONS = [
|
||||
{ label: '充值订单', value: 'recharge' as OrderType },
|
||||
{ label: 'VIP订单', value: 'vip' as OrderType },
|
||||
];
|
||||
|
||||
// ---------- 组件 ----------
|
||||
|
||||
@@ -64,12 +63,14 @@ export function OrdersPage() {
|
||||
|
||||
// ======== 筛选 & 分页 ========
|
||||
|
||||
const [orderType, setOrderType] = useState<OrderType>('recharge');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [pagination, setPagination] = useState({ page: 1, page_size: 10 });
|
||||
|
||||
// ======== 数据状态 ========
|
||||
|
||||
const [orders, setOrders] = useState<OrderListResponseBody[]>([]);
|
||||
const [rechargeOrders, setRechargeOrders] = useState<OrderListResponseBody[]>([]);
|
||||
const [vipOrders, setVipOrders] = useState<VipOrderRead[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -91,20 +92,34 @@ export function OrdersPage() {
|
||||
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, controller.signal);
|
||||
if (mountedRef.current) {
|
||||
setOrders(data);
|
||||
if (orderType === 'recharge') {
|
||||
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, controller.signal);
|
||||
if (mountedRef.current) {
|
||||
setRechargeOrders(data);
|
||||
}
|
||||
} else {
|
||||
const params: { page?: number; page_size?: number; status?: VipOrderStatusValue } = {
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size,
|
||||
};
|
||||
if (statusFilter !== 'all') {
|
||||
params.status = statusFilter as VipOrderStatusValue;
|
||||
}
|
||||
const data = await VipAPI.getVipOrders(params);
|
||||
if (mountedRef.current) {
|
||||
setVipOrders(data);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// AbortError 是预期行为(用户切换筛选导致),不视为错误
|
||||
if ((err as Error)?.name === 'AbortError') return;
|
||||
// axios 拦截器将取消错误转为 RequestError(CANCELLED),非原始 AbortError
|
||||
if (err instanceof RequestError && err.type === RequestErrorType.CANCELLED) return;
|
||||
if (mountedRef.current) {
|
||||
setError((err as Error)?.message || '加载订单列表失败');
|
||||
}
|
||||
@@ -113,7 +128,7 @@ export function OrdersPage() {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [pagination, statusFilter]);
|
||||
}, [pagination, statusFilter, orderType]);
|
||||
|
||||
// 筛选或分页变化 → 重新获取
|
||||
useEffect(() => {
|
||||
@@ -164,7 +179,7 @@ export function OrdersPage() {
|
||||
const newStatus = order.status;
|
||||
|
||||
// 局部更新 orders state(merge 最新订单信息)
|
||||
setOrders((prev) =>
|
||||
setRechargeOrders((prev) =>
|
||||
prev.map((o) => (o.id === orderId ? { ...o, ...order } : o)),
|
||||
);
|
||||
|
||||
@@ -199,21 +214,76 @@ export function OrdersPage() {
|
||||
pollingRef.current.set(orderId, timer);
|
||||
}, [message, fetchOrders]);
|
||||
|
||||
/** VIP 订单轮询(与充值订单轮询模式一致,使用 VipAPI.getVipOrder) */
|
||||
const startVipPolling = useCallback((orderId: string) => {
|
||||
const existing = pollingRef.current.get(orderId);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
pollingRef.current.delete(orderId);
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const poll = async () => {
|
||||
if (cancelled || !mountedRef.current) return;
|
||||
|
||||
try {
|
||||
const order = await VipAPI.getVipOrder(orderId);
|
||||
if (cancelled || !mountedRef.current) return;
|
||||
|
||||
const newStatus = order.status;
|
||||
|
||||
setVipOrders((prev) =>
|
||||
prev.map((o) => (o.id === orderId ? { ...o, ...order } : o)),
|
||||
);
|
||||
|
||||
if (newStatus === VipOrderStatus.Pending) {
|
||||
const timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||
pollingRef.current.set(orderId, timer);
|
||||
} else {
|
||||
pollingRef.current.delete(orderId);
|
||||
fetchOrders();
|
||||
|
||||
if (newStatus === VipOrderStatus.Paid) {
|
||||
message.success('支付成功!VIP 权益已生效');
|
||||
} else if (newStatus === VipOrderStatus.Expired) {
|
||||
message.warning('订单已过期');
|
||||
} else if (newStatus === VipOrderStatus.Cancelled) {
|
||||
message.info('订单已取消');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled && mountedRef.current) {
|
||||
const timer = setTimeout(poll, POLL_INTERVAL_MS * 2);
|
||||
pollingRef.current.set(orderId, timer);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||
pollingRef.current.set(orderId, timer);
|
||||
}, [message, fetchOrders]);
|
||||
|
||||
// ======== 操作回调 ========
|
||||
|
||||
/** "继续支付":打开支付页面 + 启动轮询 */
|
||||
const handleContinuePay = useCallback(
|
||||
(order: OrderListResponseBody) => {
|
||||
window.open(order.pay_url, '_blank');
|
||||
startPolling(order.id);
|
||||
(orderId: string, payUrl: string) => {
|
||||
window.open(payUrl, '_blank');
|
||||
if (orderType === 'recharge') {
|
||||
startPolling(orderId);
|
||||
} else {
|
||||
startVipPolling(orderId);
|
||||
}
|
||||
message.info('已打开支付页面,请完成支付');
|
||||
},
|
||||
[startPolling, message],
|
||||
[orderType, startPolling, startVipPolling, message],
|
||||
);
|
||||
|
||||
// ======== 表格列定义 ========
|
||||
|
||||
const columns = useMemo(() => [
|
||||
/** 充值订单列 */
|
||||
const rechargeColumns = useMemo(() => [
|
||||
{
|
||||
title: '订单编号',
|
||||
dataIndex: 'id',
|
||||
@@ -221,9 +291,7 @@ export function OrdersPage() {
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (id: string) => (
|
||||
<Text copyable style={{ fontSize: 12 }}>
|
||||
{id}
|
||||
</Text>
|
||||
<Text copyable style={{ fontSize: 12 }}>{id}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -233,9 +301,7 @@ export function OrdersPage() {
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
render: (amount: number) => (
|
||||
<Text strong style={{ fontSize: 13, color: token.colorPrimary }}>
|
||||
¥{centToYuan(amount)}
|
||||
</Text>
|
||||
<Text strong style={{ fontSize: 13, color: token.colorPrimary }}>¥{centToYuan(amount)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -245,7 +311,7 @@ export function OrdersPage() {
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
render: (channel: string) => (
|
||||
<Text style={{ fontSize: 12 }}>{getPaymentChannelLabel(channel)}</Text>
|
||||
<Text style={{ fontSize: 12 }}>{EnumResolver.payments.method.label(channel)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -254,9 +320,12 @@ export function OrdersPage() {
|
||||
key: 'status',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
render: (status: PaymentStatus) => (
|
||||
<Tag color={STATUS_TAG_COLOR[status]} style={{ fontSize: 11, lineHeight: '18px' }}>
|
||||
{getPaymentStatusText(status)}
|
||||
render: (status: string) => (
|
||||
<Tag
|
||||
color={EnumResolver.payments.status.color(status as PaymentStatus)}
|
||||
style={{ fontSize: 11, lineHeight: '18px' }}
|
||||
>
|
||||
{EnumResolver.payments.status.label(status as PaymentStatus)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
@@ -282,21 +351,91 @@ export function OrdersPage() {
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<DollarOutlined />}
|
||||
onClick={() => handleContinuePay(record)}
|
||||
onClick={() => handleContinuePay(record.id, record.pay_url)}
|
||||
>
|
||||
继续支付
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
return <Text type="secondary" style={{ fontSize: 12 }}>—</Text>;
|
||||
},
|
||||
},
|
||||
], [token.colorPrimary, handleContinuePay]);
|
||||
|
||||
/** VIP 订单列 */
|
||||
const vipColumns = useMemo(() => [
|
||||
{
|
||||
title: '订单编号',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 220,
|
||||
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: 'status',
|
||||
key: 'status',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
render: (status: string) => (
|
||||
<Tag
|
||||
color={EnumResolver.vip.orderStatus.color(status as VipOrderStatusValue)}
|
||||
style={{ fontSize: 11, lineHeight: '18px' }}
|
||||
>
|
||||
{EnumResolver.vip.orderStatus.label(status as VipOrderStatusValue)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 130,
|
||||
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: VipOrderRead) => {
|
||||
if (record.status === VipOrderStatus.Pending && record.pay_url) {
|
||||
return (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<DollarOutlined />}
|
||||
onClick={() => handleContinuePay(record.id, record.pay_url ?? '')}
|
||||
>
|
||||
继续支付
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return <Text type="secondary" style={{ fontSize: 12 }}>—</Text>;
|
||||
},
|
||||
},
|
||||
], [token.colorPrimary, handleContinuePay]);
|
||||
|
||||
/** 当前类型对应的列和数据 */
|
||||
const columns = orderType === 'recharge' ? rechargeColumns : vipColumns;
|
||||
const orders = orderType === 'recharge' ? rechargeOrders : vipOrders;
|
||||
|
||||
// ============================================================
|
||||
// 渲染:错误态(无数据时)
|
||||
// ============================================================
|
||||
@@ -324,12 +463,34 @@ export function OrdersPage() {
|
||||
// 渲染:正常
|
||||
// ============================================================
|
||||
|
||||
/** 当前订单类型对应的状态筛选项 */
|
||||
const currentStatusFilters = orderType === 'recharge'
|
||||
? STATUS_FILTERS
|
||||
: [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '待支付', value: VipOrderStatus.Pending },
|
||||
{ label: '已支付', value: VipOrderStatus.Paid },
|
||||
{ label: '已过期', value: VipOrderStatus.Expired },
|
||||
{ label: '已取消', value: VipOrderStatus.Cancelled },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '8px 0', width: '100%' }}>
|
||||
<div className="flex flex-col gap-3" style={{ width: '100%' }}>
|
||||
{/* ---- 状态筛选 ---- */}
|
||||
{/* ---- 订单类型 + 状态筛选 ---- */}
|
||||
<Segmented
|
||||
options={STATUS_FILTERS}
|
||||
options={ORDER_TYPE_OPTIONS}
|
||||
value={orderType}
|
||||
onChange={(val) => {
|
||||
setOrderType(val as OrderType);
|
||||
setStatusFilter('all');
|
||||
setPagination((prev) => ({ ...prev, page: 1 }));
|
||||
}}
|
||||
block
|
||||
style={{ backgroundColor: token.colorFillSecondary }}
|
||||
/>
|
||||
<Segmented
|
||||
options={currentStatusFilters}
|
||||
value={statusFilter}
|
||||
onChange={(val) => {
|
||||
setStatusFilter(val as string);
|
||||
@@ -348,7 +509,8 @@ export function OrdersPage() {
|
||||
|
||||
{/* ---- 订单表格 ---- */}
|
||||
<Card size="small" styles={{ body: { padding: 0 } }} className="rounded-md!">
|
||||
<Table<OrderListResponseBody>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Table<any>
|
||||
columns={columns}
|
||||
dataSource={orders}
|
||||
rowKey="id"
|
||||
|
||||
Reference in New Issue
Block a user