版控体系重构(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 新增已完成项
525 lines
18 KiB
TypeScript
525 lines
18 KiB
TypeScript
// ============================================================
|
||
// 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/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)}
|
||
/>
|
||
</>
|
||
);
|
||
}
|