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:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"pid": 77548,
|
"pid": 122144,
|
||||||
"version": "0.9.9",
|
"version": "0.9.9",
|
||||||
"socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea",
|
"socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea",
|
||||||
"startedAt": 1781164466085
|
"startedAt": 1781578435571
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {AccountInfo} from '@/pages/panels/AccountInfo';
|
|||||||
import {BillingInfo} from '@/pages/panels/BillingInfo';
|
import {BillingInfo} from '@/pages/panels/BillingInfo';
|
||||||
import {RechargePage} from '@/pages/panels/RechargePage';
|
import {RechargePage} from '@/pages/panels/RechargePage';
|
||||||
import {OrdersPage} from '@/pages/panels/OrdersPage';
|
import {OrdersPage} from '@/pages/panels/OrdersPage';
|
||||||
|
import {VipPage} from '@/pages/panels/VipPage';
|
||||||
|
|
||||||
// ---------- 类型 ----------
|
// ---------- 类型 ----------
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ const PAGE_MAP: Record<string, PageConfig> = {
|
|||||||
width: () => Math.min(window.outerHeight * 0.65, 720),
|
width: () => Math.min(window.outerHeight * 0.65, 720),
|
||||||
height: () => Math.min(window.outerHeight * 0.65, 680),
|
height: () => Math.min(window.outerHeight * 0.65, 680),
|
||||||
},
|
},
|
||||||
'/vip': {label: '开会员/激活'},
|
'/vip': {component: VipPage, label: 'VIP 会员开通', width: 560},
|
||||||
'/recharge': {component: RechargePage, label: '充值', width: 520},
|
'/recharge': {component: RechargePage, label: '充值', width: 520},
|
||||||
'/billing': {label: '账单记录',
|
'/billing': {label: '账单记录',
|
||||||
component: BillingInfo,
|
component: BillingInfo,
|
||||||
|
|||||||
@@ -17,13 +17,19 @@ import { DollarOutlined, ReloadOutlined } from '@ant-design/icons';
|
|||||||
import {
|
import {
|
||||||
PaymentAPI,
|
PaymentAPI,
|
||||||
PAYMENT_STATUS,
|
PAYMENT_STATUS,
|
||||||
PAYMENT_METHODS,
|
|
||||||
getPaymentStatusText,
|
|
||||||
type PaymentStatus,
|
type PaymentStatus,
|
||||||
type OrderListResponseBody,
|
type OrderListResponseBody,
|
||||||
type OrderInfoResponseBody,
|
type OrderInfoResponseBody,
|
||||||
} from '@/services/modules/payments';
|
} from '@/services/modules/payments';
|
||||||
|
import {
|
||||||
|
VipAPI,
|
||||||
|
VipOrderStatus,
|
||||||
|
type VipOrderStatusValue,
|
||||||
|
type VipOrderRead,
|
||||||
|
} from '@/services/modules/vip-api';
|
||||||
import { centToYuan, formatDate } from '@/utils/display';
|
import { centToYuan, formatDate } from '@/utils/display';
|
||||||
|
import { EnumResolver } from '@/utils/enum-resolver';
|
||||||
|
import { RequestError, RequestErrorType } from '@/services/request';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -41,20 +47,13 @@ const STATUS_FILTERS = [
|
|||||||
{ label: '支付失败', value: PAYMENT_STATUS.Failed },
|
{ label: '支付失败', value: PAYMENT_STATUS.Failed },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 状态 → Tag 颜色映射 */
|
/** 订单类型 */
|
||||||
const STATUS_TAG_COLOR: Record<string, string> = {
|
type OrderType = 'recharge' | 'vip';
|
||||||
[PAYMENT_STATUS.Pending]: 'processing',
|
|
||||||
[PAYMENT_STATUS.Paid]: 'success',
|
|
||||||
[PAYMENT_STATUS.Expired]: 'default',
|
|
||||||
[PAYMENT_STATUS.Failed]: 'error',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 支付方式 → 显示文本 */
|
const ORDER_TYPE_OPTIONS = [
|
||||||
function getPaymentChannelLabel(channel: string): string {
|
{ label: '充值订单', value: 'recharge' as OrderType },
|
||||||
if (channel === PAYMENT_METHODS.Alipay) return '支付宝';
|
{ label: 'VIP订单', value: 'vip' as OrderType },
|
||||||
if (channel === PAYMENT_METHODS.WeChat) return '微信支付';
|
];
|
||||||
return channel;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 组件 ----------
|
// ---------- 组件 ----------
|
||||||
|
|
||||||
@@ -64,12 +63,14 @@ export function OrdersPage() {
|
|||||||
|
|
||||||
// ======== 筛选 & 分页 ========
|
// ======== 筛选 & 分页 ========
|
||||||
|
|
||||||
|
const [orderType, setOrderType] = useState<OrderType>('recharge');
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
const [pagination, setPagination] = useState({ page: 1, page_size: 10 });
|
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 [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -91,6 +92,7 @@ export function OrdersPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
if (orderType === 'recharge') {
|
||||||
const params: { page: number; page_size: number; status?: PaymentStatus } = {
|
const params: { page: number; page_size: number; status?: PaymentStatus } = {
|
||||||
page: pagination.page,
|
page: pagination.page,
|
||||||
page_size: pagination.page_size,
|
page_size: pagination.page_size,
|
||||||
@@ -100,11 +102,24 @@ export function OrdersPage() {
|
|||||||
}
|
}
|
||||||
const data = await PaymentAPI.getOrdersListInfo(params, controller.signal);
|
const data = await PaymentAPI.getOrdersListInfo(params, controller.signal);
|
||||||
if (mountedRef.current) {
|
if (mountedRef.current) {
|
||||||
setOrders(data);
|
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) {
|
} catch (err) {
|
||||||
// AbortError 是预期行为(用户切换筛选导致),不视为错误
|
// axios 拦截器将取消错误转为 RequestError(CANCELLED),非原始 AbortError
|
||||||
if ((err as Error)?.name === 'AbortError') return;
|
if (err instanceof RequestError && err.type === RequestErrorType.CANCELLED) return;
|
||||||
if (mountedRef.current) {
|
if (mountedRef.current) {
|
||||||
setError((err as Error)?.message || '加载订单列表失败');
|
setError((err as Error)?.message || '加载订单列表失败');
|
||||||
}
|
}
|
||||||
@@ -113,7 +128,7 @@ export function OrdersPage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [pagination, statusFilter]);
|
}, [pagination, statusFilter, orderType]);
|
||||||
|
|
||||||
// 筛选或分页变化 → 重新获取
|
// 筛选或分页变化 → 重新获取
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -164,7 +179,7 @@ export function OrdersPage() {
|
|||||||
const newStatus = order.status;
|
const newStatus = order.status;
|
||||||
|
|
||||||
// 局部更新 orders state(merge 最新订单信息)
|
// 局部更新 orders state(merge 最新订单信息)
|
||||||
setOrders((prev) =>
|
setRechargeOrders((prev) =>
|
||||||
prev.map((o) => (o.id === orderId ? { ...o, ...order } : o)),
|
prev.map((o) => (o.id === orderId ? { ...o, ...order } : o)),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -199,21 +214,76 @@ export function OrdersPage() {
|
|||||||
pollingRef.current.set(orderId, timer);
|
pollingRef.current.set(orderId, timer);
|
||||||
}, [message, fetchOrders]);
|
}, [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(
|
const handleContinuePay = useCallback(
|
||||||
(order: OrderListResponseBody) => {
|
(orderId: string, payUrl: string) => {
|
||||||
window.open(order.pay_url, '_blank');
|
window.open(payUrl, '_blank');
|
||||||
startPolling(order.id);
|
if (orderType === 'recharge') {
|
||||||
|
startPolling(orderId);
|
||||||
|
} else {
|
||||||
|
startVipPolling(orderId);
|
||||||
|
}
|
||||||
message.info('已打开支付页面,请完成支付');
|
message.info('已打开支付页面,请完成支付');
|
||||||
},
|
},
|
||||||
[startPolling, message],
|
[orderType, startPolling, startVipPolling, message],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ======== 表格列定义 ========
|
// ======== 表格列定义 ========
|
||||||
|
|
||||||
const columns = useMemo(() => [
|
/** 充值订单列 */
|
||||||
|
const rechargeColumns = useMemo(() => [
|
||||||
{
|
{
|
||||||
title: '订单编号',
|
title: '订单编号',
|
||||||
dataIndex: 'id',
|
dataIndex: 'id',
|
||||||
@@ -221,9 +291,7 @@ export function OrdersPage() {
|
|||||||
width: 200,
|
width: 200,
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
render: (id: string) => (
|
render: (id: string) => (
|
||||||
<Text copyable style={{ fontSize: 12 }}>
|
<Text copyable style={{ fontSize: 12 }}>{id}</Text>
|
||||||
{id}
|
|
||||||
</Text>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -233,9 +301,7 @@ export function OrdersPage() {
|
|||||||
width: 90,
|
width: 90,
|
||||||
align: 'center' as const,
|
align: 'center' as const,
|
||||||
render: (amount: number) => (
|
render: (amount: number) => (
|
||||||
<Text strong style={{ fontSize: 13, color: token.colorPrimary }}>
|
<Text strong style={{ fontSize: 13, color: token.colorPrimary }}>¥{centToYuan(amount)}</Text>
|
||||||
¥{centToYuan(amount)}
|
|
||||||
</Text>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -245,7 +311,7 @@ export function OrdersPage() {
|
|||||||
width: 90,
|
width: 90,
|
||||||
align: 'center' as const,
|
align: 'center' as const,
|
||||||
render: (channel: string) => (
|
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',
|
key: 'status',
|
||||||
width: 90,
|
width: 90,
|
||||||
align: 'center' as const,
|
align: 'center' as const,
|
||||||
render: (status: PaymentStatus) => (
|
render: (status: string) => (
|
||||||
<Tag color={STATUS_TAG_COLOR[status]} style={{ fontSize: 11, lineHeight: '18px' }}>
|
<Tag
|
||||||
{getPaymentStatusText(status)}
|
color={EnumResolver.payments.status.color(status as PaymentStatus)}
|
||||||
|
style={{ fontSize: 11, lineHeight: '18px' }}
|
||||||
|
>
|
||||||
|
{EnumResolver.payments.status.label(status as PaymentStatus)}
|
||||||
</Tag>
|
</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -282,21 +351,91 @@ export function OrdersPage() {
|
|||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<DollarOutlined />}
|
icon={<DollarOutlined />}
|
||||||
onClick={() => handleContinuePay(record)}
|
onClick={() => handleContinuePay(record.id, record.pay_url)}
|
||||||
>
|
>
|
||||||
继续支付
|
继续支付
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return <Text type="secondary" style={{ fontSize: 12 }}>—</Text>;
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
—
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
], [token.colorPrimary, handleContinuePay]);
|
], [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 (
|
return (
|
||||||
<div style={{ padding: '8px 0', width: '100%' }}>
|
<div style={{ padding: '8px 0', width: '100%' }}>
|
||||||
<div className="flex flex-col gap-3" style={{ width: '100%' }}>
|
<div className="flex flex-col gap-3" style={{ width: '100%' }}>
|
||||||
{/* ---- 状态筛选 ---- */}
|
{/* ---- 订单类型 + 状态筛选 ---- */}
|
||||||
<Segmented
|
<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}
|
value={statusFilter}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
setStatusFilter(val as string);
|
setStatusFilter(val as string);
|
||||||
@@ -348,7 +509,8 @@ export function OrdersPage() {
|
|||||||
|
|
||||||
{/* ---- 订单表格 ---- */}
|
{/* ---- 订单表格 ---- */}
|
||||||
<Card size="small" styles={{ body: { padding: 0 } }} className="rounded-md!">
|
<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}
|
columns={columns}
|
||||||
dataSource={orders}
|
dataSource={orders}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
|||||||
636
src/pages/panels/VipPage.tsx
Normal file
636
src/pages/panels/VipPage.tsx
Normal file
@@ -0,0 +1,636 @@
|
|||||||
|
// ============================================================
|
||||||
|
// VipPage — VIP 会员开通页面
|
||||||
|
//
|
||||||
|
// 通过 PageDispatcher 调度(Modal 容器),导航栏"开会员/激活"按钮触发。
|
||||||
|
// 四部分布局:
|
||||||
|
// 激活码 — Space.Compact 输入框 + 激活按钮
|
||||||
|
// 套餐选择 — Radio.Group 卡片式单选,API 获取套餐列表
|
||||||
|
// 支付协议 — Checkbox + LegalTextModal(用户服务协议) + 确认开通
|
||||||
|
// 权益说明 — 选中套餐的大图展示
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import {useState, useCallback, useEffect, useRef} from 'react';
|
||||||
|
import {
|
||||||
|
Radio,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Input,
|
||||||
|
Image,
|
||||||
|
Typography,
|
||||||
|
App,
|
||||||
|
Space,
|
||||||
|
Divider,
|
||||||
|
theme as antTheme,
|
||||||
|
Spin,
|
||||||
|
Result,
|
||||||
|
} from 'antd';
|
||||||
|
import {CrownOutlined, GiftOutlined, DollarOutlined} from '@ant-design/icons';
|
||||||
|
|
||||||
|
import {useTheme} from '@/components/providers/ThemeProvider';
|
||||||
|
import {LegalTextModal} from '@/components/ui/LegalTextModal';
|
||||||
|
import {
|
||||||
|
VipAPI,
|
||||||
|
VipOrderStatus,
|
||||||
|
type VipLevelsItem,
|
||||||
|
type VipOrderStatusValue,
|
||||||
|
} from '@/services/modules/vip-api';
|
||||||
|
import {centToYuan} from '@/utils/display';
|
||||||
|
import {EnumResolver} from '@/utils/enum-resolver';
|
||||||
|
import {emit, EVENTS} from '@/utils/event-bus';
|
||||||
|
import {RequestError, RequestErrorType} from '@/services/request';
|
||||||
|
|
||||||
|
const {Text} = Typography;
|
||||||
|
|
||||||
|
// ---------- 常量 ----------
|
||||||
|
|
||||||
|
/** 订单状态轮询间隔(毫秒) */
|
||||||
|
const POLL_INTERVAL_MS = 3000;
|
||||||
|
|
||||||
|
/** 页面阶段 */
|
||||||
|
type VipPhase = 'form' | 'activate' | VipOrderStatusValue;
|
||||||
|
|
||||||
|
/** 状态 → Result status 映射 */
|
||||||
|
function getResultStatus(status: VipOrderStatusValue): 'success' | 'info' | 'error' {
|
||||||
|
if (status === VipOrderStatus.Paid) return 'success';
|
||||||
|
if (status === VipOrderStatus.Pending) return 'info';
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 组件 ----------
|
||||||
|
|
||||||
|
export function VipPage() {
|
||||||
|
const {isDark} = useTheme();
|
||||||
|
const {message} = App.useApp();
|
||||||
|
const {token} = antTheme.useToken();
|
||||||
|
|
||||||
|
// ======== 套餐列表 ========
|
||||||
|
|
||||||
|
const [levels, setLevels] = useState<VipLevelsItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
|
// ======== 表单状态 ========
|
||||||
|
|
||||||
|
const [selectedLevelId, setSelectedLevelId] = useState<number | null>(null);
|
||||||
|
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [legalOpen, setLegalOpen] = useState(false);
|
||||||
|
|
||||||
|
// ======== 激活码 ========
|
||||||
|
|
||||||
|
const [activateCode, setActivateCode] = useState('');
|
||||||
|
const [activating, setActivating] = useState(false);
|
||||||
|
|
||||||
|
// ======== 支付订单状态 ========
|
||||||
|
|
||||||
|
const [phase, setPhase] = useState<VipPhase>('form');
|
||||||
|
const [vipOrderId, setVipOrderId] = useState<string | null>(null);
|
||||||
|
const [vipOrderStatus, setVipOrderStatus] = useState<VipOrderStatusValue | null>(null);
|
||||||
|
const [vipPayUrl, setVipPayUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ======== 工具函数 ========
|
||||||
|
|
||||||
|
const clearError = useCallback((name: string) => {
|
||||||
|
setErrors((prev) => {
|
||||||
|
if (!(name in prev)) return prev;
|
||||||
|
const next = {...prev};
|
||||||
|
delete next[name];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ======== 获取套餐列表 ========
|
||||||
|
|
||||||
|
const fetchLevels = useCallback(async () => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setFetchError(null);
|
||||||
|
try {
|
||||||
|
const data = await VipAPI.getVipLevels(controller.signal);
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setLevels(data.filter((l) => l.is_active && l.image));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// axios 拦截器将取消错误转为 RequestError(CANCELLED),非原始 AbortError
|
||||||
|
if (err instanceof RequestError && err.type === RequestErrorType.CANCELLED) return;
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setFetchError((err as Error)?.message || '加载套餐列表失败');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
void fetchLevels();
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
abortRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, [fetchLevels]);
|
||||||
|
|
||||||
|
// ======== 校验 ========
|
||||||
|
|
||||||
|
const validate = useCallback((): Record<string, string> => {
|
||||||
|
const errs: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (selectedLevelId === null) {
|
||||||
|
errs.selectedLevel = '请选择会员套餐';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!agreedToTerms) {
|
||||||
|
errs.agreedToTerms = '请阅读并同意用户服务协议';
|
||||||
|
}
|
||||||
|
|
||||||
|
return errs;
|
||||||
|
}, [selectedLevelId, agreedToTerms]);
|
||||||
|
|
||||||
|
// ======== 提交 ========
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
const validationErrors = validate();
|
||||||
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
|
setErrors(validationErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await VipAPI.createVipOrder({
|
||||||
|
vip_level_id: selectedLevelId!,
|
||||||
|
payment_channel: 'alipay',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存订单信息,进入待支付阶段
|
||||||
|
setVipOrderId(res.id);
|
||||||
|
setVipOrderStatus(VipOrderStatus.Pending);
|
||||||
|
setVipPayUrl(res.pay_url);
|
||||||
|
setPhase(VipOrderStatus.Pending);
|
||||||
|
|
||||||
|
window.open(res.pay_url ?? '', '_blank');
|
||||||
|
void message.success('订单已创建,请在新页面中完成支付');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
message.error((err as Error)?.message || '创建订单失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [selectedLevelId, validate, message]);
|
||||||
|
|
||||||
|
// ======== 激活码提交 ========
|
||||||
|
|
||||||
|
const handleActivate = useCallback(async () => {
|
||||||
|
const code = activateCode.trim();
|
||||||
|
if (!code) {
|
||||||
|
message.warning('请输入激活码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActivating(true);
|
||||||
|
try {
|
||||||
|
await VipAPI.activateVipCode({code});
|
||||||
|
void message.success('激活成功!VIP 权益已生效');
|
||||||
|
emit(EVENTS.CLOSE_PAGE);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
message.error((err as Error)?.message || '激活失败,请检查激活码是否正确');
|
||||||
|
} finally {
|
||||||
|
setActivating(false);
|
||||||
|
}
|
||||||
|
}, [activateCode, message]);
|
||||||
|
|
||||||
|
// ======== 操作回调 ========
|
||||||
|
|
||||||
|
// ======== 订单状态轮询 ========
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== VipOrderStatus.Pending || !vipOrderId) return;
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const order = await VipAPI.getVipOrder(vipOrderId);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const newStatus = order.status;
|
||||||
|
setVipOrderStatus(newStatus);
|
||||||
|
|
||||||
|
if (newStatus === VipOrderStatus.Pending) {
|
||||||
|
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||||
|
} else {
|
||||||
|
setPhase(newStatus);
|
||||||
|
if (newStatus === VipOrderStatus.Paid) {
|
||||||
|
void message.success('支付成功!VIP 权益已生效');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) timer = setTimeout(poll, POLL_INTERVAL_MS * 2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [phase, vipOrderId, message]);
|
||||||
|
|
||||||
|
// ======== 页面锁定(待支付时禁止关闭容器) ========
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const locked = phase === VipOrderStatus.Pending;
|
||||||
|
emit(EVENTS.PAGE_LOCK, { locked });
|
||||||
|
return () => {
|
||||||
|
if (locked) emit(EVENTS.PAGE_LOCK, { locked: false });
|
||||||
|
};
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
|
// ======== 操作回调 ========
|
||||||
|
|
||||||
|
/** 重新下单:回到表单,保留套餐选择 */
|
||||||
|
const handleRetry = useCallback(() => {
|
||||||
|
setPhase('form');
|
||||||
|
setVipOrderId(null);
|
||||||
|
setVipOrderStatus(null);
|
||||||
|
setVipPayUrl(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
emit(EVENTS.CLOSE_PAGE);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 渲染:加载态
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{display: 'flex', justifyContent: 'center', padding: '48px 0'}}>
|
||||||
|
<Spin size="default">
|
||||||
|
<div style={{padding: 24, textAlign: 'center'}}>
|
||||||
|
<Text type="secondary" style={{fontSize: 13}}>加载套餐列表…</Text>
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 渲染:错误态
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
if (fetchError) {
|
||||||
|
return (
|
||||||
|
<div style={{padding: '8px 0'}}>
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="套餐加载失败"
|
||||||
|
subTitle={fetchError}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" size="small" onClick={fetchLevels}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 渲染:支付状态视图(非 form / activate 阶段)
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
if (phase !== 'form' && phase !== 'activate') {
|
||||||
|
const statusText = vipOrderStatus ? EnumResolver.vip.orderStatus.label(vipOrderStatus) : '';
|
||||||
|
const isPending = phase === VipOrderStatus.Pending;
|
||||||
|
const isPaid = phase === VipOrderStatus.Paid;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ padding: '16px 0' }}>
|
||||||
|
<Result
|
||||||
|
status={vipOrderStatus ? getResultStatus(vipOrderStatus) : 'info'}
|
||||||
|
title={
|
||||||
|
isPaid
|
||||||
|
? '支付成功'
|
||||||
|
: isPending
|
||||||
|
? `等待支付(${statusText})`
|
||||||
|
: statusText
|
||||||
|
}
|
||||||
|
subTitle={
|
||||||
|
isPaid
|
||||||
|
? 'VIP 会员权益已生效'
|
||||||
|
: isPending
|
||||||
|
? '请在新打开的浏览器页面中完成支付'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* ---- 订单信息卡片 ---- */}
|
||||||
|
{isPending && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: isDark ? '#2E2A5A' : '#F5F3FF',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 16,
|
||||||
|
textAlign: 'left',
|
||||||
|
maxWidth: 360,
|
||||||
|
margin: '0 auto 16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space orientation="vertical" size={6} style={{ width: '100%' }}>
|
||||||
|
<Text style={{ fontSize: 12 }} type="secondary">
|
||||||
|
订单编号:<Text copyable style={{ fontSize: 12 }}>{vipOrderId}</Text>
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 13 }}>
|
||||||
|
<DollarOutlined style={{ marginRight: 6, color: token.colorPrimary }} />
|
||||||
|
支付金额:
|
||||||
|
<Text strong>
|
||||||
|
¥{(() => {
|
||||||
|
const lvl = levels.find((l) => l.id === selectedLevelId);
|
||||||
|
return lvl ? centToYuan(lvl.price_cent) : '—';
|
||||||
|
})()}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 13 }}>
|
||||||
|
<CrownOutlined style={{ marginRight: 6, color: token.colorSuccess }} />
|
||||||
|
套餐:
|
||||||
|
<Text strong>{levels.find((l) => l.id === selectedLevelId)?.name ?? '—'}</Text>
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- 操作按钮 ---- */}
|
||||||
|
<Space size={12}>
|
||||||
|
{isPending && vipPayUrl && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<DollarOutlined />}
|
||||||
|
onClick={() => window.open(vipPayUrl, '_blank')}
|
||||||
|
>
|
||||||
|
打开支付页面
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!isPending && !isPaid && (
|
||||||
|
<Button type="primary" onClick={handleRetry}>
|
||||||
|
重新下单
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleClose}>
|
||||||
|
{isPaid ? '完成' : '关闭'}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Result>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- 协议弹窗(待支付阶段仍可查看) ---- */}
|
||||||
|
<LegalTextModal
|
||||||
|
open={legalOpen}
|
||||||
|
type="service"
|
||||||
|
onClose={() => setLegalOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 渲染:正常
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{padding: '4px 0'}}>
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{fontSize: 13, marginBottom: 10, display: 'block'}}>
|
||||||
|
<GiftOutlined style={{marginRight: 4}} />
|
||||||
|
使用激活码
|
||||||
|
</Text>
|
||||||
|
<Space.Compact style={{width: '100%'}}>
|
||||||
|
<Input
|
||||||
|
value={activateCode}
|
||||||
|
onChange={(e) => setActivateCode(e.target.value)}
|
||||||
|
placeholder="输入 VIP 激活码"
|
||||||
|
allowClear
|
||||||
|
onPressEnter={() => void handleActivate()}
|
||||||
|
style={{flex: 1}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<GiftOutlined />}
|
||||||
|
loading={activating}
|
||||||
|
onClick={() => void handleActivate()}
|
||||||
|
disabled={!activateCode.trim()}
|
||||||
|
>
|
||||||
|
激活
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
</div>
|
||||||
|
<Divider style={{margin: '20px 0 16px'}} />
|
||||||
|
<Text type="secondary" style={{fontSize: 13, marginBottom: 12, display: 'block'}}>
|
||||||
|
选择会员套餐
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 双列布局:左列套餐选择 | 右列权益图库 */}
|
||||||
|
<div style={{display: 'flex', gap: 12, marginBottom: 16}}>
|
||||||
|
{/* ---- 左列:套餐选择 ---- */}
|
||||||
|
<div style={{flex: '0 0 52%', minWidth: 0}}>
|
||||||
|
<Radio.Group
|
||||||
|
value={selectedLevelId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedLevelId(e.target.value);
|
||||||
|
clearError('selectedLevel');
|
||||||
|
}}
|
||||||
|
style={{width: '100%'}}
|
||||||
|
>
|
||||||
|
<Space orientation="vertical" style={{width: '100%'}} size={6}>
|
||||||
|
{levels.map((level) => (
|
||||||
|
<Radio
|
||||||
|
key={level.id}
|
||||||
|
value={level.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: `1px solid ${
|
||||||
|
selectedLevelId === level.id
|
||||||
|
? token.colorPrimary
|
||||||
|
: isDark
|
||||||
|
? '#2E2A5A'
|
||||||
|
: '#E5E7EB'
|
||||||
|
}`,
|
||||||
|
background:
|
||||||
|
selectedLevelId === level.id
|
||||||
|
? isDark
|
||||||
|
? '#2E2A5A'
|
||||||
|
: '#F5F3FF'
|
||||||
|
: isDark
|
||||||
|
? '#1A1730'
|
||||||
|
: '#FFFFFF',
|
||||||
|
width: '100%',
|
||||||
|
margin: 0,
|
||||||
|
transition: 'border-color 0.2s, background 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{flex: 1, marginLeft: 8, minWidth: 0}}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text strong style={{fontSize: 13}}>
|
||||||
|
{level.name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: token.colorPrimary,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
¥{centToYuan(level.price_cent)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{display: 'flex', gap: 8, flexWrap: 'wrap'}}>
|
||||||
|
<Text type="secondary" style={{fontSize: 11}}>
|
||||||
|
{level.duration_days}天
|
||||||
|
</Text>
|
||||||
|
{level.gift_balance_cent > 0 && (
|
||||||
|
<Text type="secondary" style={{fontSize: 11}}>
|
||||||
|
赠{centToYuan(level.gift_balance_cent)}积分
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text type="secondary" style={{fontSize: 11}}>
|
||||||
|
{EnumResolver.vip.targetAccountType.label(level.target_account_type)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
{level.description && (
|
||||||
|
<Text type="secondary" style={{fontSize: 10, display: 'block', marginTop: 2}}>
|
||||||
|
{level.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Radio>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Radio.Group>
|
||||||
|
|
||||||
|
{errors.selectedLevel && (
|
||||||
|
<Text type="danger" style={{fontSize: 12, marginTop: 4, display: 'block'}}>
|
||||||
|
{errors.selectedLevel}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- 右列:权益图库 ---- */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text type="secondary" style={{fontSize: 11, textAlign: 'center'}}>
|
||||||
|
权益说明(点击放大)
|
||||||
|
</Text>
|
||||||
|
{levels.map((level) => (
|
||||||
|
<div
|
||||||
|
key={level.id}
|
||||||
|
style={{
|
||||||
|
borderRadius: 6,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: `2px solid ${
|
||||||
|
selectedLevelId === level.id
|
||||||
|
? token.colorPrimary
|
||||||
|
: 'transparent'
|
||||||
|
}`,
|
||||||
|
opacity: selectedLevelId === null || selectedLevelId === level.id ? 1 : 0.45,
|
||||||
|
transition: 'border-color 0.2s, opacity 0.2s',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={level.image!}
|
||||||
|
alt={`${level.name} 权益`}
|
||||||
|
width="100%"
|
||||||
|
style={{display: 'block'}}
|
||||||
|
preview={{mask: '查看大图'}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- Part 2:支付协议 + 确认开通 ---- */}
|
||||||
|
<div style={{marginBottom: 16}}>
|
||||||
|
<Checkbox
|
||||||
|
checked={agreedToTerms}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAgreedToTerms(e.target.checked);
|
||||||
|
clearError('agreedToTerms');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{fontSize: 12}}>
|
||||||
|
我已阅读并同意
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setLegalOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: '0 2px',
|
||||||
|
fontSize: 12,
|
||||||
|
height: 'auto',
|
||||||
|
lineHeight: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
《用户服务协议》
|
||||||
|
</Button>
|
||||||
|
</Text>
|
||||||
|
</Checkbox>
|
||||||
|
{errors.agreedToTerms && (
|
||||||
|
<Text type="danger" style={{fontSize: 12, marginTop: 4, display: 'block'}}>
|
||||||
|
{errors.agreedToTerms}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div style={{display: 'flex', justifyContent: 'flex-end', gap: 8}}>
|
||||||
|
<Button onClick={handleClose}>取消</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<CrownOutlined />}
|
||||||
|
loading={submitting}
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
disabled={selectedLevelId === null}
|
||||||
|
>
|
||||||
|
确认开通
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- 协议弹窗 ---- */}
|
||||||
|
<LegalTextModal
|
||||||
|
open={legalOpen}
|
||||||
|
type="service"
|
||||||
|
onClose={() => setLegalOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -53,7 +53,7 @@ export interface VipLevelsItem {
|
|||||||
model_package_id: number;
|
model_package_id: number;
|
||||||
description: Nullable<string>;
|
description: Nullable<string>;
|
||||||
image: Nullable<string>;
|
image: Nullable<string>;
|
||||||
is_active: number;
|
is_active: boolean;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
target_account_type: TargetAccountTypeValue;
|
target_account_type: TargetAccountTypeValue;
|
||||||
seat_limit: Nullable<number>;
|
seat_limit: Nullable<number>;
|
||||||
@@ -147,6 +147,12 @@ export class VipAPI {
|
|||||||
return await get<VipOrderRead[]>(VipApiUrl.VipOrders, params as Record<string, unknown>);
|
return await get<VipOrderRead[]>(VipApiUrl.VipOrders, params as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取单个 VIP 订单详情 */
|
||||||
|
static async getVipOrder(orderId: string): Promise<VipOrderRead> {
|
||||||
|
const url = `${VipApiUrl.VipOrders}/${orderId}`;
|
||||||
|
return await get<VipOrderRead>(url);
|
||||||
|
}
|
||||||
|
|
||||||
/** 模拟支付成功(仅开发测试用) */
|
/** 模拟支付成功(仅开发测试用) */
|
||||||
static async mockPay(orderId: string): Promise<VipOrderRead> {
|
static async mockPay(orderId: string): Promise<VipOrderRead> {
|
||||||
return await get<VipOrderRead>(`${VipApiUrl.MockPay}/${orderId}`);
|
return await get<VipOrderRead>(`${VipApiUrl.MockPay}/${orderId}`);
|
||||||
|
|||||||
@@ -20,6 +20,11 @@
|
|||||||
// EnumResolver.announcement.type.color('feature') // "purple"
|
// EnumResolver.announcement.type.color('feature') // "purple"
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
import {
|
||||||
|
PAYMENT_STATUS_TEXT_MAP,
|
||||||
|
type PaymentStatus,
|
||||||
|
} from '@/services/modules/payments';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EntryTypeLabelMap,
|
EntryTypeLabelMap,
|
||||||
EntryTypeColorMap,
|
EntryTypeColorMap,
|
||||||
@@ -77,6 +82,27 @@ const SLUG_TO_CATEGORY: Record<string, string> = Object.fromEntries(
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
export const EnumResolver = {
|
export const EnumResolver = {
|
||||||
|
/** 支付 */
|
||||||
|
payments: {
|
||||||
|
status: {
|
||||||
|
label: (value: PaymentStatus) =>
|
||||||
|
PAYMENT_STATUS_TEXT_MAP[value] || value || '未知状态',
|
||||||
|
color: (value: PaymentStatus) => {
|
||||||
|
if (value === 'pending') return 'processing';
|
||||||
|
if (value === 'paid') return 'success';
|
||||||
|
if (value === 'failed') return 'error';
|
||||||
|
return 'default';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
method: {
|
||||||
|
label: (value: string) => {
|
||||||
|
if (value === 'alipay') return '支付宝';
|
||||||
|
if (value === 'wechat') return '微信支付';
|
||||||
|
return value || '—';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
/** 账单 */
|
/** 账单 */
|
||||||
billing: {
|
billing: {
|
||||||
entryType: {
|
entryType: {
|
||||||
@@ -116,6 +142,12 @@ export const EnumResolver = {
|
|||||||
orderStatus: {
|
orderStatus: {
|
||||||
label: (value: VipOrderStatusValue) =>
|
label: (value: VipOrderStatusValue) =>
|
||||||
VipOrderStatusLabelMap[value] || value || '未知订单状态',
|
VipOrderStatusLabelMap[value] || value || '未知订单状态',
|
||||||
|
color: (value: VipOrderStatusValue) => {
|
||||||
|
if (value === 'pending') return 'processing';
|
||||||
|
if (value === 'paid') return 'success';
|
||||||
|
if (value === 'cancelled') return 'error';
|
||||||
|
return 'default';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
targetAccountType: {
|
targetAccountType: {
|
||||||
label: (value: TargetAccountTypeValue) =>
|
label: (value: TargetAccountTypeValue) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user