feat: 订单轮询 + 竞态防护 + 性能优化 (OrdersPage 完善)
- startPolling: 基于 Map<orderId, timer> 的多订单并发轮询
幂等启动(重复点击防重)+ 局部 state 更新 + 终态全量刷新
- fetchOrders: AbortController 防竞态,切换筛选/分页时取消旧请求
AbortError 静默吞掉,不污染 error state
- columns: useMemo 稳定引用,依赖 [token.colorPrimary, handleContinuePay]
- 卸载清理: abort + clearTimeout 双重防护
- 文档同步: CHANGELOG / TODO 更新至 0.0.21
This commit is contained in:
14
CHANGELOG.md
14
CHANGELOG.md
@@ -32,6 +32,20 @@
|
|||||||
- uploadValidation 内联到 ImageInput(controls/ 不再混工具函数)
|
- uploadValidation 内联到 ImageInput(controls/ 不再混工具函数)
|
||||||
- 删除空占位 Test3.tsx
|
- 删除空占位 Test3.tsx
|
||||||
|
|
||||||
|
### 支付系统 & 订单明细
|
||||||
|
|
||||||
|
- **RechargePage antd 6.x 适配**:`InputNumber.addonAfter` → `Space.Compact`、`Space direction` → `orientation`,消除弃用警告
|
||||||
|
- **OrdersPage 订单明细页面**(`/orders`,方案 B — 独立订单管理页):
|
||||||
|
- 状态筛选 Segmented(全部/待支付/已支付/已过期/支付失败)+ 分页 Table
|
||||||
|
- "继续支付"操作:打开支付 URL + `startPolling` 单订单轮询
|
||||||
|
- `pollingRef: Map<orderId, timer>` 管理多订单并发轮询,幂等启动(重复点击防重)
|
||||||
|
- 局部 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)
|
## 0.0.20(2026-06-12)
|
||||||
|
|||||||
5
TODO.md
5
TODO.md
@@ -15,9 +15,12 @@
|
|||||||
### 路由与页面
|
### 路由与页面
|
||||||
|
|
||||||
- [x] 账户信息页面(`/account`) — `AccountInfo.tsx`,AuthAPI.getUserInfo() 驱动
|
- [x] 账户信息页面(`/account`) — `AccountInfo.tsx`,AuthAPI.getUserInfo() 驱动
|
||||||
|
- [x] 充值页面(`/recharge`) — `RechargePage.tsx`,创建订单 → 支付状态跟踪
|
||||||
|
- [x] 订单明细页面(`/orders`) — `OrdersPage.tsx`,状态筛选 + 分页 + 继续支付 + 轮询
|
||||||
- [ ] 合规素材库页面(`/compliance-assets`)
|
- [ ] 合规素材库页面(`/compliance-assets`)
|
||||||
- [ ] 项目管理页面(`/projects`,企业版)
|
- [ ] 项目管理页面(`/projects`,企业版)
|
||||||
- [ ] 会员/充值/消费记录/订单明细页面
|
- [ ] 会员页面(`/vip`)
|
||||||
|
- [ ] 消费记录页面
|
||||||
|
|
||||||
### 导航栏
|
### 导航栏
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
Table,
|
Table,
|
||||||
@@ -30,7 +30,6 @@ const { Text } = Typography;
|
|||||||
// ---------- 常量 ----------
|
// ---------- 常量 ----------
|
||||||
|
|
||||||
/** 订单状态轮询间隔(毫秒) */
|
/** 订单状态轮询间隔(毫秒) */
|
||||||
// startPolling 实现后将使用此常量
|
|
||||||
const POLL_INTERVAL_MS = 3000;
|
const POLL_INTERVAL_MS = 3000;
|
||||||
|
|
||||||
/** 状态筛选项 */
|
/** 状态筛选项 */
|
||||||
@@ -78,10 +77,17 @@ export function OrdersPage() {
|
|||||||
const pollingRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
const pollingRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
// 组件是否已挂载
|
// 组件是否已挂载
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
|
// fetchOrders 的 AbortController(切换筛选/分页时取消旧请求)
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
// ======== 数据获取 ========
|
// ======== 数据获取 ========
|
||||||
|
|
||||||
const fetchOrders = useCallback(async () => {
|
const fetchOrders = useCallback(async () => {
|
||||||
|
// 取消上一次未完成的请求(防竞态)
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -92,11 +98,13 @@ export function OrdersPage() {
|
|||||||
if (statusFilter !== 'all') {
|
if (statusFilter !== 'all') {
|
||||||
params.status = statusFilter as PaymentStatus;
|
params.status = statusFilter as PaymentStatus;
|
||||||
}
|
}
|
||||||
const data = await PaymentAPI.getOrdersListInfo(params);
|
const data = await PaymentAPI.getOrdersListInfo(params, controller.signal);
|
||||||
if (mountedRef.current) {
|
if (mountedRef.current) {
|
||||||
setOrders(data);
|
setOrders(data);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// AbortError 是预期行为(用户切换筛选导致),不视为错误
|
||||||
|
if ((err as Error)?.name === 'AbortError') return;
|
||||||
if (mountedRef.current) {
|
if (mountedRef.current) {
|
||||||
setError((err as Error)?.message || '加载订单列表失败');
|
setError((err as Error)?.message || '加载订单列表失败');
|
||||||
}
|
}
|
||||||
@@ -116,29 +124,79 @@ export function OrdersPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
const polling = pollingRef.current;
|
const polling = pollingRef.current;
|
||||||
|
const abort = abortRef.current;
|
||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false;
|
mountedRef.current = false;
|
||||||
// 清理所有轮询定时器
|
// 清理所有轮询定时器
|
||||||
polling.forEach((timer) => clearTimeout(timer));
|
polling.forEach((timer) => clearTimeout(timer));
|
||||||
polling.clear();
|
polling.clear();
|
||||||
|
// 取消进行中的列表请求
|
||||||
|
abort?.abort();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ======== 单订单轮询 ========
|
// ======== 单订单轮询 ========
|
||||||
|
|
||||||
// TODO(human): 实现 startPolling 函数
|
/**
|
||||||
// 该函数接收 orderId,对该订单启动轮询(每 POLL_INTERVAL_MS 查一次)
|
* 单订单轮询:对指定 orderId 启动轮询,每 POLL_INTERVAL_MS 查一次
|
||||||
// 轮询到终态时停止,并刷新列表;网络异常时延长间隔重试
|
* - 幂等启动:取消同一订单的旧轮询再创建新的
|
||||||
// 需要考虑:
|
* - 局部更新 orders state,终态时调 fetchOrders 保证一致性
|
||||||
// 1. 取消之前同一订单的轮询(避免重复)
|
* - 网络异常时延长间隔(2×)重试
|
||||||
// 2. 组件卸载时清理定时器(通过 mountedRef)
|
* - 通过 mountedRef 防止卸载后 setState
|
||||||
// 3. 轮询结果更新到 orders state 中(局部更新后,终态时调用 fetchOrders 保证一致性)
|
*/
|
||||||
const startPolling = useCallback((_orderId: string) => {
|
const startPolling = useCallback((orderId: string) => {
|
||||||
// TODO(human): 实现此函数(使用 POLL_INTERVAL_MS、message、fetchOrders 等闭包变量)
|
// ① 取消同一订单的旧轮询(幂等启动,避免重复轮询)
|
||||||
void POLL_INTERVAL_MS;
|
const existing = pollingRef.current.get(orderId);
|
||||||
void message;
|
if (existing) {
|
||||||
void fetchOrders;
|
clearTimeout(existing);
|
||||||
void ({} as OrderInfoResponseBody);
|
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]);
|
}, [message, fetchOrders]);
|
||||||
|
|
||||||
// ======== 操作回调 ========
|
// ======== 操作回调 ========
|
||||||
@@ -155,7 +213,7 @@ export function OrdersPage() {
|
|||||||
|
|
||||||
// ======== 表格列定义 ========
|
// ======== 表格列定义 ========
|
||||||
|
|
||||||
const columns = [
|
const columns = useMemo(() => [
|
||||||
{
|
{
|
||||||
title: '订单编号',
|
title: '订单编号',
|
||||||
dataIndex: 'id',
|
dataIndex: 'id',
|
||||||
@@ -237,7 +295,7 @@ export function OrdersPage() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
], [token.colorPrimary, handleContinuePay]);
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 渲染:错误态(无数据时)
|
// 渲染:错误态(无数据时)
|
||||||
|
|||||||
Reference in New Issue
Block a user