This commit is contained in:
2026-05-15 02:41:43 +08:00
39 changed files with 2539 additions and 200 deletions

View File

@@ -17,6 +17,7 @@ import type {
PublicUserSummary,
} from '../../../packages/shared/src/contracts/auth';
import type {
ConfirmWechatProfileRechargeOrderResponse,
CreateProfileRechargeOrderResponse,
ProfileReferralInviteCenterResponse,
ProfileTaskCenterResponse,
@@ -39,6 +40,7 @@ const {
mockBuildReferralCenter,
mockBuildTaskCenter,
mockClaimRpgProfileTaskReward,
mockConfirmWechatRpgProfileRechargeOrder,
mockCreateRpgProfileRechargeOrder,
mockGetRpgProfileReferralInviteCenter,
mockGetRpgProfileRechargeCenter,
@@ -219,6 +221,65 @@ const {
},
}),
),
mockConfirmWechatRpgProfileRechargeOrder: vi.fn(
async (): Promise<ConfirmWechatProfileRechargeOrderResponse> => ({
order: {
orderId: 'order-wechat-1',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'paid',
paymentChannel: 'wechat_mp',
paidAt: '2026-04-25T10:01:00Z',
providerTransactionId: 'wx-transaction-1',
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 120,
membershipExpiresAt: null,
},
center: {
walletBalance: 120,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [
{
productId: 'points_60',
title: '60泥点',
priceCents: 600,
kind: 'points',
pointsAmount: 60,
bonusPoints: 0,
durationDays: 0,
badgeLabel: '',
description: '60泥点',
tier: 'normal',
},
],
membershipProducts: [],
benefits: [],
latestOrder: {
orderId: 'order-wechat-1',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'paid',
paymentChannel: 'wechat_mp',
providerTransactionId: 'wx-transaction-1',
createdAt: '2026-04-25T10:00:00Z',
paidAt: '2026-04-25T10:01:00Z',
pointsDelta: 120,
membershipExpiresAt: null,
},
hasPointsRecharged: true,
},
}),
),
mockRedeemRpgProfileReferralInviteCode: vi.fn(async () => ({
center: buildReferralCenter({
invitedUsers: [],
@@ -303,6 +364,8 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
redeemRpgProfileReferralInviteCode: mockRedeemRpgProfileReferralInviteCode,
getRpgProfileRechargeCenter: mockGetRpgProfileRechargeCenter,
createRpgProfileRechargeOrder: mockCreateRpgProfileRechargeOrder,
confirmWechatRpgProfileRechargeOrder:
mockConfirmWechatRpgProfileRechargeOrder,
}));
vi.mock('../ResolvedAssetImage', () => ({
@@ -859,6 +922,10 @@ afterEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
window.wx = undefined;
document
.querySelectorAll('script[src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"]')
.forEach((script) => script.remove());
mockGetRpgProfileReferralInviteCenter.mockResolvedValue(
mockBuildReferralCenter(),
);
@@ -949,7 +1016,10 @@ test('profile recharge modal buys points through mock channel outside mini progr
const onRechargeSuccess = vi.fn();
renderProfileView(onRechargeSuccess);
await user.click(screen.getByRole('button', { name: /\s*\//u }));
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
expect(await screen.findByText('账户充值')).toBeTruthy();
expect(mockGetRpgProfileRechargeCenter).toHaveBeenCalledTimes(1);
@@ -961,18 +1031,19 @@ test('profile recharge modal buys points through mock channel outside mini progr
'mock',
);
});
expect(await screen.findByText('已到账')).toBeTruthy();
expect(
await screen.findByRole('dialog', { name: '支付成功' }),
).toBeTruthy();
expect(screen.getByText('已到账,账户状态已刷新。')).toBeTruthy();
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
});
test('profile recharge modal posts requestPayment params in mini program web-view', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
const navigateTo = vi.fn((options: { url: string }) => {
const url = new URL(`https://mini.test${options.url}`);
const requestId = url.searchParams.get('requestId');
window.location.hash = `wx_pay_result=${requestId}:success`;
window.dispatchEvent(new HashChangeEvent('hashchange'));
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
options.success?.();
});
window.wx = {
miniProgram: {
@@ -1018,8 +1089,11 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
},
});
renderProfileView();
await user.click(screen.getByRole('button', { name: /\s*\//u }));
renderProfileView(onRechargeSuccess);
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
await user.click(await screen.findByRole('button', { name: /60/u }));
await waitFor(() => {
@@ -1030,12 +1104,352 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
});
expect(navigateTo).toHaveBeenCalledWith({
url: expect.stringContaining('/pages/wechat-pay/index?'),
success: expect.any(Function),
fail: expect.any(Function),
});
const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get(
'requestId',
);
expect(requestId).toBeTruthy();
act(() => {
window.location.hash = `wx_pay_result=${requestId}:success`;
window.dispatchEvent(new HashChangeEvent('hashchange'));
});
expect(navigateUrl).toContain('order-wechat-1');
expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay');
expect(await screen.findByText('支付已提交')).toBeTruthy();
expect(
await screen.findByRole('dialog', { name: '支付成功' }),
).toBeTruthy();
expect(screen.getByText('已到账,账户状态已刷新。')).toBeTruthy();
expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith(
'order-wechat-1',
);
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
});
test('profile recharge modal waits for paid confirmation before refreshing dashboard', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
options.success?.();
});
window.wx = {
miniProgram: {
navigateTo,
},
};
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-wechat-pending-then-paid',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp',
paidAt: null as string | null,
providerTransactionId: null,
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 0,
membershipExpiresAt: null,
},
center: {
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
},
wechatMiniProgramPayParams: {
timeStamp: '1777110165',
nonceStr: 'nonce',
package: 'prepay_id=wx-prepay',
signType: 'RSA',
paySign: 'signature',
},
});
mockConfirmWechatRpgProfileRechargeOrder
.mockResolvedValueOnce({
order: {
orderId: 'order-wechat-pending-then-paid',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp',
paidAt: null,
providerTransactionId: null,
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 0,
membershipExpiresAt: null,
},
center: {
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
},
})
.mockResolvedValueOnce({
order: {
orderId: 'order-wechat-pending-then-paid',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'paid' as const,
paymentChannel: 'wechat_mp',
paidAt: '2026-04-25T10:01:00Z',
providerTransactionId: 'wx-transaction-2',
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 120,
membershipExpiresAt: null,
},
center: {
walletBalance: 120,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: true,
},
});
renderProfileView(onRechargeSuccess);
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
await user.click(await screen.findByRole('button', { name: /60/u }));
const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get(
'requestId',
);
expect(requestId).toBeTruthy();
await act(async () => {
window.location.hash = `wx_pay_result=${requestId}:success`;
window.dispatchEvent(new HashChangeEvent('hashchange'));
});
expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledTimes(1);
expect(onRechargeSuccess).not.toHaveBeenCalled();
await waitFor(() => {
expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledTimes(2);
});
expect(
await screen.findByRole('dialog', { name: '支付成功' }),
).toBeTruthy();
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
});
test('profile recharge modal loads wechat js sdk before mini program payment bridge', async () => {
const user = userEvent.setup();
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
window.wx = undefined;
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
options.success?.();
});
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-wechat-sdk-1',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp',
paidAt: null as string | null,
providerTransactionId: null,
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 0,
membershipExpiresAt: null,
},
center: {
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
},
wechatMiniProgramPayParams: {
timeStamp: '1777110165',
nonceStr: 'nonce',
package: 'prepay_id=wx-prepay',
signType: 'RSA',
paySign: 'signature',
},
});
renderProfileView();
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
await user.click(await screen.findByRole('button', { name: /60/u }));
await waitFor(() => {
const script = document.querySelector<HTMLScriptElement>(
'script[src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"]',
);
expect(script).toBeTruthy();
window.wx = {
miniProgram: {
navigateTo,
},
};
script?.dispatchEvent(new Event('load'));
});
await waitFor(() => {
expect(navigateTo).toHaveBeenCalledWith({
url: expect.stringContaining('/pages/wechat-pay/index?'),
success: expect.any(Function),
fail: expect.any(Function),
});
});
const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get(
'requestId',
);
expect(requestId).toBeTruthy();
act(() => {
window.location.hash = `wx_pay_result=${requestId}:success`;
window.dispatchEvent(new HashChangeEvent('hashchange'));
});
expect(
await screen.findByRole('dialog', { name: '支付成功' }),
).toBeTruthy();
});
test('profile recharge modal releases submitting state after cancelled wechat pay result', async () => {
const user = userEvent.setup();
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
options.success?.();
});
window.wx = {
miniProgram: {
navigateTo,
},
};
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-wechat-cancel-1',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp',
paidAt: null as string | null,
providerTransactionId: null,
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 0,
membershipExpiresAt: null,
},
center: {
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
},
wechatMiniProgramPayParams: {
timeStamp: '1777110165',
nonceStr: 'nonce',
package: 'prepay_id=wx-prepay-cancel',
signType: 'RSA',
paySign: 'signature',
},
});
renderProfileView();
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
const buyButton = await screen.findByRole('button', { name: /60/u });
await user.click(buyButton);
await waitFor(() => {
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
'points_60',
'wechat_mp',
);
});
expect(
within(buyButton).getByText('处理中', { selector: 'span' }),
).toBeTruthy();
const requestUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
const requestId = new URL(`https://mini.test${requestUrl}`).searchParams.get(
'requestId',
);
expect(requestId).toBeTruthy();
act(() => {
window.location.hash = `wx_pay_result=${requestId}:cancel`;
window.dispatchEvent(new HashChangeEvent('hashchange'));
});
expect(
await screen.findByRole('dialog', { name: '支付已取消' }),
).toBeTruthy();
expect(screen.getByText('本次没有扣款,账户状态未发生变化。')).toBeTruthy();
await waitFor(() => {
expect(
within(screen.getByRole('button', { name: /60/u })).getByText(
'购买',
{ selector: 'span' },
),
).toBeTruthy();
});
expect(mockConfirmWechatRpgProfileRechargeOrder).not.toHaveBeenCalled();
});
test('profile daily task shortcut opens task center and claims reward', async () => {
@@ -1296,9 +1710,6 @@ test('profile page shows legal entries and ICP record link', async () => {
expect(
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();
expect(
within(shortcutRegion).queryByRole('button', { name: //u }),
).toBeNull();
expect(
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();

View File

@@ -1,7 +1,9 @@
import {
ArrowRight,
AlertCircle,
BookOpen,
Camera,
CheckCircle2,
ChevronDown,
ChevronRight,
Clock3,
@@ -27,6 +29,7 @@ import {
Ticket,
UserPlus,
UserRound,
XCircle,
} from 'lucide-react';
import {
type ComponentType,
@@ -46,6 +49,7 @@ import type { PublicUserSummary } from '../../../packages/shared/src/contracts/a
import type {
CustomWorldLibraryEntry,
PlatformBrowseHistoryEntry,
ConfirmWechatProfileRechargeOrderResponse,
ProfileDashboardCardKey,
ProfileDashboardSummary,
ProfilePlayedWorkSummary,
@@ -71,6 +75,7 @@ import {
import { copyTextToClipboard } from '../../services/clipboard';
import {
claimRpgProfileTaskReward,
confirmWechatRpgProfileRechargeOrder,
createRpgProfileRechargeOrder,
getRpgProfileReferralInviteCenter,
getRpgProfileRechargeCenter,
@@ -213,10 +218,23 @@ 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';
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
type RechargeTab = 'points' | 'membership';
type WechatMiniProgramPaymentStatus = 'success' | 'fail' | 'cancel';
type WechatPayResult = {
requestId: string;
orderId: string | null;
status: WechatMiniProgramPaymentStatus;
};
type RechargePaymentResultKind = 'success' | 'pending' | 'cancel' | 'failed';
type RechargePaymentResult = {
kind: RechargePaymentResultKind;
title: string;
message: string;
};
type DiscoverChannel =
| 'recommend'
| 'today'
@@ -2348,54 +2366,136 @@ function clearWechatPayResultHash() {
window.history.replaceState(null, '', nextUrl);
}
function requestWechatMiniProgramPayment(
function readWechatPayResultFromHash(): WechatPayResult | null {
if (typeof window === 'undefined') {
return null;
}
const result = new URLSearchParams(
window.location.hash.replace(/^#/, ''),
).get('wx_pay_result');
if (!result) {
return null;
}
const [requestId = '', rawStatus = ''] = result.split(':');
const orderId = requestId
.replace(/^wechat_pay_/, '')
.replace(/_\d+$/, '')
.trim();
const status =
rawStatus === 'success'
? 'success'
: rawStatus === 'cancel'
? 'cancel'
: 'fail';
return {
requestId,
orderId: orderId || null,
status,
};
}
function loadWechatJsSdk() {
if (typeof window === 'undefined') {
return Promise.reject(new Error('请在微信小程序内完成支付'));
}
if (window.wx?.miniProgram?.navigateTo) {
return Promise.resolve(window.wx);
}
return new Promise<NonNullable<Window['wx']>>((resolve, reject) => {
const existingScript = document.querySelector<HTMLScriptElement>(
`script[src="${WECHAT_JS_SDK_URL}"]`,
);
const complete = () => {
if (window.wx?.miniProgram?.navigateTo) {
resolve(window.wx);
} else {
reject(new Error('请在微信小程序内完成支付'));
}
};
if (existingScript) {
existingScript.addEventListener('load', complete, { once: true });
existingScript.addEventListener(
'error',
() => reject(new Error('请在微信小程序内完成支付')),
{ once: true },
);
complete();
return;
}
const script = document.createElement('script');
script.src = WECHAT_JS_SDK_URL;
script.async = true;
script.onload = complete;
script.onerror = () => reject(new Error('请在微信小程序内完成支付'));
document.head.appendChild(script);
});
}
async function requestWechatMiniProgramPayment(
payload: WechatMiniProgramPayParams | null | undefined,
orderId: string,
) {
const miniProgram = window.wx?.miniProgram;
if (
!payload ||
!miniProgram ||
typeof miniProgram.navigateTo !== 'function'
) {
): Promise<void> {
if (!payload) {
return Promise.reject(new Error('请在微信小程序内完成支付'));
}
const wxBridge = await loadWechatJsSdk();
const miniProgram = wxBridge.miniProgram;
if (!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);
const requestId = `wechat_pay_${orderId}_${Date.now()}`;
return new Promise<void>((resolve, reject) => {
navigateTo({
url: `/pages/wechat-pay/index?requestId=${encodeURIComponent(requestId)}&orderId=${encodeURIComponent(orderId)}&payParams=${encodeURIComponent(JSON.stringify(payload))}`,
success() {
resolve();
},
fail(error) {
window.removeEventListener('hashchange', handleHashChange);
console.error('[wechat-pay] navigateTo failed', error);
resolve('fail');
reject(
error instanceof Error
? error
: new Error('请在微信小程序内完成支付'),
);
},
});
});
}
function waitWechatPayConfirmDelay(delayMs: number) {
return new Promise<void>((resolve) => {
window.setTimeout(resolve, delayMs);
});
}
async function confirmWechatRechargeOrderUntilSettled(
orderId: string,
): Promise<ConfirmWechatProfileRechargeOrderResponse> {
let latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId);
if (latestResponse.order.status === 'paid') {
return latestResponse;
}
for (const delayMs of WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS) {
await waitWechatPayConfirmDelay(delayMs);
latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId);
if (latestResponse.order.status === 'paid') {
return latestResponse;
}
}
return latestResponse;
}
function RechargeProductCard({
product,
submittingProductId,
@@ -2445,7 +2545,6 @@ function ProfileRechargeModal({
center,
isLoading,
error,
success,
submittingProductId,
activeTab,
onTabChange,
@@ -2456,7 +2555,6 @@ function ProfileRechargeModal({
center: ProfileRechargeCenterResponse | null;
isLoading: boolean;
error: string | null;
success: string | null;
submittingProductId: string | null;
activeTab: RechargeTab;
onTabChange: (tab: RechargeTab) => void;
@@ -2526,11 +2624,6 @@ function ProfileRechargeModal({
</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">
@@ -2563,6 +2656,62 @@ function ProfileRechargeModal({
);
}
function RechargePaymentResultModal({
result,
onClose,
}: {
result: RechargePaymentResult;
onClose: () => void;
}) {
const Icon =
result.kind === 'success'
? CheckCircle2
: result.kind === 'cancel'
? XCircle
: AlertCircle;
const iconClass =
result.kind === 'success'
? 'text-[var(--platform-success-text)]'
: result.kind === 'cancel'
? 'text-[var(--platform-text-soft)]'
: 'text-[var(--platform-button-danger-text)]';
return (
<div className="platform-modal-backdrop fixed inset-0 z-[90] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby="recharge-payment-result-title"
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.4rem]"
>
<div className="px-5 pb-5 pt-6 text-center">
<div
className={`mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-white/10 ${iconClass}`}
>
<Icon className="h-8 w-8" aria-hidden="true" />
</div>
<div
id="recharge-payment-result-title"
className="mt-4 text-xl font-black text-[var(--platform-text-strong)]"
>
{result.title}
</div>
<div className="mt-3 text-sm font-semibold leading-6 text-[var(--platform-text-soft)]">
{result.message}
</div>
<button
type="button"
onClick={onClose}
className="platform-primary-button mt-5 w-full rounded-2xl px-4 py-3 text-sm font-black"
>
</button>
</div>
</div>
</div>
);
}
function WalletLedgerModal({
ledger,
fallbackBalance,
@@ -3269,7 +3418,8 @@ export function RpgEntryHomeView({
useState<ProfileRechargeCenterResponse | null>(null);
const [isLoadingRechargeCenter, setIsLoadingRechargeCenter] = useState(false);
const [rechargeError, setRechargeError] = useState<string | null>(null);
const [rechargeSuccess, setRechargeSuccess] = useState<string | null>(null);
const [rechargePaymentResult, setRechargePaymentResult] =
useState<RechargePaymentResult | null>(null);
const [activeRechargeTab, setActiveRechargeTab] =
useState<RechargeTab>('points');
const [submittingRechargeProductId, setSubmittingRechargeProductId] =
@@ -3335,6 +3485,7 @@ export function RpgEntryHomeView({
useState<LegalDocumentId | null>(null);
const profileCopyResetTimerRef = useRef<number | null>(null);
const avatarFileInputRef = useRef<HTMLInputElement | null>(null);
const pendingWechatRechargeOrderIdRef = useRef<string | null>(null);
const [isNicknameModalOpen, setIsNicknameModalOpen] = useState(false);
const [nicknameInput, setNicknameInput] = useState('');
const [nicknameError, setNicknameError] = useState<string | null>(null);
@@ -3790,6 +3941,87 @@ export function RpgEntryHomeView({
})
.finally(() => setIsLoadingRechargeCenter(false));
};
const refreshRechargeState = useCallback(
() => {
loadRechargeCenter();
setSubmittingRechargeProductId(null);
pendingWechatRechargeOrderIdRef.current = null;
},
[loadRechargeCenter],
);
const handleWechatPayResult = useCallback(() => {
const payResult = readWechatPayResultFromHash();
if (!payResult) {
return;
}
if (
pendingWechatRechargeOrderIdRef.current &&
payResult.orderId &&
payResult.orderId !== pendingWechatRechargeOrderIdRef.current
) {
return;
}
if (payResult.status === 'success') {
setRechargePaymentResult({
kind: 'pending',
title: '支付已提交',
message: '正在确认到账状态,请稍后查看余额或会员状态。',
});
if (payResult.orderId) {
void confirmWechatRechargeOrderUntilSettled(payResult.orderId)
.then((response) => {
const isPaid = response.order.status === 'paid';
setRechargeCenter(response.center);
setRechargePaymentResult(
isPaid
? {
kind: 'success',
title: '支付成功',
message: '已到账,账户状态已刷新。',
}
: {
kind: 'pending',
title: '支付已提交',
message: '正在等待微信支付确认,请稍后查看账户状态。',
},
);
if (isPaid) {
void onRechargeSuccess?.();
}
setSubmittingRechargeProductId(null);
pendingWechatRechargeOrderIdRef.current = null;
})
.catch(() => {
setRechargePaymentResult({
kind: 'pending',
title: '支付已提交',
message: '暂时没能确认到账状态,请稍后查看余额或会员状态。',
});
refreshRechargeState();
});
} else {
refreshRechargeState();
}
} else if (payResult.status === 'cancel') {
setRechargePaymentResult({
kind: 'cancel',
title: '支付已取消',
message: '本次没有扣款,账户状态未发生变化。',
});
refreshRechargeState();
} else {
setRechargePaymentResult({
kind: 'failed',
title: '支付未完成',
message: '微信支付没有完成,本次不会入账。',
});
refreshRechargeState();
}
clearWechatPayResultHash();
}, [onRechargeSuccess, refreshRechargeState]);
const openRechargeModal = () => {
if (!authUi?.user) {
authUi?.openLoginModal();
@@ -3797,7 +4029,6 @@ export function RpgEntryHomeView({
}
setIsRechargeOpen(true);
setRechargeSuccess(null);
loadRechargeCenter();
};
const buyRechargeProduct = (product: ProfileRechargeProduct) => {
@@ -3810,67 +4041,51 @@ export function RpgEntryHomeView({
: '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(
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
await requestWechatMiniProgramPayment(
response.wechatMiniProgramPayParams,
response.order.orderId,
);
if (status === 'cancel') {
setRechargeCenter(response.center);
setRechargeSuccess('支付已取消');
return;
}
if (status !== 'success') {
throw new Error('微信支付未完成');
}
setRechargeSuccess('支付已提交');
loadRechargeCenter();
setRechargeCenter(response.center);
return;
} else {
setRechargeCenter(response.center);
setRechargeSuccess('已到账');
setRechargePaymentResult({
kind: 'success',
title: '支付成功',
message: '已到账,账户状态已刷新。',
});
pendingWechatRechargeOrderIdRef.current = null;
setSubmittingRechargeProductId(null);
}
void onRechargeSuccess?.();
})
.catch((error: unknown) => {
pendingWechatRechargeOrderIdRef.current = null;
setRechargeError(error instanceof Error ? error.message : '充值失败');
})
.finally(() => setSubmittingRechargeProductId(null));
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();
}
const handleResume = () => {
handleWechatPayResult();
};
window.addEventListener('hashchange', handleWechatPayResult);
handleWechatPayResult();
return () =>
window.removeEventListener('hashchange', handleWechatPayResult);
}, [isRechargeOpen, onRechargeSuccess]);
window.addEventListener('hashchange', handleResume);
window.addEventListener('focus', handleResume);
window.addEventListener('pageshow', handleResume);
document.addEventListener('visibilitychange', handleResume);
handleResume();
return () => {
window.removeEventListener('hashchange', handleResume);
window.removeEventListener('focus', handleResume);
window.removeEventListener('pageshow', handleResume);
document.removeEventListener('visibilitychange', handleResume);
};
}, [handleWechatPayResult]);
const loadTaskCenter = () => {
setTaskCenterError(null);
setIsLoadingTaskCenter(true);
@@ -5656,7 +5871,6 @@ export function RpgEntryHomeView({
center={rechargeCenter}
isLoading={isLoadingRechargeCenter}
error={rechargeError}
success={rechargeSuccess}
submittingProductId={submittingRechargeProductId}
activeTab={activeRechargeTab}
onTabChange={setActiveRechargeTab}
@@ -5665,6 +5879,12 @@ export function RpgEntryHomeView({
onBuy={buyRechargeProduct}
/>
) : null;
const rechargePaymentResultModal: ReactNode = rechargePaymentResult ? (
<RechargePaymentResultModal
result={rechargePaymentResult}
onClose={() => setRechargePaymentResult(null)}
/>
) : null;
if (!isDesktopLayout) {
const isMobileRecommendTab = activeTab === 'home';
@@ -5748,6 +5968,7 @@ export function RpgEntryHomeView({
) : null}
{rewardCodeModal}
{rechargeModal}
{rechargePaymentResultModal}
{isTaskCenterOpen ? (
<ProfileTaskCenterModal
center={taskCenter}
@@ -5879,6 +6100,7 @@ export function RpgEntryHomeView({
</div>
{rewardCodeModal}
{rechargeModal}
{rechargePaymentResultModal}
{isTaskCenterOpen ? (
<ProfileTaskCenterModal
center={taskCenter}

View File

@@ -1,4 +1,5 @@
import type {
ConfirmWechatProfileRechargeOrderResponse,
CreateProfileRechargeOrderResponse,
ClaimProfileTaskRewardResponse,
PlatformBrowseHistoryBatchSyncRequest,
@@ -105,6 +106,18 @@ export function createRpgProfileRechargeOrder(
);
}
export function confirmWechatRpgProfileRechargeOrder(
orderId: string,
options: RuntimeRequestOptions = {},
) {
return requestRpgRuntimeJson<ConfirmWechatProfileRechargeOrderResponse>(
`/profile/recharge/orders/${encodeURIComponent(orderId)}/wechat/confirm`,
{ method: 'POST' },
'确认微信支付订单失败',
options,
);
}
export function submitRpgProfileFeedback(
payload: SubmitProfileFeedbackRequest,
options: RuntimeRequestOptions = {},
@@ -305,6 +318,7 @@ export const rpgProfileClient = {
getWalletLedger: getRpgProfileWalletLedger,
getRechargeCenter: getRpgProfileRechargeCenter,
createRechargeOrder: createRpgProfileRechargeOrder,
confirmWechatRechargeOrder: confirmWechatRpgProfileRechargeOrder,
submitFeedback: submitRpgProfileFeedback,
getReferralInviteCenter: getRpgProfileReferralInviteCenter,
redeemReferralInviteCode: redeemRpgProfileReferralInviteCode,

2
src/vite-env.d.ts vendored
View File

@@ -9,9 +9,11 @@ interface Window {
miniProgram?: {
navigateTo?: (options: {
url: string;
success?: (result?: unknown) => void;
fail?: (error: { errMsg?: string }) => void;
}) => void;
postMessage?: (message: unknown) => void;
};
};
WeixinJSBridge?: unknown;
}