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

@@ -17,14 +17,12 @@ import type {
PublicUserSummary,
} from '../../../packages/shared/src/contracts/auth';
import type {
CreateProfileRechargeOrderResponse,
ProfileReferralInviteCenterResponse,
ProfileTaskCenterResponse,
} from '../../../packages/shared/src/contracts/runtime';
import { AuthUiContext } from '../auth/AuthUiContext';
import {
ICP_RECORD_NUMBER,
ICP_RECORD_URL,
} from '../common/legalDocuments';
import { ICP_RECORD_NUMBER, ICP_RECORD_URL } from '../common/legalDocuments';
import {
RpgEntryHomeView,
type RpgEntryHomeViewProps,
@@ -41,7 +39,9 @@ const {
mockBuildReferralCenter,
mockBuildTaskCenter,
mockClaimRpgProfileTaskReward,
mockCreateRpgProfileRechargeOrder,
mockGetRpgProfileReferralInviteCenter,
mockGetRpgProfileRechargeCenter,
mockGetRpgProfileTasks,
mockGetRpgProfileWalletLedger,
mockRedeemRpgProfileReferralInviteCode,
@@ -137,6 +137,88 @@ const {
},
center: buildClaimedTaskCenter(),
})),
mockGetRpgProfileRechargeCenter: vi.fn(async () => ({
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [
{
productId: 'points_60',
title: '60光点',
priceCents: 600,
kind: 'points',
pointsAmount: 60,
bonusPoints: 60,
durationDays: 0,
badgeLabel: '首充双倍',
description: '首充送60光点',
tier: 'normal',
},
],
membershipProducts: [
{
productId: 'member_month',
title: '月卡',
priceCents: 2800,
kind: 'membership',
pointsAmount: 0,
bonusPoints: 0,
durationDays: 30,
badgeLabel: '',
description: '30天会员',
tier: 'month',
},
],
benefits: [
{
benefitName: '免光点回合数',
normalValue: '30',
monthValue: '100',
seasonValue: '100',
yearValue: '100',
},
],
latestOrder: null,
hasPointsRecharged: false,
})),
mockCreateRpgProfileRechargeOrder: vi.fn(
async (): Promise<CreateProfileRechargeOrderResponse> => ({
order: {
orderId: 'order-1',
productId: 'points_60',
productTitle: '60光点',
kind: 'points',
amountCents: 600,
status: 'paid',
paymentChannel: 'mock',
paidAt: '2026-04-25T10:00:00Z',
providerTransactionId: null,
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,
},
}),
),
mockRedeemRpgProfileReferralInviteCode: vi.fn(async () => ({
center: buildReferralCenter({
invitedUsers: [],
@@ -219,85 +301,8 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger,
claimRpgProfileTaskReward: mockClaimRpgProfileTaskReward,
redeemRpgProfileReferralInviteCode: mockRedeemRpgProfileReferralInviteCode,
getRpgProfileRechargeCenter: vi.fn(async () => ({
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [
{
productId: 'points_60',
title: '60光点',
priceCents: 600,
kind: 'points',
pointsAmount: 60,
bonusPoints: 60,
durationDays: 0,
badgeLabel: '首充双倍',
description: '首充送60光点',
tier: 'normal',
},
],
membershipProducts: [
{
productId: 'member_month',
title: '月卡',
priceCents: 2800,
kind: 'membership',
pointsAmount: 0,
bonusPoints: 0,
durationDays: 30,
badgeLabel: '',
description: '30天会员',
tier: 'month',
},
],
benefits: [
{
benefitName: '免光点回合数',
normalValue: '30',
monthValue: '100',
seasonValue: '100',
yearValue: '100',
},
],
latestOrder: null,
hasPointsRecharged: false,
})),
createRpgProfileRechargeOrder: vi.fn(async () => ({
order: {
orderId: 'order-1',
productId: 'points_60',
productTitle: '60光点',
kind: 'points',
amountCents: 600,
status: 'paid',
paymentChannel: 'mock',
paidAt: '2026-04-25T10:00:00Z',
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,
},
})),
getRpgProfileRechargeCenter: mockGetRpgProfileRechargeCenter,
createRpgProfileRechargeOrder: mockCreateRpgProfileRechargeOrder,
}));
vi.mock('../ResolvedAssetImage', () => ({
@@ -906,6 +911,106 @@ test('opens wallet ledger modal from narrative coin card', async () => {
expect(screen.getByText('+30')).toBeTruthy();
});
test('profile recharge modal buys points through mock channel outside mini program', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
renderProfileView(onRechargeSuccess);
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
expect(await screen.findByText('账户充值')).toBeTruthy();
expect(mockGetRpgProfileRechargeCenter).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole('button', { name: /60/u }));
await waitFor(() => {
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
'points_60',
'mock',
);
});
expect(await screen.findByText('已到账')).toBeTruthy();
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
});
test('profile recharge modal posts requestPayment params in mini program web-view', async () => {
const user = userEvent.setup();
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'));
});
window.wx = {
miniProgram: {
navigateTo,
},
};
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-wechat-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(() => {
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
'points_60',
'wechat_mp',
);
});
expect(navigateTo).toHaveBeenCalledWith({
url: expect.stringContaining('/pages/wechat-pay/index?'),
fail: expect.any(Function),
});
const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
expect(navigateUrl).toContain('order-wechat-1');
expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay');
expect(await screen.findByText('支付已提交')).toBeTruthy();
});
test('profile daily task shortcut opens task center and claims reward', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
@@ -1136,22 +1241,29 @@ test('profile page shows legal entries and ICP record link', async () => {
expect(
shortcutRegion.querySelector('.grid')?.className.includes('grid-cols-3'),
).toBe(true);
expect(within(shortcutRegion).getByRole('button', { name: //u }))
.toBeTruthy();
expect(within(shortcutRegion).getByRole('button', { name: //u }))
.toBeTruthy();
expect(within(shortcutRegion).getByRole('button', { name: //u }))
.toBeTruthy();
expect(within(shortcutRegion).getByRole('button', { name: //u }))
.toBeTruthy();
expect(
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();
expect(
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();
expect(
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();
expect(
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();
const legalRegion = screen.getByRole('region', { name: '法律信息' });
expect(within(legalRegion).getByRole('button', { name: //u }))
.toBeTruthy();
expect(within(legalRegion).getByRole('button', { name: //u }))
.toBeTruthy();
expect(within(legalRegion).getByRole('button', { name: //u }))
.toBeTruthy();
expect(
within(legalRegion).getByRole('button', { name: //u }),
).toBeTruthy();
expect(
within(legalRegion).getByRole('button', { name: //u }),
).toBeTruthy();
expect(
within(legalRegion).getByRole('button', { name: //u }),
).toBeTruthy();
const recordLink = within(legalRegion).getByRole('link', {
name: ICP_RECORD_NUMBER,
@@ -1160,7 +1272,9 @@ test('profile page shows legal entries and ICP record link', async () => {
expect(recordLink.getAttribute('target')).toBe('_blank');
expect(recordLink.getAttribute('rel')).toBe('noreferrer');
await user.click(within(legalRegion).getByRole('button', { name: //u }));
await user.click(
within(legalRegion).getByRole('button', { name: //u }),
);
expect(await screen.findByRole('dialog', { name: '隐私政策' })).toBeTruthy();
});
@@ -1423,7 +1537,8 @@ test('mobile discover keeps baby object match works in edutainment channel only'
await user.click(babyObjectMatchButton);
expect(onOpenGalleryDetail).toHaveBeenCalledWith(babyObjectMatchEntry);
const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
const searchInput =
screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
await user.type(searchInput, '宝贝识物水果篮{enter}');
expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy();
expect(within(discoverPanel).queryByText('宝贝识物水果篮')).toBeNull();

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}

View File

@@ -538,8 +538,9 @@ describe('authService', () => {
const sessions = await getAuthSessions();
expect(sessions).toHaveLength(1);
expect(sessions[0].sessionIds).toEqual(['usess_1', 'usess_2']);
expect(sessions[0].sessionCount).toBe(2);
const [session] = sessions;
expect(session?.sessionIds).toEqual(['usess_1', 'usess_2']);
expect(session?.sessionCount).toBe(2);
});
it('revokes a single auth session by backend route', async () => {

View File

@@ -90,6 +90,7 @@ export function getRpgProfileRechargeCenter(
export function createRpgProfileRechargeOrder(
productId: string,
paymentChannel = 'mock',
options: RuntimeRequestOptions = {},
) {
return requestRpgRuntimeJson<CreateProfileRechargeOrderResponse>(
@@ -97,7 +98,7 @@ export function createRpgProfileRechargeOrder(
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId, paymentChannel: 'mock' }),
body: JSON.stringify({ productId, paymentChannel }),
},
'充值失败',
options,

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

@@ -3,3 +3,15 @@
interface ImportMetaEnv {
readonly VITE_DEBUG_MODE?: string;
}
interface Window {
wx?: {
miniProgram?: {
navigateTo?: (options: {
url: string;
fail?: (error: { errMsg?: string }) => void;
}) => void;
postMessage?: (message: unknown) => void;
};
};
}