Files
ele-HeiXiu/src/pages/panels/OrdersPage.tsx
YoungestSongMo 8b697e775d feat: 去除构建时版控变量 + 窗口标题动态版控 + 退出登录保留缓存 (0.0.24)
版控体系重构(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 新增已完成项
2026-06-22 13:36:42 +08:00

542 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
// 轮询定时器 MaporderId → 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 statemerge 最新订单信息)
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>
);
}