版控体系重构(13 文件):
- 移除 build.mjs --personal/--enterprise 参数和 EDITION 环境变量
- 统一安装包文件名:船长-HeiXiu-{os}-{版本号}-Setup.{ext}
- 简化 package.json 构建脚本,移除 :enterprise 变体
- scripts/*.mjs 不再接受 edition 参数
- 移除 shared/constants/app.ts 的 parseEdition()
窗口标题动态版控:
- 新格式:船长-HeiXiu-{os}-{版本号}(登录前)/ ·{版控} 后缀(登录后)
- 链路:AppProvider(account_type) → useEffect → appRuntime IPC → main process
- 移除 updater.ts 更新检查 API 的 edition 查询参数
本地存储生命周期修正:
- 退出登录不再自动清空缓存(user_id 隔离,重新登录命中缓存)
- clearUserStorage 添加 isOpen() 竞态守卫
- 移除 closeDatabase() 避免退出→重新登录后数据库不可用
- 手动「清理缓存」按钮保留
文档:
- CHANGELOG.md 新增 0.0.24 条目
- TODO.md 新增已完成项
542 lines
17 KiB
TypeScript
542 lines
17 KiB
TypeScript
|
||
|
||
import { useState, useCallback, useEffect, useMemo, 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,
|
||
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;
|
||
|
||
// ---------- 常量 ----------
|
||
|
||
/** 订单状态轮询间隔(毫秒) */
|
||
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 },
|
||
];
|
||
|
||
/** 订单类型 */
|
||
type OrderType = 'recharge' | 'vip';
|
||
|
||
const ORDER_TYPE_OPTIONS = [
|
||
{ label: '充值订单', value: 'recharge' as OrderType },
|
||
{ label: 'VIP订单', value: 'vip' as OrderType },
|
||
];
|
||
|
||
// ---------- 组件 ----------
|
||
|
||
export function OrdersPage() {
|
||
const { message } = App.useApp();
|
||
const { token } = antTheme.useToken();
|
||
|
||
// ======== 筛选 & 分页 ========
|
||
|
||
const [orderType, setOrderType] = useState<OrderType>('recharge');
|
||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||
const [pagination, setPagination] = useState({ page: 1, page_size: 10 });
|
||
|
||
// ======== 数据状态 ========
|
||
|
||
const [rechargeOrders, setRechargeOrders] = useState<OrderListResponseBody[]>([]);
|
||
const [vipOrders, setVipOrders] = useState<VipOrderRead[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// 轮询定时器 Map(orderId → timer)
|
||
const pollingRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||
// 组件是否已挂载
|
||
const mountedRef = useRef(true);
|
||
// fetchOrders 的 AbortController(切换筛选/分页时取消旧请求)
|
||
const abortRef = useRef<AbortController | null>(null);
|
||
|
||
// ======== 数据获取 ========
|
||
|
||
const fetchOrders = useCallback(async () => {
|
||
// 取消上一次未完成的请求(防竞态)
|
||
abortRef.current?.abort();
|
||
const controller = new AbortController();
|
||
abortRef.current = controller;
|
||
|
||
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,
|
||
};
|
||
if (statusFilter !== 'all') {
|
||
params.status = statusFilter as PaymentStatus;
|
||
}
|
||
const data = await PaymentAPI.getOrdersListInfo(params, controller.signal);
|
||
if (mountedRef.current) {
|
||
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) {
|
||
// axios 拦截器将取消错误转为 RequestError(CANCELLED),非原始 AbortError
|
||
if (err instanceof RequestError && err.type === RequestErrorType.CANCELLED) return;
|
||
if (mountedRef.current) {
|
||
setError((err as Error)?.message || '加载订单列表失败');
|
||
}
|
||
} finally {
|
||
if (mountedRef.current) {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
}, [pagination, statusFilter, orderType]);
|
||
|
||
// 筛选或分页变化 → 重新获取
|
||
useEffect(() => {
|
||
fetchOrders();
|
||
}, [fetchOrders]);
|
||
|
||
// 挂载/卸载管理
|
||
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();
|
||
};
|
||
}, []);
|
||
|
||
// ======== 单订单轮询 ========
|
||
|
||
/**
|
||
* 单订单轮询:对指定 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 最新订单信息)
|
||
setRechargeOrders((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]);
|
||
|
||
/** 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(
|
||
(orderId: string, payUrl: string) => {
|
||
window.open(payUrl, '_blank');
|
||
if (orderType === 'recharge') {
|
||
startPolling(orderId);
|
||
} else {
|
||
startVipPolling(orderId);
|
||
}
|
||
message.info('已打开支付页面,请完成支付');
|
||
},
|
||
[orderType, startPolling, startVipPolling, message],
|
||
);
|
||
|
||
// ======== 表格列定义 ========
|
||
|
||
/** 充值订单列 */
|
||
const rechargeColumns = useMemo(() => [
|
||
{
|
||
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 }}>{EnumResolver.payments.method.label(channel)}</Text>
|
||
),
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
key: 'status',
|
||
width: 90,
|
||
align: 'center' as const,
|
||
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>
|
||
),
|
||
},
|
||
{
|
||
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.id, record.pay_url)}
|
||
>
|
||
继续支付
|
||
</Button>
|
||
);
|
||
}
|
||
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;
|
||
|
||
// ============================================================
|
||
// 渲染:错误态(无数据时)
|
||
// ============================================================
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 渲染:正常
|
||
// ============================================================
|
||
|
||
/** 当前订单类型对应的状态筛选项 */
|
||
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={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);
|
||
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!">
|
||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||
<Table<any>
|
||
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 });
|
||
},
|
||
placement: ['bottomCenter'],
|
||
}}
|
||
locale={{ emptyText: '暂无订单记录' }}
|
||
/>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|