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:
2026-06-16 18:29:59 +08:00
parent 72a0290693
commit e1c8eff946
6 changed files with 895 additions and 58 deletions

View File

@@ -1,6 +1,6 @@
{
"pid": 77548,
"pid": 122144,
"version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea",
"startedAt": 1781164466085
"startedAt": 1781578435571
}

View File

@@ -26,6 +26,7 @@ import {AccountInfo} from '@/pages/panels/AccountInfo';
import {BillingInfo} from '@/pages/panels/BillingInfo';
import {RechargePage} from '@/pages/panels/RechargePage';
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),
height: () => Math.min(window.outerHeight * 0.65, 680),
},
'/vip': {label: '开会员/激活'},
'/vip': {component: VipPage, label: 'VIP 会员开通', width: 560},
'/recharge': {component: RechargePage, label: '充值', width: 520},
'/billing': {label: '账单记录',
component: BillingInfo,

View File

@@ -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,6 +92,7 @@ export function OrdersPage() {
setLoading(true);
setError(null);
try {
if (orderType === 'recharge') {
const params: { page: number; page_size: number; status?: PaymentStatus } = {
page: pagination.page,
page_size: pagination.page_size,
@@ -100,11 +102,24 @@ export function OrdersPage() {
}
const data = await PaymentAPI.getOrdersListInfo(params, controller.signal);
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) {
// 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 statemerge 最新订单信息)
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"

View 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)}
/>
</>
);
}

View File

@@ -53,7 +53,7 @@ export interface VipLevelsItem {
model_package_id: number;
description: Nullable<string>;
image: Nullable<string>;
is_active: number;
is_active: boolean;
sort_order: number;
target_account_type: TargetAccountTypeValue;
seat_limit: Nullable<number>;
@@ -147,6 +147,12 @@ export class VipAPI {
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> {
return await get<VipOrderRead>(`${VipApiUrl.MockPay}/${orderId}`);

View File

@@ -20,6 +20,11 @@
// EnumResolver.announcement.type.color('feature') // "purple"
// ============================================================
import {
PAYMENT_STATUS_TEXT_MAP,
type PaymentStatus,
} from '@/services/modules/payments';
import {
EntryTypeLabelMap,
EntryTypeColorMap,
@@ -77,6 +82,27 @@ const SLUG_TO_CATEGORY: Record<string, string> = Object.fromEntries(
// ============================================================
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: {
entryType: {
@@ -116,6 +142,12 @@ export const EnumResolver = {
orderStatus: {
label: (value: VipOrderStatusValue) =>
VipOrderStatusLabelMap[value] || value || '未知订单状态',
color: (value: VipOrderStatusValue) => {
if (value === 'pending') return 'processing';
if (value === 'paid') return 'success';
if (value === 'cancelled') return 'error';
return 'default';
},
},
targetAccountType: {
label: (value: TargetAccountTypeValue) =>