feat: 接入微信小程序支付

This commit is contained in:
2026-05-14 00:16:17 +08:00
parent bf4423e53b
commit ae58a443a3
42 changed files with 2265 additions and 191 deletions

View File

@@ -50,6 +50,9 @@ import type {
ProfilePlayedWorkSummary,
ProfilePlayStatsResponse,
ProfileReferralInviteCenterResponse,
ProfileRechargeCenterResponse,
ProfileRechargeProduct,
WechatMiniProgramPayParams,
ProfileSaveArchiveSummary,
ProfileTaskCenterResponse,
ProfileTaskItem,
@@ -67,7 +70,9 @@ import {
import { copyTextToClipboard } from '../../services/clipboard';
import {
claimRpgProfileTaskReward,
createRpgProfileRechargeOrder,
getRpgProfileReferralInviteCenter,
getRpgProfileRechargeCenter,
getRpgProfileTasks,
getRpgProfileWalletLedger,
redeemRpgProfileReferralInviteCode,
@@ -199,8 +204,11 @@ const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36;
const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180;
const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
const WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL = 'wechat_mp';
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
type RechargeTab = 'points' | 'membership';
type WechatMiniProgramPaymentStatus = 'success' | 'fail' | 'cancel';
type DiscoverChannel =
| 'recommend'
| 'today'
@@ -2141,7 +2149,9 @@ function ProfileLegalSection({
type="button"
onClick={() => onOpenDocument(document.id)}
className={`flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--platform-button-secondary-fill)] ${
index > 0 ? 'border-t border-[var(--platform-subpanel-border)]' : ''
index > 0
? 'border-t border-[var(--platform-subpanel-border)]'
: ''
}`}
>
<span className="flex min-w-0 items-center gap-3">
@@ -2484,6 +2494,254 @@ function formatWalletLedgerAmount(amountDelta: number) {
return amountDelta > 0 ? `+${amountDelta}` : `${amountDelta}`;
}
function formatRechargePrice(priceCents: number) {
const yuan = priceCents / 100;
return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`;
}
function isWechatMiniProgramWebView() {
if (typeof window === 'undefined') {
return false;
}
const params = new URLSearchParams(window.location.search);
return (
params.get('clientRuntime') === 'wechat_mini_program' ||
params.get('clientType') === 'mini_program'
);
}
function clearWechatPayResultHash() {
if (typeof window === 'undefined') {
return;
}
const rawHash = window.location.hash.replace(/^#/, '');
if (!rawHash.includes('wx_pay_result=')) {
return;
}
const params = new URLSearchParams(rawHash);
params.delete('wx_pay_result');
const nextHash = params.toString();
const nextUrl = `${window.location.pathname}${window.location.search}${nextHash ? `#${nextHash}` : ''}`;
window.history.replaceState(null, '', nextUrl);
}
function requestWechatMiniProgramPayment(
payload: WechatMiniProgramPayParams | null | undefined,
orderId: string,
) {
const miniProgram = window.wx?.miniProgram;
if (
!payload ||
!miniProgram ||
typeof miniProgram.navigateTo !== 'function'
) {
return Promise.reject(new Error('请在微信小程序内完成支付'));
}
const navigateTo = miniProgram.navigateTo;
return new Promise<WechatMiniProgramPaymentStatus>((resolve) => {
const requestId = `wechat_pay_${orderId}_${Date.now()}`;
const handleHashChange = () => {
const params = new URLSearchParams(
window.location.hash.replace(/^#/, ''),
);
const result = params.get('wx_pay_result') ?? '';
const [resultRequestId, status] = result.split(':');
if (resultRequestId !== requestId) {
return;
}
window.removeEventListener('hashchange', handleHashChange);
resolve(
status === 'success'
? 'success'
: status === 'cancel'
? 'cancel'
: 'fail',
);
};
window.addEventListener('hashchange', handleHashChange);
navigateTo({
url: `/pages/wechat-pay/index?requestId=${encodeURIComponent(requestId)}&orderId=${encodeURIComponent(orderId)}&payParams=${encodeURIComponent(JSON.stringify(payload))}`,
fail(error) {
window.removeEventListener('hashchange', handleHashChange);
console.error('[wechat-pay] navigateTo failed', error);
resolve('fail');
},
});
});
}
function RechargeProductCard({
product,
submittingProductId,
onBuy,
}: {
product: ProfileRechargeProduct;
submittingProductId: string | null;
onBuy: (product: ProfileRechargeProduct) => void;
}) {
const submitting = submittingProductId === product.productId;
const value =
product.kind === 'points'
? `${product.pointsAmount}${product.bonusPoints > 0 ? `+${product.bonusPoints}` : ''}光点`
: `${product.durationDays}`;
return (
<button
type="button"
onClick={() => onBuy(product)}
disabled={Boolean(submittingProductId)}
className="platform-subpanel platform-interactive-card relative min-h-[7.25rem] rounded-[1.15rem] px-3.5 py-3.5 text-left disabled:cursor-not-allowed disabled:opacity-60"
>
{product.badgeLabel ? (
<span className="platform-pill platform-pill--warm absolute right-3 top-3 max-w-[7rem] truncate px-2 py-0.5 text-[10px]">
{product.badgeLabel}
</span>
) : 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>
</button>
);
}
function ProfileRechargeModal({
center,
isLoading,
error,
success,
submittingProductId,
activeTab,
onTabChange,
onClose,
onRetry,
onBuy,
}: {
center: ProfileRechargeCenterResponse | null;
isLoading: boolean;
error: string | null;
success: string | null;
submittingProductId: string | null;
activeTab: RechargeTab;
onTabChange: (tab: RechargeTab) => void;
onClose: () => void;
onRetry: () => void;
onBuy: (product: ProfileRechargeProduct) => void;
}) {
const products =
activeTab === 'points'
? (center?.pointProducts ?? [])
: (center?.membershipProducts ?? []);
const memberLabel =
center?.membership.status === 'active'
? center.membership.expiresAt
? `会员至 ${formatSnapshotTime(center.membership.expiresAt)}`
: '会员已生效'
: '普通用户';
return (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div className="platform-recharge-modal w-full max-w-[34rem] overflow-hidden rounded-[1.4rem]">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div>
<div className="text-base font-black"></div>
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]">
{center
? `${center.walletBalance}光点 · ${memberLabel}`
: '读取中'}
</div>
</div>
<button
type="button"
aria-label="关闭账户充值"
onClick={onClose}
className="platform-modal-close flex h-9 w-9 items-center justify-center rounded-full"
>
×
</button>
</div>
<div className="max-h-[min(76vh,36rem)] overflow-y-auto px-5 py-5">
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => onTabChange('points')}
className={`platform-category-chip justify-center ${activeTab === 'points' ? 'platform-category-chip--active' : ''}`}
>
</button>
<button
type="button"
onClick={() => onTabChange('membership')}
className={`platform-category-chip justify-center ${activeTab === 'membership' ? 'platform-category-chip--active' : ''}`}
>
</button>
</div>
{error ? (
<div className="platform-profile-error mt-4 rounded-2xl px-3 py-2 text-xs font-semibold">
<div>{error}</div>
<button
type="button"
onClick={onRetry}
className="platform-primary-button mt-3 rounded-2xl px-4 py-2 text-xs font-black"
>
</button>
</div>
) : null}
{success ? (
<div className="platform-profile-success mt-4 rounded-2xl px-3 py-2 text-xs font-semibold">
{success}
</div>
) : null}
{isLoading ? (
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="h-28 animate-pulse rounded-[1.15rem] bg-white/10"
/>
))}
</div>
) : products.length > 0 ? (
<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>
) : (
<div className="platform-subpanel mt-4 rounded-2xl px-4 py-8 text-center text-sm font-semibold text-[var(--platform-text-soft)]">
</div>
)}
</div>
</div>
</div>
);
}
function WalletLedgerModal({
ledger,
fallbackBalance,
@@ -3184,6 +3442,16 @@ export function RpgEntryHomeView({
const [rewardCodeSuccess, setRewardCodeSuccess] = useState<string | null>(
null,
);
const [isRechargeOpen, setIsRechargeOpen] = useState(false);
const [rechargeCenter, setRechargeCenter] =
useState<ProfileRechargeCenterResponse | null>(null);
const [isLoadingRechargeCenter, setIsLoadingRechargeCenter] = useState(false);
const [rechargeError, setRechargeError] = useState<string | null>(null);
const [rechargeSuccess, setRechargeSuccess] = useState<string | null>(null);
const [activeRechargeTab, setActiveRechargeTab] =
useState<RechargeTab>('points');
const [submittingRechargeProductId, setSubmittingRechargeProductId] =
useState<string | null>(null);
const [isWalletLedgerOpen, setIsWalletLedgerOpen] = useState(false);
const [walletLedger, setWalletLedger] =
useState<ProfileWalletLedgerResponse | null>(null);
@@ -3725,6 +3993,100 @@ export function RpgEntryHomeView({
setIsWalletLedgerOpen(true);
loadWalletLedger();
};
const loadRechargeCenter = () => {
setRechargeError(null);
setIsLoadingRechargeCenter(true);
void getRpgProfileRechargeCenter()
.then(setRechargeCenter)
.catch((error: unknown) => {
setRechargeCenter(null);
setRechargeError(
error instanceof Error ? error.message : '读取账户充值失败',
);
})
.finally(() => setIsLoadingRechargeCenter(false));
};
const openRechargeModal = () => {
if (!authUi?.user) {
authUi?.openLoginModal();
return;
}
setIsRechargeOpen(true);
setRechargeSuccess(null);
loadRechargeCenter();
};
const buyRechargeProduct = (product: ProfileRechargeProduct) => {
if (submittingRechargeProductId) {
return;
}
const paymentChannel = isWechatMiniProgramWebView()
? WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL
: 'mock';
setSubmittingRechargeProductId(product.productId);
setRechargeError(null);
setRechargeSuccess(null);
void createRpgProfileRechargeOrder(product.productId, paymentChannel)
.then(async (response) => {
if (paymentChannel === WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL) {
const status = await requestWechatMiniProgramPayment(
response.wechatMiniProgramPayParams,
response.order.orderId,
);
if (status === 'cancel') {
setRechargeCenter(response.center);
setRechargeSuccess('支付已取消');
return;
}
if (status !== 'success') {
throw new Error('微信支付未完成');
}
setRechargeSuccess('支付已提交');
loadRechargeCenter();
} else {
setRechargeCenter(response.center);
setRechargeSuccess('已到账');
}
void onRechargeSuccess?.();
})
.catch((error: unknown) => {
setRechargeError(error instanceof Error ? error.message : '充值失败');
})
.finally(() => setSubmittingRechargeProductId(null));
};
useEffect(() => {
if (!isRechargeOpen) {
return undefined;
}
const handleWechatPayResult = () => {
const result = new URLSearchParams(
window.location.hash.replace(/^#/, ''),
).get('wx_pay_result');
if (!result) {
return;
}
const [, status] = result.split(':');
if (status === 'success') {
setRechargeSuccess('支付已提交');
loadRechargeCenter();
void onRechargeSuccess?.();
clearWechatPayResultHash();
} else if (status === 'cancel') {
setRechargeSuccess('支付已取消');
clearWechatPayResultHash();
} else {
setRechargeError('微信支付未完成');
clearWechatPayResultHash();
}
};
window.addEventListener('hashchange', handleWechatPayResult);
handleWechatPayResult();
return () =>
window.removeEventListener('hashchange', handleWechatPayResult);
}, [isRechargeOpen, onRechargeSuccess]);
const loadTaskCenter = () => {
setTaskCenterError(null);
setIsLoadingTaskCenter(true);
@@ -4919,13 +5281,13 @@ export function RpgEntryHomeView({
<button
type="button"
onClick={openRewardCodeModal}
onClick={openRechargeModal}
className="platform-profile-action flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-left"
>
<Ticket className="h-4 w-4" />
<Coins className="h-4 w-4" />
<div>
<div className="text-xs font-bold"></div>
<div className="text-[10px] opacity-80"></div>
<div className="text-xs font-bold"></div>
<div className="text-[10px] opacity-80">/</div>
</div>
<ChevronRight className="h-4 w-4 opacity-80" />
</button>
@@ -5013,6 +5375,18 @@ export function RpgEntryHomeView({
icon={Star}
onClick={openTaskCenterPanel}
/>
<ProfileShortcutButton
label="充值"
subLabel="光点/会员"
icon={Coins}
onClick={openRechargeModal}
/>
<ProfileShortcutButton
label="兑换码"
subLabel="福利奖励"
icon={Ticket}
onClick={openRewardCodeModal}
/>
<ProfileShortcutButton
label="邀请好友"
subLabel={
@@ -5455,6 +5829,20 @@ export function RpgEntryHomeView({
onClose={() => setIsRewardCodeOpen(false)}
/>
) : null;
const rechargeModal: ReactNode = isRechargeOpen ? (
<ProfileRechargeModal
center={rechargeCenter}
isLoading={isLoadingRechargeCenter}
error={rechargeError}
success={rechargeSuccess}
submittingProductId={submittingRechargeProductId}
activeTab={activeRechargeTab}
onTabChange={setActiveRechargeTab}
onClose={() => setIsRechargeOpen(false)}
onRetry={loadRechargeCenter}
onBuy={buyRechargeProduct}
/>
) : null;
if (!isDesktopLayout) {
const isMobileRecommendTab = activeTab === 'home';
@@ -5537,6 +5925,7 @@ export function RpgEntryHomeView({
/>
) : null}
{rewardCodeModal}
{rechargeModal}
{isTaskCenterOpen ? (
<ProfileTaskCenterModal
center={taskCenter}
@@ -5667,6 +6056,7 @@ export function RpgEntryHomeView({
</div>
</div>
{rewardCodeModal}
{rechargeModal}
{isTaskCenterOpen ? (
<ProfileTaskCenterModal
center={taskCenter}