Files
ele-HeiXiu/src/pages/panels/RechargePage.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

525 lines
18 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.
// ============================================================
// 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)}
/>
</>
);
}