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

@@ -22,4 +22,5 @@
- CLAUDE.md - CLAUDE.md
- 永远不要修改CLAUDE.md文档 - 永远不要修改CLAUDE.md文档
- 在各个文档中的更新需要保持精简。且职责划分保持严谨除了架构、项目文档已完成的事需要从TODO.md文档中移除。 - 在各个文档中的更新需要保持精简。且职责划分保持严谨除了架构、项目文档已完成的事需要从TODO.md文档中移除。
- 在没有得到修改指令时,不要修改文档文件。

View File

@@ -24,6 +24,8 @@ import {FloatingPanel} from '@/components/ui/FloatingPanel';
import {WechatWorkPage} from '@/pages/panels/WechatWorkPage'; import {WechatWorkPage} from '@/pages/panels/WechatWorkPage';
import {AccountInfo} from '@/pages/panels/AccountInfo'; 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 {OrdersPage} from '@/pages/panels/OrdersPage';
// ---------- 类型 ---------- // ---------- 类型 ----------
@@ -81,13 +83,16 @@ const PAGE_MAP: Record<string, PageConfig> = {
height: () => Math.min(window.outerHeight * 0.65, 680), height: () => Math.min(window.outerHeight * 0.65, 680),
}, },
'/vip': {label: '开会员/激活'}, '/vip': {label: '开会员/激活'},
'/recharge': {label: '充值积分'}, '/recharge': {component: RechargePage, label: '充值', width: 520},
'/billing': {label: '账单记录', '/billing': {label: '账单记录',
component: BillingInfo, component: BillingInfo,
width: 960, width: 960,
height: 680,}, height: 680,},
'/orders': { '/orders': {
component: OrdersPage,
label: '订单明细', label: '订单明细',
width: 820,
height: 640,
}, },
'/projects': {label: '项目管理'}, '/projects': {label: '项目管理'},
'/quota': { '/quota': {
@@ -126,6 +131,12 @@ function resolveHeight(target: string): number | undefined {
export function PageDispatcher() { export function PageDispatcher() {
const [activePanel, setActivePanel] = useState<ActivePanel | null>(null); const [activePanel, setActivePanel] = useState<ActivePanel | null>(null);
const nextIdRef = useRef(0); const nextIdRef = useRef(0);
const [locked, setLocked] = useState(false);
// 关闭面板
const closePanel = useCallback(() => {
setActivePanel(null);
}, []);
// 监听 OPEN_PAGE 事件 // 监听 OPEN_PAGE 事件
useEffect(() => { useEffect(() => {
@@ -149,11 +160,26 @@ export function PageDispatcher() {
return () => off(EVENTS.OPEN_PAGE, handler); return () => off(EVENTS.OPEN_PAGE, handler);
}, []); }, []);
// 关闭面板 // 监听 CLOSE_PAGE 事件(允许页面组件内部"取消"按钮关闭容器)
const closePanel = useCallback(() => { useEffect(() => {
setActivePanel(null); on(EVENTS.CLOSE_PAGE, closePanel);
return () => off(EVENTS.CLOSE_PAGE, closePanel);
}, [closePanel]);
// 监听 PAGE_LOCK 事件(页面组件操作进行中时禁止关闭容器)
useEffect(() => {
const handler = (payload: unknown) => {
setLocked((payload as { locked: boolean }).locked);
};
on(EVENTS.PAGE_LOCK, handler);
return () => off(EVENTS.PAGE_LOCK, handler);
}, []); }, []);
// 面板关闭时重置锁定状态(防止下次打开时仍处于锁定)
useEffect(() => {
if (!activePanel) setLocked(false);
}, [activePanel]);
// 页面组件useMemo 稳定引用) // 页面组件useMemo 稳定引用)
const PanelPage = useMemo<ComponentType | null>( const PanelPage = useMemo<ComponentType | null>(
() => (activePanel ? resolvePage(activePanel.target) : null), () => (activePanel ? resolvePage(activePanel.target) : null),
@@ -178,7 +204,7 @@ export function PageDispatcher() {
onCancel={closePanel} onCancel={closePanel}
footer={null} footer={null}
destroyOnHidden={true} destroyOnHidden={true}
mask={{closable: true}} mask={{closable: !locked}}
width={panelWidth ?? defaultModalWidth} width={panelWidth ?? defaultModalWidth}
> >
{/* eslint-disable-next-line react-hooks/static-components */} {/* eslint-disable-next-line react-hooks/static-components */}
@@ -193,6 +219,9 @@ export function PageDispatcher() {
title={activePanel.label} title={activePanel.label}
onClose={closePanel} onClose={closePanel}
destroyOnHidden={true} destroyOnHidden={true}
mask={{
closable:locked
}}
size={panelWidth ?? defaultDrawerWidth} size={panelWidth ?? defaultDrawerWidth}
> >
{/* eslint-disable-next-line react-hooks/static-components */} {/* eslint-disable-next-line react-hooks/static-components */}

View File

@@ -65,7 +65,7 @@ const personalRight: NavItemConfig[] = [
}, },
{ {
key: 'recharge-points', key: 'recharge-points',
label: '充值积分', label: '充值',
iconSrc: rechargeSvg, iconSrc: rechargeSvg,
action: 'page', action: 'page',
target: '/recharge', target: '/recharge',

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

View File

@@ -0,0 +1,524 @@
// ============================================================
// RechargePage — 积分充值表单 + 支付状态跟踪
//
// 通过 PageDispatcher 调度Modal 容器),导航栏"充值"按钮触发。
// 两阶段视图:
// form — 金额选择 → 自定义输入 → 支付方式 → 协议 → 提交
// pending — 订单已创建,轮询支付状态,可重新打开支付页面
// paid — 支付成功
// expired — 订单过期,可重新下单
// failed — 支付失败,可重新下单
// ============================================================
import { useState, useMemo, useCallback, useEffect } from 'react';
import {
Radio,
InputNumber,
Button,
Checkbox,
Typography,
App,
Space,
Divider,
theme as antTheme,
Result,
} from 'antd';
import {
AlipayOutlined,
WechatOutlined,
WalletOutlined,
DollarOutlined,
} from '@ant-design/icons';
import { useTheme } from '@/components/providers/ThemeProvider';
import { LegalTextModal } from '@/components/ui/LegalTextModal';
import {
PaymentAPI,
PAYMENT_METHODS,
PAYMENT_STATUS,
getPaymentStatusText,
type PaymentStatus,
} from '@/services/modules/payments';
import { emit, EVENTS } from '@/utils/event-bus';
const { Text } = Typography;
// ---------- 常量 ----------
const PRESET_AMOUNTS = [10, 100, 500, 1000, 5000, 10000] as const;
/** 单次充值上限(元) */
const MAX_AMOUNT_YUAN = 100000;
/** 订单状态轮询间隔(毫秒) */
const POLL_INTERVAL_MS = 3000;
/** 页面阶段 */
type RechargePhase = 'form' | PaymentStatus;
// ---------- 组件 ----------
export function RechargePage() {
const { isDark } = useTheme();
const { message } = App.useApp();
const { token } = antTheme.useToken();
// ======== 表单状态 ========
const [selectedAmount, setSelectedAmount] = useState<number | null>(null);
const [customAmount, setCustomAmount] = useState<number | null>(null);
const [paymentMethod, setPaymentMethod] = useState<string>(PAYMENT_METHODS.Alipay);
const [agreedToTerms, setAgreedToTerms] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [legalOpen, setLegalOpen] = useState(false);
// ======== 订单状态 ========
const [phase, setPhase] = useState<RechargePhase>('form');
const [orderId, setOrderId] = useState<string | null>(null);
const [orderStatus, setOrderStatus] = useState<PaymentStatus | null>(null);
const [payUrl, setPayUrl] = useState<string | null>(null);
// ======== 派生值 ========
/** 最终充值金额(元):自定义金额优先,否则预设金额,否则 0 */
const finalAmountYuan = useMemo(() => {
if (customAmount !== null && customAmount !== undefined) {
return customAmount;
}
return selectedAmount ?? 0;
}, [customAmount, selectedAmount]);
/** 最终充值金额(分):元 × 100用于 API 调用 */
const finalAmountCent = finalAmountYuan * 100;
// ======== 工具函数 ========
const clearError = useCallback((name: string) => {
setErrors((prev) => {
if (!(name in prev)) return prev;
const next = { ...prev };
delete next[name];
return next;
});
}, []);
// ======== 校验 ========
const validate = useCallback((): Record<string, string> => {
const errs: Record<string, string> = {};
if (!finalAmountYuan || finalAmountYuan <= 0) {
errs.amount = '请选择或输入充值金额';
} else if (!Number.isInteger(finalAmountYuan) || finalAmountYuan % 10 !== 0) {
errs.customAmount = '金额必须为 10 的倍数';
} else if (finalAmountYuan > MAX_AMOUNT_YUAN) {
errs.customAmount = `单次充值上限为 ${MAX_AMOUNT_YUAN.toLocaleString()}`;
}
if (!agreedToTerms) {
errs.agreedToTerms = '请阅读并同意充值协议';
}
return errs;
}, [finalAmountYuan, agreedToTerms]);
// ======== 支付方式切换 ========
const handlePaymentChange = useCallback(
(value: string) => {
if (value === PAYMENT_METHODS.WeChat) {
void message.info('微信支付正在接入中,敬请期待');
return; // 不更新 state保持支付宝选中
}
setPaymentMethod(value);
},
[message],
);
// ======== 提交流程 ========
const handleSubmit = useCallback(async () => {
const validationErrors = validate();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setSubmitting(true);
try {
const res = await PaymentAPI.createOrder({
amount_cent: finalAmountCent,
payment_channel: PAYMENT_METHODS.Alipay,
});
// 保存订单信息,进入等待支付阶段
setOrderId(res.id);
setOrderStatus(PAYMENT_STATUS.Pending);
setPayUrl(res.pay_url);
setPhase(PAYMENT_STATUS.Pending);
window.open(res.pay_url, '_blank');
void message.success('订单已创建,请在新页面中完成支付');
} catch (err: unknown) {
message.error((err as Error)?.message || '创建订单失败,请重试');
} finally {
setSubmitting(false);
}
}, [finalAmountCent, validate, message]);
// ======== 订单状态轮询 ========
useEffect(() => {
if (phase !== PAYMENT_STATUS.Pending || !orderId) return;
let timer: ReturnType<typeof setTimeout>;
let cancelled = false;
const poll = async () => {
try {
const order = await PaymentAPI.getOrderInfo({ order_id: orderId });
if (cancelled) return;
const newStatus = order.status;
setOrderStatus(newStatus);
if (newStatus === PAYMENT_STATUS.Pending) {
// 仍待支付 → 继续轮询
timer = setTimeout(poll, POLL_INTERVAL_MS);
} else {
// 终态 → 停止轮询,切换阶段
setPhase(newStatus);
if (newStatus === PAYMENT_STATUS.Paid) {
void message.success('支付成功,积分已到账');
}
}
} catch {
// 网络异常 → 延长间隔后继续轮询
if (!cancelled) timer = setTimeout(poll, POLL_INTERVAL_MS * 2);
}
};
timer = setTimeout(poll, POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [phase, orderId, message]);
// ======== 页面锁定(待支付时禁止关闭容器) ========
useEffect(() => {
const locked = phase === PAYMENT_STATUS.Pending;
emit(EVENTS.PAGE_LOCK, { locked });
// 组件卸载时确保解锁(防止锁定残留)
return () => {
if (locked) emit(EVENTS.PAGE_LOCK, { locked: false });
};
}, [phase]);
// ======== 操作回调 ========
/** 重新下单:回到表单,保留之前填写的金额 */
const handleRetry = useCallback(() => {
setPhase('form');
setOrderId(null);
setOrderStatus(null);
setPayUrl(null);
}, []);
/** 关闭充值页面 */
const handleClose = useCallback(() => {
emit(EVENTS.CLOSE_PAGE);
}, []);
// ================================================================
// 渲染:支付状态视图(非 form 阶段)
// ================================================================
if (phase !== 'form') {
const statusText = orderStatus ? getPaymentStatusText(orderStatus) : '';
const isPending = phase === PAYMENT_STATUS.Pending;
const isPaid = phase === PAYMENT_STATUS.Paid;
const isTerminal = !isPending;
return (
<>
<div style={{ padding: '16px 0' }}>
<Result
status={
isPaid
? 'success'
: isPending
? 'info'
: 'error'
}
title={
isPaid
? '支付成功'
: isPending
? `等待支付(${statusText}`
: statusText
}
subTitle={
isPaid
? `已到账积分:${finalAmountYuan.toLocaleString()} 积分`
: 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 }}>{orderId}</Text>
</Text>
<Text style={{ fontSize: 13 }}>
<DollarOutlined style={{ marginRight: 6, color: token.colorPrimary }} />
<Text strong>¥{finalAmountYuan.toLocaleString()} </Text>
</Text>
<Text style={{ fontSize: 13 }}>
<WalletOutlined style={{ marginRight: 6, color: token.colorSuccess }} />
<Text strong>{finalAmountYuan.toLocaleString()}</Text>
</Text>
</Space>
</div>
)}
{/* ---- 操作按钮 ---- */}
<Space size={12}>
{isPending && payUrl && (
<Button
type="primary"
icon={<DollarOutlined />}
onClick={() => window.open(payUrl, '_blank')}
>
</Button>
)}
{isTerminal && !isPaid && (
<Button type="primary" onClick={handleRetry}>
</Button>
)}
<Button onClick={handleClose}>
{isPaid ? '完成' : '关闭(取消支付)'}
</Button>
</Space>
</Result>
</div>
{/* ---- 协议弹窗(待支付阶段仍可查看) ---- */}
<LegalTextModal
open={legalOpen}
type="points"
onClose={() => setLegalOpen(false)}
/>
</>
);
}
// ================================================================
// 渲染表单视图form 阶段)
// ================================================================
return (
<>
<div style={{ padding: '4px 0' }}>
{/* ---- 页面副标题 ---- */}
<Text
type="secondary"
style={{ fontSize: 13, marginBottom: 20, display: 'block' }}
>
</Text>
{/* ---- Part 1预设金额单选组 ---- */}
<div style={{ marginBottom: 16 }}>
<Radio.Group
value={selectedAmount}
onChange={(e) => {
setSelectedAmount(e.target.value);
setCustomAmount(null);
clearError('amount');
}}
optionType="button"
buttonStyle="solid"
style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}
>
{PRESET_AMOUNTS.map((amt) => (
<Radio.Button key={amt} value={amt}>
{amt}
</Radio.Button>
))}
</Radio.Group>
{errors.amount && (
<Text type="danger" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
{errors.amount}
</Text>
)}
</div>
{/* ---- Part 2自定义金额输入 ---- */}
<div style={{ marginBottom: 16 }}>
<Space.Compact style={{ width: '100%' }}>
<InputNumber
value={customAmount}
onChange={(val) => {
setCustomAmount(val);
setSelectedAmount(null);
clearError('customAmount');
}}
min={10}
step={10}
max={MAX_AMOUNT_YUAN}
placeholder="输入自定义金额10的倍数"
style={{ flex: 1 }}
size="large"
precision={0}
/>
<Button size="large" disabled style={{ minWidth: 44 }}>
</Button>
</Space.Compact>
{errors.customAmount && (
<Text type="danger" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
{errors.customAmount}
</Text>
)}
</div>
{/* ---- Part 3到账信息 + 支付金额 ---- */}
{finalAmountYuan > 0 && (
<div
style={{
background: isDark ? '#2E2A5A' : '#F5F3FF',
padding: '12px 16px',
borderRadius: 8,
marginBottom: 12,
}}
>
<Space orientation="vertical" size={6}>
<Text style={{ fontSize: 13 }}>
<WalletOutlined style={{ marginRight: 6, color: token.colorPrimary }} />
<Text strong>{finalAmountYuan.toLocaleString()}</Text>
</Text>
<Text style={{ fontSize: 13 }}>
<DollarOutlined style={{ marginRight: 6, color: token.colorSuccess }} />
<Text strong style={{ color: token.colorPrimary, fontSize: 15 }}>
¥{finalAmountYuan.toLocaleString()}
</Text>
{' '}
</Text>
</Space>
</div>
)}
<Divider style={{ margin: '12px 0' }} />
{/* ---- Part 4支付方式 ---- */}
<div style={{ marginBottom: 16 }}>
<Text
type="secondary"
style={{ fontSize: 12, marginBottom: 8, display: 'block' }}
>
</Text>
<Radio.Group
value={paymentMethod}
onChange={(e) => handlePaymentChange(e.target.value)}
optionType="button"
buttonStyle="solid"
>
<Radio.Button value={PAYMENT_METHODS.Alipay}>
<Space size={4}>
<AlipayOutlined />
</Space>
</Radio.Button>
<Radio.Button value={PAYMENT_METHODS.WeChat}>
<Space size={4}>
<WechatOutlined />
</Space>
</Radio.Button>
</Radio.Group>
</div>
<Divider style={{ margin: '12px 0' }} />
{/* ---- Part 5充值协议 ---- */}
<div style={{ marginBottom: 20 }}>
<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>
{/* ---- Part 6操作按钮 ---- */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={handleClose}>
</Button>
<Button
type="primary"
loading={submitting}
onClick={() => void handleSubmit()}
disabled={finalAmountYuan <= 0}
>
</Button>
</div>
</div>
{/* ---- 协议弹窗 ---- */}
<LegalTextModal
open={legalOpen}
type="points"
onClose={() => setLegalOpen(false)}
/>
</>
);
}

View File

@@ -0,0 +1,99 @@
import { get, post } from '@/services/request';
export const PAYMENT_METHODS = {
Alipay: 'alipay',
WeChat: 'wechat',
Mock: "mock"
} as const;
export type PaymentMethod = typeof PAYMENT_METHODS[keyof typeof PAYMENT_METHODS];
export type AvailablePaymentMethod = typeof PAYMENT_METHODS.Alipay;
export interface CreateOrderRequestBody {
amount_cent: number,
payment_channel: AvailablePaymentMethod
}
export const PAYMENT_STATUS = {
Pending: 'pending', // 待支付
Paid: 'paid', // 已支付
Expired: 'expired', // 已过期
Failed: 'failed', // 支付失败
} as const;
export type PaymentStatus = typeof PAYMENT_STATUS[keyof typeof PAYMENT_STATUS];
// 中文映射表
export const PAYMENT_STATUS_TEXT_MAP: Record<PaymentStatus, string> = {
[PAYMENT_STATUS.Pending]: '待支付',
[PAYMENT_STATUS.Paid]: '已支付',
[PAYMENT_STATUS.Expired]: '已过期',
[PAYMENT_STATUS.Failed]: '支付失败',
};
// 辅助函数:根据状态获取中文
export function getPaymentStatusText(status: PaymentStatus): string {
return PAYMENT_STATUS_TEXT_MAP[status];
}
export interface CreateOrderResponseBody {
id: string,
user_id: string,
amount_cent: number,
status: PaymentStatus,
payment_channel: AvailablePaymentMethod,
out_trade_no: string,
transaction_id: string,
pay_url: string,
expires_at: Date,
paid_at: Date,
created_at: Date,
}
export interface OrderListRequestParam {
status?: PaymentStatus,
page: number,
page_size: number,
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface OrderListResponseBody extends CreateOrderResponseBody {
}
export interface OrderInfoRequestParam {
order_id: string,
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface OrderInfoResponseBody extends CreateOrderResponseBody {
}
const PaymentUrlObj = {
createOrder: "/api/v1/payments/orders",
getOrderList: "/api/v1/payments/orders",
getOrderInfo: "/api/v1/payments/orders/{order_id}",
};
// ============================================================
// 调用方式PaymentAPI.createOrder() / PaymentAPI.getOrdersListInfo() 等
// ============================================================
export class PaymentAPI {
/** 创建支付订单 */
static async createOrder(body: CreateOrderRequestBody): Promise<CreateOrderResponseBody> {
return await post<CreateOrderResponseBody>(PaymentUrlObj.createOrder, body);
}
/** 获取订单列表(支持按状态筛选 + 分页) */
static async getOrdersListInfo(param: OrderListRequestParam, signal?: AbortSignal): Promise<OrderListResponseBody[]> {
return await get<OrderListResponseBody[]>(PaymentUrlObj.getOrderList, param as unknown as Record<string, unknown>, { signal });
}
/** 获取单个订单详情 */
static async getOrderInfo(param: OrderInfoRequestParam): Promise<OrderInfoResponseBody> {
const url = PaymentUrlObj.getOrderInfo.replace('{order_id}', param.order_id);
return await get<OrderInfoResponseBody>(url);
}
}

View File

@@ -75,4 +75,8 @@ export const EVENTS = {
OPEN_SETTINGS: 'settings:open', OPEN_SETTINGS: 'settings:open',
/** 打开页面 Modal导航栏点击页面导航项触发携带 target 路径标识符) */ /** 打开页面 Modal导航栏点击页面导航项触发携带 target 路径标识符) */
OPEN_PAGE: 'page:open', OPEN_PAGE: 'page:open',
/** 关闭当前页面由页面组件内部按钮触发PageDispatcher 响应) */
CLOSE_PAGE: 'page:close',
/** 锁定/解锁页面关闭页面组件内部操作进行中时禁止关闭容器PageDispatcher 响应) */
PAGE_LOCK: 'page:lock',
} as const; } as const;