Merge branch 'master' of https://git.genarrative.world/GenarrativeAI/Genarrative
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
2
src/vite-env.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user