diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b3249b..8b83d7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,20 @@ - uploadValidation 内联到 ImageInput(controls/ 不再混工具函数) - 删除空占位 Test3.tsx +### 支付系统 & 订单明细 + +- **RechargePage antd 6.x 适配**:`InputNumber.addonAfter` → `Space.Compact`、`Space direction` → `orientation`,消除弃用警告 +- **OrdersPage 订单明细页面**(`/orders`,方案 B — 独立订单管理页): + - 状态筛选 Segmented(全部/待支付/已支付/已过期/支付失败)+ 分页 Table + - "继续支付"操作:打开支付 URL + `startPolling` 单订单轮询 + - `pollingRef: Map` 管理多订单并发轮询,幂等启动(重复点击防重) + - 局部 state 更新 + 终态全量刷新(`fetchOrders`) + - `AbortController` 防竞态:切换筛选/分页时取消旧请求 + - `columns` 使用 `useMemo` 稳定引用 + - 错误态 Result + 重试按钮 +- **PageDispatcher**:注册 `/orders` 路由(width: 820, height: 640),新增 `CLOSE_PAGE` / `PAGE_LOCK` 事件监听 +- **payments.ts**:支付 API 模块类型体系(`CreateOrderResponseBody` → `OrderListResponseBody` / `OrderInfoResponseBody` 名义类型),`PaymentAPI.getOrdersListInfo` 支持 `AbortSignal` + --- ## 0.0.20(2026-06-12) diff --git a/TODO.md b/TODO.md index dbcced3..8bd42ed 100644 --- a/TODO.md +++ b/TODO.md @@ -15,9 +15,12 @@ ### 路由与页面 - [x] 账户信息页面(`/account`) — `AccountInfo.tsx`,AuthAPI.getUserInfo() 驱动 +- [x] 充值页面(`/recharge`) — `RechargePage.tsx`,创建订单 → 支付状态跟踪 +- [x] 订单明细页面(`/orders`) — `OrdersPage.tsx`,状态筛选 + 分页 + 继续支付 + 轮询 - [ ] 合规素材库页面(`/compliance-assets`) - [ ] 项目管理页面(`/projects`,企业版) -- [ ] 会员/充值/消费记录/订单明细页面 +- [ ] 会员页面(`/vip`) +- [ ] 消费记录页面 ### 导航栏 diff --git a/src/pages/panels/OrdersPage.tsx b/src/pages/panels/OrdersPage.tsx index 3678235..f2e0708 100644 --- a/src/pages/panels/OrdersPage.tsx +++ b/src/pages/panels/OrdersPage.tsx @@ -1,6 +1,6 @@ -import { useState, useCallback, useEffect, useRef } from 'react'; +import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { Card, Table, @@ -30,7 +30,6 @@ const { Text } = Typography; // ---------- 常量 ---------- /** 订单状态轮询间隔(毫秒) */ -// startPolling 实现后将使用此常量 const POLL_INTERVAL_MS = 3000; /** 状态筛选项 */ @@ -78,10 +77,17 @@ export function OrdersPage() { const pollingRef = useRef>>(new Map()); // 组件是否已挂载 const mountedRef = useRef(true); + // fetchOrders 的 AbortController(切换筛选/分页时取消旧请求) + const abortRef = useRef(null); // ======== 数据获取 ======== const fetchOrders = useCallback(async () => { + // 取消上一次未完成的请求(防竞态) + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + setLoading(true); setError(null); try { @@ -92,11 +98,13 @@ export function OrdersPage() { if (statusFilter !== 'all') { params.status = statusFilter as PaymentStatus; } - const data = await PaymentAPI.getOrdersListInfo(params); + const data = await PaymentAPI.getOrdersListInfo(params, controller.signal); if (mountedRef.current) { setOrders(data); } } catch (err) { + // AbortError 是预期行为(用户切换筛选导致),不视为错误 + if ((err as Error)?.name === 'AbortError') return; if (mountedRef.current) { setError((err as Error)?.message || '加载订单列表失败'); } @@ -116,29 +124,79 @@ export function OrdersPage() { useEffect(() => { mountedRef.current = true; const polling = pollingRef.current; + const abort = abortRef.current; return () => { mountedRef.current = false; // 清理所有轮询定时器 polling.forEach((timer) => clearTimeout(timer)); polling.clear(); + // 取消进行中的列表请求 + abort?.abort(); }; }, []); // ======== 单订单轮询 ======== - // 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); + /** + * 单订单轮询:对指定 orderId 启动轮询,每 POLL_INTERVAL_MS 查一次 + * - 幂等启动:取消同一订单的旧轮询再创建新的 + * - 局部更新 orders state,终态时调 fetchOrders 保证一致性 + * - 网络异常时延长间隔(2×)重试 + * - 通过 mountedRef 防止卸载后 setState + */ + const startPolling = 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: OrderInfoResponseBody = await PaymentAPI.getOrderInfo({ order_id: orderId }); + if (cancelled || !mountedRef.current) return; + + const newStatus = order.status; + + // 局部更新 orders state(merge 最新订单信息) + setOrders((prev) => + prev.map((o) => (o.id === orderId ? { ...o, ...order } : o)), + ); + + if (newStatus === PAYMENT_STATUS.Pending) { + // 仍待支付 → 继续轮询 + const timer = setTimeout(poll, POLL_INTERVAL_MS); + pollingRef.current.set(orderId, timer); + } else { + // 终态 → 停止轮询,刷新列表保证整体一致性 + pollingRef.current.delete(orderId); + fetchOrders(); + + if (newStatus === PAYMENT_STATUS.Paid) { + message.success('支付成功!'); + } else if (newStatus === PAYMENT_STATUS.Failed) { + message.error('支付失败,请重试'); + } else if (newStatus === PAYMENT_STATUS.Expired) { + message.warning('订单已过期'); + } + } + } 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]); // ======== 操作回调 ======== @@ -155,7 +213,7 @@ export function OrdersPage() { // ======== 表格列定义 ======== - const columns = [ + const columns = useMemo(() => [ { title: '订单编号', dataIndex: 'id', @@ -237,7 +295,7 @@ export function OrdersPage() { ); }, }, - ]; + ], [token.colorPrimary, handleContinuePay]); // ============================================================ // 渲染:错误态(无数据时)