Files
Genarrative/src/components/platform-entry/PlatformProfileRechargeModal.tsx
T
kdletters 542fb09365 接入微信 Native 充值与订单过期处理
调整网页端充值渠道,微信内浏览器在开放平台接入前走 Native 支付。

拆分 Native 支付二维码弹窗,展示金额和充值内容并取消无效 code_url 跳转。

修复微信 Native 下单 time_expire 秒级 RFC3339 格式,避免小数秒参数错误。

新增充值订单 5 分钟过期调度,过期前查询微信真实订单状态。

补充微信授权绑定、路由加载恢复和后台表时间显示等配套逻辑。

更新 SpacetimeDB schema、绑定和相关文档测试。
2026-07-08 21:35:02 +08:00

357 lines
11 KiB
TypeScript

import QRCode from 'qrcode';
import { useEffect, useState } from 'react';
import type {
ProfileRechargeCenterResponse,
ProfileRechargeProduct,
} from '../../../packages/shared/src/contracts/runtime';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
import { PlatformOptionSegment } from '../common/PlatformSegmentedTabPresets';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformProfileModalShell } from './PlatformProfileModalShell';
import type {
NativeWechatPaymentState,
RechargeTab,
} from './usePlatformProfileCenterController';
import { buildMembershipLabel } from '../rpg-entry/rpgEntryProfileFundsViewModel';
import { formatSnapshotTime } from '../rpg-entry/rpgEntryProfileDashboardPresentation';
import {
buildRechargeProductValueLabel,
formatRechargePrice,
} from '../rpg-entry/rpgEntryProfileFundsViewModel';
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
const RECHARGE_TAB_ITEMS: Array<{ id: RechargeTab; label: string }> = [
{ id: 'points', label: '泥点充值' },
{ id: 'membership', label: '会员卡' },
];
export type PlatformProfileRechargeModalProps = {
center: ProfileRechargeCenterResponse | null;
isLoading: boolean;
error: string | null;
submittingProductId: string | null;
nativePayment: NativeWechatPaymentState | null;
activeTab: RechargeTab;
onTabChange: (tab: RechargeTab) => void;
onClose: () => void;
onRetry: () => void;
onBuy: (product: ProfileRechargeProduct) => void;
onConfirmNativePayment: () => void;
onCloseNativePayment: () => void;
};
/**
* 生成微信 Native 支付二维码图片,保持首页现有二维码尺寸与容错行为。
*/
function useWechatNativeQrCode(codeUrl: string | null) {
const [qrImageUrl, setQrImageUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setQrImageUrl(null);
if (!codeUrl) {
return () => {
cancelled = true;
};
}
void QRCode.toDataURL(codeUrl, {
errorCorrectionLevel: 'M',
margin: 1,
width: WECHAT_NATIVE_PAY_QR_IMAGE_SIZE,
}).then((dataUrl) => {
if (!cancelled) {
setQrImageUrl(dataUrl);
}
});
return () => {
cancelled = true;
};
}, [codeUrl]);
return qrImageUrl;
}
function formatNativePaymentRemaining(remainingMs: number) {
const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function useNativePaymentRemaining(expiresAt: string) {
const resolveRemainingMs = () => {
const expiresAtMs = Date.parse(expiresAt);
if (!Number.isFinite(expiresAtMs)) {
return 0;
}
return Math.max(0, expiresAtMs - Date.now());
};
const [remainingMs, setRemainingMs] = useState(resolveRemainingMs);
useEffect(() => {
setRemainingMs(resolveRemainingMs());
const timer = window.setInterval(() => {
setRemainingMs(resolveRemainingMs());
}, 1000);
return () => window.clearInterval(timer);
}, [expiresAt]);
return {
isExpired: remainingMs <= 0,
label: formatNativePaymentRemaining(remainingMs),
};
}
/**
* 充值套餐卡片,沿用 RPG 首页当前视觉和交互语义。
*/
function RechargeProductCard({
product,
submittingProductId,
onBuy,
}: {
product: ProfileRechargeProduct;
submittingProductId: string | null;
onBuy: (product: ProfileRechargeProduct) => void;
}) {
const submitting = submittingProductId === product.productId;
const badgeLabel = product.badgeLabel;
const value = buildRechargeProductValueLabel(product);
return (
<PlatformSubpanel
as="button"
type="button"
surface="platform"
onClick={() => onBuy(product)}
disabled={Boolean(submittingProductId)}
interactive
radius="sm"
padding="none"
className="platform-interactive-card relative min-h-[7.25rem] px-3.5 py-3.5 text-left"
>
{badgeLabel ? (
<PlatformPillBadge
tone="warning"
size="xxs"
className="absolute right-3 top-3 max-w-[7rem] truncate px-2 py-0.5 tracking-[0.18em]"
>
{badgeLabel}
</PlatformPillBadge>
) : null}
<div className="pr-20 text-sm font-black text-[var(--platform-text-strong)]">
{product.title}
</div>
<div className="mt-3 text-2xl font-black text-[var(--platform-text-strong)]">
{value}
</div>
<div className="mt-2 flex items-center justify-between gap-3">
<span className="text-sm font-bold text-[var(--platform-text-soft)]">
{formatRechargePrice(product.priceCents)}
</span>
<span className="platform-primary-button rounded-full px-3 py-1.5 text-xs font-black">
{submitting ? '处理中' : '购买'}
</span>
</div>
</PlatformSubpanel>
);
}
function PlatformProfileWechatNativePaymentModal({
nativePayment,
nativeQrImageUrl,
onClose,
onConfirm,
}: {
nativePayment: NativeWechatPaymentState;
nativeQrImageUrl: string | null;
onClose: () => void;
onConfirm: () => void;
}) {
const remaining = useNativePaymentRemaining(nativePayment.expiresAt);
return (
<PlatformProfileModalShell
title="微信扫码支付"
description="请使用微信扫描二维码完成付款"
onClose={onClose}
closeLabel="关闭微信扫码支付"
closeDisabled={nativePayment.isConfirming}
size="sm"
zIndexClassName="z-[85]"
panelClassName="platform-recharge-modal !max-w-sm rounded-[1.4rem]"
bodyClassName="px-5 py-5"
>
<div className="text-center">
<div className="mb-4 rounded-2xl border border-white/10 bg-white/6 px-4 py-3 text-left">
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
支付项目
</div>
<div className="mt-1 text-sm font-black text-[var(--platform-text-strong)]">
{nativePayment.productTitle}
</div>
<div className="mt-3 text-xs font-bold text-[var(--platform-text-soft)]">
支付金额
</div>
<div className="mt-1 text-2xl font-black text-[var(--platform-text-strong)]">
{formatRechargePrice(nativePayment.amountCents)}
</div>
<div className="mt-3 flex items-center justify-between gap-3 text-xs font-bold text-[var(--platform-text-soft)]">
<span>剩余时间</span>
<span className="tabular-nums">
{remaining.isExpired ? '已过期' : remaining.label}
</span>
</div>
</div>
<div className="mx-auto flex h-[180px] w-[180px] items-center justify-center rounded-xl bg-white p-2">
{nativeQrImageUrl ? (
<img
src={nativeQrImageUrl}
alt="微信 Native 支付二维码"
className="h-full w-full"
/>
) : (
<span className="text-xs font-semibold text-slate-500">生成中</span>
)}
</div>
<PlatformActionButton
surface="profile"
size="xs"
className="mt-4 disabled:cursor-not-allowed"
onClick={onConfirm}
disabled={nativePayment.isConfirming || remaining.isExpired}
>
{nativePayment.isConfirming ? '确认中' : '我已支付'}
</PlatformActionButton>
{nativePayment.confirmMessage ? (
<div className="mt-3 text-xs font-semibold text-[var(--platform-text-soft)]">
{nativePayment.confirmMessage}
</div>
) : null}
</div>
</PlatformProfileModalShell>
);
}
/**
* 个人中心充值弹窗,共享给不同入口复用,但保持现有 props 与文案不变。
*/
export function PlatformProfileRechargeModal({
center,
isLoading,
error,
submittingProductId,
nativePayment,
activeTab,
onTabChange,
onClose,
onRetry,
onBuy,
onConfirmNativePayment,
onCloseNativePayment,
}: PlatformProfileRechargeModalProps) {
const nativeQrImageUrl = useWechatNativeQrCode(
nativePayment?.codeUrl ?? null,
);
const products =
activeTab === 'points'
? (center?.pointProducts ?? [])
: (center?.membershipProducts ?? []);
const memberLabel = buildMembershipLabel(
center?.membership,
formatSnapshotTime,
);
return (
<>
<PlatformProfileModalShell
title="账户充值"
description={
center ? `${center.walletBalance}泥点 · ${memberLabel}` : '读取中'
}
onClose={onClose}
closeLabel="关闭账户充值"
size="md"
panelClassName="platform-recharge-modal !max-w-[34rem] rounded-[1.4rem]"
bodyClassName="max-h-[min(76vh,36rem)] overflow-y-auto px-5 py-5"
>
<PlatformOptionSegment
items={RECHARGE_TAB_ITEMS}
activeId={activeTab}
onChange={onTabChange}
variant="profile"
ariaLabel="充值类型"
/>
<PlatformAsyncStatePanel
errorState={
error ? (
<PlatformStatusMessage
tone="error"
surface="profile"
size="xs"
className="mt-4 rounded-2xl font-semibold"
>
<div>{error}</div>
<PlatformActionButton
surface="profile"
size="xs"
className="mt-3"
onClick={onRetry}
>
重新加载
</PlatformActionButton>
</PlatformStatusMessage>
) : null
}
isLoading={isLoading}
loadingState={
<PlatformProfileSkeletonList
count={4}
containerClassName="mt-4 grid gap-3 sm:grid-cols-2"
itemClassName="h-28 rounded-[1.15rem] bg-white/10"
/>
}
isEmpty={products.length === 0}
emptyState={
<PlatformEmptyState
surface="subpanel"
size="inline"
className="mt-4"
>
暂无可购买套餐
</PlatformEmptyState>
}
>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{products.map((product) => (
<RechargeProductCard
key={product.productId}
product={product}
submittingProductId={submittingProductId}
onBuy={onBuy}
/>
))}
</div>
</PlatformAsyncStatePanel>
</PlatformProfileModalShell>
{nativePayment ? (
<PlatformProfileWechatNativePaymentModal
nativePayment={nativePayment}
nativeQrImageUrl={nativeQrImageUrl}
onClose={onCloseNativePayment}
onConfirm={onConfirmNativePayment}
/>
) : null}
</>
);
}