Merge branch 'master' into codex/puzzle-clear-template-runtime-fixes

# Conflicts:
#	.hermes/shared-memory/decision-log.md
#	.hermes/shared-memory/project-overview.md
#	docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
#	scripts/dev.test.ts
#	server-rs/crates/api-server/src/creation_entry_config.rs
#	server-rs/crates/api-server/src/wooden_fish.rs
#	server-rs/crates/module-auth/src/lib.rs
#	server-rs/crates/spacetime-client/src/wooden_fish.rs
#	server-rs/crates/spacetime-module/src/auth/procedures.rs
#	src/components/custom-world-home/creationWorkShelf.ts
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
#	src/components/rpg-entry/rpgEntryWorldPresentation.ts
#	src/services/miniGameDraftGenerationProgress.test.ts
#	src/services/miniGameDraftGenerationProgress.ts
This commit is contained in:
2026-06-04 11:24:14 +08:00
451 changed files with 18452 additions and 5266 deletions

View File

@@ -49,6 +49,7 @@ const {
mockGetRpgProfileTasks,
mockGetRpgProfileWalletLedger,
mockRedeemRpgProfileReferralInviteCode,
mockWatchWechatRpgProfileRechargeOrder,
} = vi.hoisted(() => {
const qrCodeToDataUrl = vi.fn(async () => 'data:image/png;base64,QR');
const redirectToPaymentUrl = vi.fn();
@@ -235,7 +236,7 @@ const {
kind: 'points',
amountCents: 600,
status: 'paid',
paymentChannel: 'wechat_mp',
paymentChannel: 'wechat_mp_virtual',
createdAt: '2026-04-25T10:00:00Z',
paidAt: '2026-04-25T10:01:00Z',
providerTransactionId: 'wx-transaction-1',
@@ -274,7 +275,7 @@ const {
kind: 'points',
amountCents: 600,
status: 'paid',
paymentChannel: 'wechat_mp',
paymentChannel: 'wechat_mp_virtual',
createdAt: '2026-04-25T10:00:00Z',
providerTransactionId: 'wx-transaction-1',
paidAt: '2026-04-25T10:01:00Z',
@@ -313,6 +314,7 @@ const {
},
],
})),
mockWatchWechatRpgProfileRechargeOrder: vi.fn(async () => null),
};
});
@@ -379,6 +381,7 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
createRpgProfileRechargeOrder: mockCreateRpgProfileRechargeOrder,
confirmWechatRpgProfileRechargeOrder:
mockConfirmWechatRpgProfileRechargeOrder,
watchWechatRpgProfileRechargeOrder: mockWatchWechatRpgProfileRechargeOrder,
}));
vi.mock('../ResolvedAssetImage', () => ({
@@ -1336,7 +1339,7 @@ test('profile recharge modal trusts per-product first bonus display after points
expect(screen.getByText('60+60泥点')).toBeTruthy();
});
test('profile recharge modal posts requestPayment params in mini program web-view', async () => {
test('profile recharge modal posts virtual payment params in mini program web-view', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
@@ -1356,7 +1359,7 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp',
paymentChannel: 'wechat_mp_virtual',
createdAt: '2026-04-25T10:00:00Z',
paidAt: null as string | null,
providerTransactionId: null,
@@ -1379,11 +1382,11 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
hasPointsRecharged: false,
},
wechatMiniProgramPayParams: {
timeStamp: '1777110165',
nonceStr: 'nonce',
package: 'prepay_id=wx-prepay',
signType: 'RSA',
paySign: 'signature',
mode: 'short_series_coin',
signData:
'{"offerId":"offer-1","buyQuantity":1,"env":0,"currencyType":"CNY","outTradeNo":"order-wechat-1","attach":"mud_points_60"}',
paySig: 'pay-sig',
signature: 'user-sig',
},
});
@@ -1394,7 +1397,7 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
await waitFor(() => {
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
'points_60',
'wechat_mp',
'wechat_mp_virtual',
);
});
expect(navigateTo).toHaveBeenCalledWith({
@@ -1407,12 +1410,13 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
'requestId',
);
expect(requestId).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '正在支付' })).toBeNull();
act(() => {
window.location.hash = `wx_pay_result=${requestId}:success`;
window.location.hash = `wx_pay_result=${requestId}:success:order-wechat-1`;
window.dispatchEvent(new HashChangeEvent('hashchange'));
});
expect(navigateUrl).toContain('order-wechat-1');
expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay');
expect(decodeURIComponent(navigateUrl)).toContain('short_series_coin');
expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
expect(mockCreateRpgProfileRechargeOrder).not.toHaveBeenCalledWith(
'points_60',
@@ -1426,6 +1430,368 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
});
test('profile recharge modal posts membership goods virtual payment 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; success?: () => void }) => {
options.success?.();
});
window.wx = {
miniProgram: {
navigateTo,
},
};
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-member-virtual-1',
productId: 'member_month',
productTitle: '月卡',
kind: 'membership',
amountCents: 2800,
status: 'pending' as const,
paymentChannel: 'wechat_mp_virtual',
paidAt: null as string | null,
providerTransactionId: null,
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 0,
membershipExpiresAt: '2026-06-25T10:00:00Z',
},
center: {
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
},
wechatMiniProgramPayParams: {
mode: 'short_series_goods',
signData:
'{"offerId":"offer-1","buyQuantity":1,"env":0,"currencyType":"CNY","productId":"member_month","goodsPrice":2800,"outTradeNo":"order-member-virtual-1","attach":"member_month"}',
paySig: 'pay-sig',
signature: 'user-sig',
},
});
renderProfileView();
await openRechargeModal(user);
await user.click(screen.getByRole('button', { name: '会员卡' }));
await user.click(await screen.findByRole('button', { name: //u }));
await waitFor(() => {
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
'member_month',
'wechat_mp_virtual',
);
});
const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get(
'requestId',
);
expect(requestId).toBeTruthy();
const payParams = JSON.parse(
new URL(`https://mini.test${navigateUrl}`).searchParams.get('payParams') ?? '{}',
);
const signData = JSON.parse(payParams.signData);
expect(payParams.mode).toBe('short_series_goods');
expect(signData.productId).toBe('member_month');
expect(signData.goodsPrice).toBe(2800);
expect(decodeURIComponent(navigateUrl)).toContain('"paySig":"pay-sig"');
});
test('profile recharge modal releases submitting state and shows virtual payment failure detail', 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-sandbox-fail',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp_virtual',
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: {
mode: 'short_series_coin',
signData:
'{"offerId":"offer-1","buyQuantity":1,"env":1,"currencyType":"CNY","outTradeNo":"order-wechat-sandbox-fail","attach":"mud_points_60"}',
paySig: 'sandbox-pay-sig',
signature: 'user-sig',
},
});
renderProfileView();
await openRechargeModal(user);
const buyButton = await screen.findByRole('button', { name: /60/u });
await user.click(buyButton);
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}:fail:order-wechat-sandbox-fail:${encodeURIComponent('{"errCode":-1,"errMsg":"requestVirtualPayment:fail sandbox"}')}`;
window.dispatchEvent(new HashChangeEvent('hashchange'));
});
expect(
await screen.findByRole('dialog', { name: '支付未完成' }),
).toBeTruthy();
expect(
screen.getByText(/requestVirtualPayment:fail sandbox/u),
).toBeTruthy();
await waitFor(() => {
expect(
within(screen.getByRole('button', { name: /60/u })).getByText(
'购买',
{ selector: 'span' },
),
).toBeTruthy();
});
});
test('profile recharge modal eventually shows error text even when hashchange is not dispatched', 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-delayed-fail',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp_virtual',
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: {
mode: 'short_series_coin',
signData:
'{"offerId":"offer-1","buyQuantity":1,"env":1,"currencyType":"CNY","outTradeNo":"order-wechat-delayed-fail","attach":"mud_points_60"}',
paySig: 'sandbox-pay-sig',
signature: 'user-sig',
},
});
renderProfileView();
await openRechargeModal(user);
const buyButton = await screen.findByRole('button', { name: /60/u });
await user.click(buyButton);
const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get(
'requestId',
);
expect(requestId).toBeTruthy();
window.location.hash = `wx_pay_result=${requestId}:fail:order-wechat-delayed-fail:${encodeURIComponent('{"errCode":-1,"errMsg":"requestVirtualPayment:fail delayed"}')}`;
expect(
await screen.findByRole('dialog', { name: '支付未完成' }),
).toBeTruthy();
expect(screen.getByText(/requestVirtualPayment:fail delayed/u)).toBeTruthy();
});
test('profile recharge modal keeps polling long enough for late success result', 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-late-success',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp_virtual',
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: {
mode: 'short_series_coin',
signData:
'{"offerId":"offer-1","buyQuantity":60,"env":0,"currencyType":"CNY","outTradeNo":"order-wechat-late-success","attach":"mud_points_60"}',
paySig: 'pay-sig',
signature: 'user-sig',
},
});
mockConfirmWechatRpgProfileRechargeOrder
.mockResolvedValueOnce({
order: {
orderId: 'order-wechat-late-success',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp_virtual',
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-late-success',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'paid' as const,
paymentChannel: 'wechat_mp_virtual',
paidAt: '2026-04-25T10:01:00Z',
providerTransactionId: 'wx-transaction-late',
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);
await openRechargeModal(user);
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 new Promise((resolve) => window.setTimeout(resolve, 2600));
act(() => {
window.location.hash = `wx_pay_result=${requestId}:success:order-wechat-late-success`;
window.dispatchEvent(new HashChangeEvent('hashchange'));
});
await waitFor(() => {
expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledTimes(2);
});
expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
}, 12000);
test('profile recharge modal waits for paid confirmation before refreshing dashboard', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
@@ -1446,7 +1812,7 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp',
paymentChannel: 'wechat_mp_virtual',
createdAt: '2026-04-25T10:00:00Z',
paidAt: null as string | null,
providerTransactionId: null,
@@ -1469,11 +1835,11 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb
hasPointsRecharged: false,
},
wechatMiniProgramPayParams: {
timeStamp: '1777110165',
nonceStr: 'nonce',
package: 'prepay_id=wx-prepay',
signType: 'RSA',
paySign: 'signature',
mode: 'short_series_coin',
signData:
'{"offerId":"offer-1","buyQuantity":1,"env":0,"currencyType":"CNY","outTradeNo":"order-wechat-pending-then-paid","attach":"mud_points_60"}',
paySig: 'pay-sig',
signature: 'user-sig',
},
});
mockConfirmWechatRpgProfileRechargeOrder
@@ -1485,7 +1851,7 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp',
paymentChannel: 'wechat_mp_virtual',
createdAt: '2026-04-25T10:00:00Z',
paidAt: null,
providerTransactionId: null,
@@ -1516,7 +1882,7 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb
kind: 'points',
amountCents: 600,
status: 'paid' as const,
paymentChannel: 'wechat_mp',
paymentChannel: 'wechat_mp_virtual',
createdAt: '2026-04-25T10:00:00Z',
paidAt: '2026-04-25T10:01:00Z',
providerTransactionId: 'wx-transaction-2',
@@ -1565,22 +1931,27 @@ test('profile recharge modal waits for paid confirmation before refreshing dashb
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
});
test('profile recharge modal loads wechat js sdk before mini program payment bridge', async () => {
test('profile recharge modal confirms virtual payment after returning without hash result', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
window.wx = undefined;
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
options.success?.();
});
window.wx = {
miniProgram: {
navigateTo,
},
};
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-wechat-sdk-1',
orderId: 'order-wechat-no-hash-paid',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp',
paymentChannel: 'wechat_mp_virtual',
createdAt: '2026-04-25T10:00:00Z',
paidAt: null as string | null,
providerTransactionId: null,
@@ -1603,11 +1974,234 @@ test('profile recharge modal loads wechat js sdk before mini program payment bri
hasPointsRecharged: false,
},
wechatMiniProgramPayParams: {
timeStamp: '1777110165',
nonceStr: 'nonce',
package: 'prepay_id=wx-prepay',
signType: 'RSA',
paySign: 'signature',
mode: 'short_series_coin',
signData:
'{"offerId":"offer-1","buyQuantity":60,"env":0,"currencyType":"CNY","outTradeNo":"order-wechat-no-hash-paid","attach":"mud_points_60"}',
paySig: 'pay-sig',
signature: 'user-sig',
},
});
mockConfirmWechatRpgProfileRechargeOrder
.mockResolvedValueOnce({
order: {
orderId: 'order-wechat-no-hash-paid',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp_virtual',
createdAt: '2026-04-25T10:00:00Z',
paidAt: null,
providerTransactionId: null,
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-no-hash-paid',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'paid' as const,
paymentChannel: 'wechat_mp_virtual',
createdAt: '2026-04-25T10:00:00Z',
paidAt: '2026-04-25T10:01:00Z',
providerTransactionId: 'wx-transaction-no-hash',
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);
await openRechargeModal(user);
await user.click(await screen.findByRole('button', { name: /60/u }));
act(() => {
window.dispatchEvent(new PageTransitionEvent('pageshow'));
});
expect(screen.getByRole('dialog', { name: '正在确认支付' })).toBeTruthy();
await waitFor(() => {
expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith(
'order-wechat-no-hash-paid',
);
});
expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
});
test('profile recharge modal blocks tab navigation while virtual payment confirmation is pending', 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-confirm-mask',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp_virtual',
createdAt: '2026-04-25T10:00:00Z',
paidAt: null as string | null,
providerTransactionId: null,
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: {
mode: 'short_series_coin',
signData:
'{"offerId":"offer-1","buyQuantity":60,"env":0,"currencyType":"CNY","outTradeNo":"order-wechat-confirm-mask","attach":"mud_points_60"}',
paySig: 'pay-sig',
signature: 'user-sig',
},
});
mockConfirmWechatRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-wechat-confirm-mask',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp_virtual',
createdAt: '2026-04-25T10:00:00Z',
paidAt: null,
providerTransactionId: null,
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,
},
});
mockWatchWechatRpgProfileRechargeOrder.mockReturnValueOnce(new Promise(() => undefined));
renderProfileView();
await openRechargeModal(user);
await user.click(await screen.findByRole('button', { name: /60/u }));
act(() => {
window.dispatchEvent(new PageTransitionEvent('pageshow'));
});
expect(screen.getByRole('dialog', { name: '正在确认支付' })).toBeTruthy();
expect(
(screen.getByRole('button', { name: '创作' }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
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_virtual',
createdAt: '2026-04-25T10:00:00Z',
paidAt: null as string | null,
providerTransactionId: null,
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: {
mode: 'short_series_coin',
signData:
'{"offerId":"offer-1","buyQuantity":1,"env":0,"currencyType":"CNY","outTradeNo":"order-wechat-sdk-1","attach":"mud_points_60"}',
paySig: 'pay-sig',
signature: 'user-sig',
},
});
@@ -1666,7 +2260,7 @@ test('profile recharge modal releases submitting state after cancelled wechat pa
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp',
paymentChannel: 'wechat_mp_virtual',
createdAt: '2026-04-25T10:00:00Z',
paidAt: null as string | null,
providerTransactionId: null,
@@ -1689,11 +2283,11 @@ test('profile recharge modal releases submitting state after cancelled wechat pa
hasPointsRecharged: false,
},
wechatMiniProgramPayParams: {
timeStamp: '1777110165',
nonceStr: 'nonce',
package: 'prepay_id=wx-prepay-cancel',
signType: 'RSA',
paySign: 'signature',
mode: 'short_series_coin',
signData:
'{"offerId":"offer-1","buyQuantity":1,"env":0,"currencyType":"CNY","outTradeNo":"order-wechat-cancel-1","attach":"mud_points_60"}',
paySig: 'pay-sig',
signature: 'user-sig',
},
});
@@ -1705,7 +2299,7 @@ test('profile recharge modal releases submitting state after cancelled wechat pa
await waitFor(() => {
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
'points_60',
'wechat_mp',
'wechat_mp_virtual',
);
});
expect(
@@ -2049,6 +2643,21 @@ test('mobile profile page matches the reference layout sections', async () => {
.querySelector('.platform-profile-shortcut-grid')
?.classList.contains('platform-profile-shortcut-grid'),
).toBe(true);
expect(
shortcutRegion
.querySelector('.platform-profile-shortcut-grid')
?.className,
).toContain('!grid-cols-4');
expect(
shortcutRegion
.querySelector('.platform-profile-shortcut-grid')
?.className,
).toContain('w-full');
for (const shortcutButton of shortcutRegion.querySelectorAll(
'.platform-profile-shortcut-button',
)) {
expect(shortcutButton.className).toContain('w-full');
}
for (const label of [
'泥点充值',
'兑换码',
@@ -2528,6 +3137,41 @@ test('logged in create tab shows real wallet balance beside the brand', () => {
expect(topbar?.textContent).toContain('1,234泥点');
});
test('create tab wallet chip opens reward code when recharge entry is hidden', async () => {
const user = userEvent.setup();
mockNarrowMobileLayout();
render(
<ProfileHomeViewHarness
activeTab="create"
profileDashboardOverrides={{ walletBalance: 70 }}
/>,
);
await user.click(screen.getByRole('button', { name: /^70$/u }));
expect(await screen.findByPlaceholderText('输入兑换码')).toBeTruthy();
expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled();
});
test('create tab wallet chip opens recharge when recharge entry is enabled', async () => {
const user = userEvent.setup();
mockWechatDesktopLayout();
render(
<ProfileHomeViewHarness
activeTab="create"
profileDashboardOverrides={{ walletBalance: 70 }}
/>,
);
await user.click(screen.getByRole('button', { name: /^70$/u }));
expect(await screen.findByText('账户充值')).toBeTruthy();
expect(mockGetRpgProfileRechargeCenter).toHaveBeenCalledTimes(1);
expect(screen.queryByPlaceholderText('输入兑换码')).toBeNull();
});
test('mobile discover search submits public work code', async () => {
const user = userEvent.setup();
const onSearchPublicCode = vi.fn();
@@ -2825,6 +3469,34 @@ test('public gallery cards hide work code until detail is opened', async () => {
expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry);
});
test('public gallery cards hide phone masked author and public user code', async () => {
mockDesktopLayout();
const user = userEvent.setup();
const maskedAuthorEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-masked-author',
profileId: 'puzzle-profile-masked-author',
publicWorkCode: 'PZ-MASKED1',
authorDisplayName: '158****3533 · SY-00000003',
worldName: '喜气洋洋',
} satisfies PlatformPublicGalleryCard;
renderStatefulLoggedOutHomeView(
{
latestEntries: [maskedAuthorEntry],
},
true,
);
await user.click(screen.getByRole('button', { name: '发现' }));
const card = screen.getByRole('button', { name: //u });
expect(card).toBeTruthy();
expect(within(card).getByText('公开作者')).toBeTruthy();
expect(within(card).queryByText('158****3533 · SY-00000003')).toBeNull();
expect(within(card).queryByText('158****3533')).toBeNull();
expect(within(card).queryByText('SY-00000003')).toBeNull();
});
test('logged out mobile shell defaults to discover tab', () => {
const { container } = renderStatefulLoggedOutHomeView({
latestEntries: [puzzlePublicEntry],

View File

@@ -16,6 +16,7 @@ import {
Heart,
LogIn,
MessageCircle,
Loader2,
Palette,
Pencil,
Plus,
@@ -74,6 +75,7 @@ import type {
ProfileWalletLedgerResponse,
RedeemProfileRewardCodeResponse,
WechatMiniProgramPayParams,
WechatMiniProgramVirtualPayParams,
WechatNativePayment,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
@@ -86,10 +88,10 @@ import {
} from '../../services/authService';
import { copyTextToClipboard } from '../../services/clipboard';
import {
resolveProfileRechargePaymentChannel,
resolveProfileRechargeProductPaymentChannel,
shouldShowRechargeEntry,
WECHAT_H5_PAYMENT_CHANNEL,
WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL,
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL,
WECHAT_NATIVE_PAYMENT_CHANNEL,
} from '../../services/payment/paymentPlatform';
import { redirectToPaymentUrl } from '../../services/payment/paymentRedirect';
@@ -103,6 +105,7 @@ import {
getRpgProfileWalletLedger,
redeemRpgProfileReferralInviteCode,
redeemRpgProfileRewardCode,
watchWechatRpgProfileRechargeOrder,
} from '../../services/rpg-entry/rpgProfileClient';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
@@ -329,6 +332,7 @@ type WechatPayResult = {
requestId: string;
orderId: string | null;
status: WechatMiniProgramPaymentStatus;
errorMessage: string | null;
};
type RechargePaymentResultKind = 'success' | 'pending' | 'cancel' | 'failed';
type RechargePaymentResult = {
@@ -336,6 +340,11 @@ type RechargePaymentResult = {
title: string;
message: string;
};
type WechatRechargeOrderConfirmationState = {
orderId: string;
};
const WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS = 250;
const WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS = 10000;
function getBarcodeDetectorConstructor(): BarcodeDetectorConstructorLike | null {
const maybeDetector = (globalThis as unknown as {
@@ -1201,6 +1210,7 @@ function PlatformTabButton({
onClick,
emphasized = false,
showDot = false,
disabled = false,
}: {
active: boolean;
label: string;
@@ -1208,6 +1218,7 @@ function PlatformTabButton({
onClick: () => void;
emphasized?: boolean;
showDot?: boolean;
disabled?: boolean;
}) {
const ariaLabel = showDot ? `${label},有新草稿` : label;
@@ -1215,8 +1226,9 @@ function PlatformTabButton({
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel}
className={`platform-bottom-nav__button ${emphasized ? 'platform-bottom-nav__button--primary' : ''} ${active ? 'platform-bottom-nav__button--active' : ''}`}
className={`platform-bottom-nav__button ${emphasized ? 'platform-bottom-nav__button--primary' : ''} ${active ? 'platform-bottom-nav__button--active' : ''} disabled:cursor-not-allowed disabled:opacity-55`}
>
<span className="platform-bottom-nav__button-content">
<span
@@ -1246,6 +1258,7 @@ function DesktopTabButton({
onClick,
emphasized = false,
showDot = false,
disabled = false,
}: {
active: boolean;
label: string;
@@ -1253,6 +1266,7 @@ function DesktopTabButton({
onClick: () => void;
emphasized?: boolean;
showDot?: boolean;
disabled?: boolean;
}) {
const ariaLabel = showDot ? `${label},有新草稿` : label;
@@ -1260,8 +1274,9 @@ function DesktopTabButton({
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel}
className={`platform-desktop-rail__button ${emphasized ? 'platform-desktop-rail__button--primary' : ''} ${active ? 'platform-desktop-rail__button--active' : ''}`}
className={`platform-desktop-rail__button ${emphasized ? 'platform-desktop-rail__button--primary' : ''} ${active ? 'platform-desktop-rail__button--active' : ''} disabled:cursor-not-allowed disabled:opacity-55`}
>
<span className="platform-desktop-rail__icon-shell">
<Icon className="platform-desktop-rail__icon h-[1.1rem] w-[1.1rem]" />
@@ -2434,7 +2449,7 @@ function ProfileShortcutButton({
<button
type="button"
onClick={onClick ?? undefined}
className="platform-profile-shortcut-button flex min-h-[5.25rem] flex-col items-center justify-center gap-2 px-2.5 py-3 text-center transition"
className="platform-profile-shortcut-button flex min-h-[5.25rem] w-full flex-col items-center justify-center gap-2 px-2.5 py-3 text-center transition"
>
<div className="platform-profile-shortcut-button__icon">
{imageSrc ? (
@@ -2679,22 +2694,34 @@ function readWechatPayResultFromHash(): WechatPayResult | null {
return null;
}
const [requestId = '', rawStatus = ''] = result.split(':');
const orderId = requestId
const [requestId = '', rawStatus = '', explicitOrderId = '', ...rawErrors] =
result.split(':');
const inferredOrderId = requestId
.replace(/^wechat_pay_/, '')
.replace(/_\d+$/, '')
.trim();
const orderId = explicitOrderId.trim() || inferredOrderId;
const status =
rawStatus === 'success'
? 'success'
: rawStatus === 'cancel'
? 'cancel'
: 'fail';
let errorMessage: string | null = null;
const rawError = rawErrors.join(':');
if (rawError) {
try {
errorMessage = decodeURIComponent(rawError);
} catch (_error) {
errorMessage = rawError;
}
}
return {
requestId,
orderId: orderId || null,
status,
errorMessage,
};
}
@@ -2739,7 +2766,11 @@ function loadWechatJsSdk() {
}
async function requestWechatMiniProgramPayment(
payload: WechatMiniProgramPayParams | null | undefined,
payload:
| WechatMiniProgramPayParams
| WechatMiniProgramVirtualPayParams
| null
| undefined,
orderId: string,
): Promise<void> {
if (!payload) {
@@ -2781,7 +2812,7 @@ async function confirmWechatRechargeOrderUntilSettled(
orderId: string,
): Promise<ConfirmWechatProfileRechargeOrderResponse> {
let latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId);
if (latestResponse.order.status === 'paid') {
if (latestResponse.order.status !== 'pending') {
return latestResponse;
}
@@ -2789,12 +2820,17 @@ async function confirmWechatRechargeOrderUntilSettled(
await waitWechatPayConfirmDelay(delayMs);
latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId);
if (latestResponse.order.status === 'paid') {
if (latestResponse.order.status !== 'pending') {
return latestResponse;
}
}
return latestResponse;
try {
const streamedResponse = await watchWechatRpgProfileRechargeOrder(orderId);
return streamedResponse;
} catch {
return latestResponse;
}
}
function useWechatNativeQrCode(codeUrl: string | null) {
@@ -3079,6 +3115,35 @@ function RechargePaymentResultModal({
);
}
function RechargePaymentConfirmationMask({
orderId,
}: {
orderId: string;
}) {
return (
<div className="platform-modal-backdrop fixed inset-0 z-[95] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-label="正在确认支付"
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 text-[var(--platform-accent)]">
<Loader2 className="h-8 w-8 animate-spin" aria-hidden="true" />
</div>
<div className="mt-4 text-xl font-black text-[var(--platform-text-strong)]">
</div>
<div className="mt-3 text-sm font-semibold leading-6 text-[var(--platform-text-soft)]">
{orderId}
</div>
</div>
</div>
</div>
);
}
function WalletLedgerModal({
ledger,
fallbackBalance,
@@ -4005,6 +4070,8 @@ export function RpgEntryHomeView({
const [rechargeError, setRechargeError] = useState<string | null>(null);
const [rechargePaymentResult, setRechargePaymentResult] =
useState<RechargePaymentResult | null>(null);
const [wechatRechargeOrderConfirmationState, setWechatRechargeOrderConfirmationState] =
useState<WechatRechargeOrderConfirmationState | null>(null);
const [nativeWechatPayment, setNativeWechatPayment] =
useState<NativeWechatPaymentState | null>(null);
const [activeRechargeTab, setActiveRechargeTab] =
@@ -4085,6 +4152,7 @@ export function RpgEntryHomeView({
const profileCopyResetTimerRef = useRef<number | null>(null);
const avatarFileInputRef = useRef<HTMLInputElement | null>(null);
const pendingWechatRechargeOrderIdRef = useRef<string | null>(null);
const confirmingWechatRechargeOrderIdRef = useRef<string | null>(null);
const [isNicknameModalOpen, setIsNicknameModalOpen] = useState(false);
const [nicknameInput, setNicknameInput] = useState('');
const [nicknameError, setNicknameError] = useState<string | null>(null);
@@ -4575,12 +4643,14 @@ export function RpgEntryHomeView({
loadRechargeCenter();
setSubmittingRechargeProductId(null);
pendingWechatRechargeOrderIdRef.current = null;
confirmingWechatRechargeOrderIdRef.current = null;
setWechatRechargeOrderConfirmationState(null);
setNativeWechatPayment(null);
}, [loadRechargeCenter]);
const handleWechatPayResult = useCallback(() => {
const payResult = readWechatPayResultFromHash();
if (!payResult) {
return;
return false;
}
if (
@@ -4588,68 +4658,131 @@ export function RpgEntryHomeView({
payResult.orderId &&
payResult.orderId !== pendingWechatRechargeOrderIdRef.current
) {
return;
return false;
}
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();
const orderId = payResult.orderId || pendingWechatRechargeOrderIdRef.current;
if (!orderId) {
clearWechatPayResultHash();
return true;
}
if (confirmingWechatRechargeOrderIdRef.current === orderId) {
clearWechatPayResultHash();
return true;
}
confirmingWechatRechargeOrderIdRef.current = orderId;
setWechatRechargeOrderConfirmationState({ orderId });
setSubmittingRechargeProductId(null);
setRechargePaymentResult(null);
void confirmWechatRechargeOrderUntilSettled(orderId)
.then((response) => {
const isPaid = response.order.status === 'paid';
setRechargeCenter(response.center);
pendingWechatRechargeOrderIdRef.current = null;
confirmingWechatRechargeOrderIdRef.current = null;
setWechatRechargeOrderConfirmationState(null);
setRechargePaymentResult(
isPaid
? {
kind: 'success',
title: '支付成功',
message: '已到账,账户状态已刷新。',
}
: {
kind: 'pending',
title: '支付处理中',
message: '正在等待到账状态确认,请稍后查看余额或会员状态。',
},
);
if (isPaid) {
void onRechargeSuccess?.();
}
clearWechatPayResultHash();
})
.catch(() => {
confirmingWechatRechargeOrderIdRef.current = null;
setWechatRechargeOrderConfirmationState(null);
setRechargePaymentResult({
kind: 'pending',
title: '支付处理中',
message: '暂时没能确认到账状态,请稍后查看余额或会员状态。',
});
clearWechatPayResultHash();
});
} else if (payResult.status === 'cancel') {
setRechargePaymentResult({
kind: 'cancel',
title: '支付已取消',
message: '本次没有扣款,账户状态未发生变化。',
});
setWechatRechargeOrderConfirmationState(null);
refreshRechargeState();
} else {
const detail = payResult.errorMessage
? `微信返回:${payResult.errorMessage}`
: '微信支付没有完成,本次不会入账。';
setRechargePaymentResult({
kind: 'failed',
title: '支付未完成',
message: '微信支付没有完成,本次不会入账。',
message: detail,
});
setWechatRechargeOrderConfirmationState(null);
refreshRechargeState();
}
clearWechatPayResultHash();
return true;
}, [onRechargeSuccess, refreshRechargeState]);
const pollWechatPayResultFromHash = useCallback(
() => handleWechatPayResult(),
[handleWechatPayResult],
);
const confirmPendingWechatRechargeOrder = useCallback(() => {
const orderId = pendingWechatRechargeOrderIdRef.current;
if (!orderId || confirmingWechatRechargeOrderIdRef.current === orderId) {
return false;
}
confirmingWechatRechargeOrderIdRef.current = orderId;
setWechatRechargeOrderConfirmationState({ orderId });
setRechargePaymentResult(null);
void confirmWechatRechargeOrderUntilSettled(orderId)
.then((response) => {
const isPaid = response.order.status === 'paid';
setRechargeCenter(response.center);
pendingWechatRechargeOrderIdRef.current = null;
confirmingWechatRechargeOrderIdRef.current = null;
setWechatRechargeOrderConfirmationState(null);
setSubmittingRechargeProductId(null);
setRechargePaymentResult(
isPaid
? {
kind: 'success',
title: '支付成功',
message: '已到账,账户状态已刷新。',
}
: {
kind: 'pending',
title: '支付处理中',
message: '正在等待到账状态确认,请稍后查看余额或会员状态。',
},
);
if (isPaid) {
void onRechargeSuccess?.();
}
})
.catch(() => {
confirmingWechatRechargeOrderIdRef.current = null;
setWechatRechargeOrderConfirmationState(null);
setRechargePaymentResult({
kind: 'pending',
title: '支付处理中',
message: '暂时没能确认到账状态,请稍后查看余额或会员状态。',
});
});
return true;
}, [onRechargeSuccess]);
const openRechargeModal = () => {
if (!authUi?.user) {
authUi?.openLoginModal();
@@ -4672,20 +4805,24 @@ export function RpgEntryHomeView({
return;
}
const paymentChannel = resolveProfileRechargePaymentChannel();
const paymentChannel = resolveProfileRechargeProductPaymentChannel(
{ kind: product.kind },
{},
);
setSubmittingRechargeProductId(product.productId);
setRechargeError(null);
setRechargePaymentResult(null);
setWechatRechargeOrderConfirmationState(null);
setNativeWechatPayment(null);
void createRpgProfileRechargeOrder(product.productId, paymentChannel)
.then(async (response) => {
if (paymentChannel === WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL) {
if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) {
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
setRechargeCenter(response.center);
await requestWechatMiniProgramPayment(
response.wechatMiniProgramPayParams,
response.order.orderId,
);
setRechargeCenter(response.center);
return;
}
if (paymentChannel === WECHAT_H5_PAYMENT_CHANNEL) {
@@ -4787,22 +4924,67 @@ export function RpgEntryHomeView({
.finally(() => setSubmittingRechargeProductId(null));
}, [nativeWechatPayment, onRechargeSuccess]);
useEffect(() => {
const handleResume = () => {
const handleHashChange = () => {
handleWechatPayResult();
};
const handleResume = () => {
if (
typeof document !== 'undefined' &&
document.visibilityState === 'hidden'
) {
return;
}
if (!handleWechatPayResult()) {
confirmPendingWechatRechargeOrder();
}
};
window.addEventListener('hashchange', handleResume);
window.addEventListener('hashchange', handleHashChange);
window.addEventListener('focus', handleResume);
window.addEventListener('pageshow', handleResume);
document.addEventListener('visibilitychange', handleResume);
handleResume();
handleWechatPayResult();
return () => {
window.removeEventListener('hashchange', handleResume);
window.removeEventListener('hashchange', handleHashChange);
window.removeEventListener('focus', handleResume);
window.removeEventListener('pageshow', handleResume);
document.removeEventListener('visibilitychange', handleResume);
};
}, [handleWechatPayResult]);
}, [confirmPendingWechatRechargeOrder, handleWechatPayResult]);
useEffect(() => {
if (!submittingRechargeProductId || wechatRechargeOrderConfirmationState) {
return undefined;
}
const startedAt = Date.now();
let timer: number | null = null;
const pollPayResult = () => {
if (pollWechatPayResultFromHash()) {
return;
}
if (Date.now() - startedAt >= WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS) {
return;
}
timer = window.setTimeout(
pollPayResult,
WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS,
);
};
timer = window.setTimeout(
pollPayResult,
WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS,
);
return () => {
if (timer !== null) {
window.clearTimeout(timer);
}
};
}, [
pollWechatPayResultFromHash,
submittingRechargeProductId,
wechatRechargeOrderConfirmationState,
]);
const loadTaskCenter = useCallback(() => {
const requestId = ++taskCenterRequestIdRef.current;
setTaskCenterError(null);
@@ -6042,6 +6224,11 @@ export function RpgEntryHomeView({
<div className={MOBILE_PAGE_STAGE_CLASS}>
<section>
<SectionHeader title="我的创作" detail="草稿与已发布" />
{platformError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{platformError}
</div>
) : null}
{isLoadingPlatform ? (
<EmptyShelf text="正在读取你的作品..." />
) : myEntries.length > 0 ? (
@@ -6078,7 +6265,16 @@ export function RpgEntryHomeView({
const createContent: ReactNode =
createTabContent ?? fallbackCreateStartContent;
const savesContent: ReactNode = draftTabContent ?? fallbackDraftContent;
const savesContent: ReactNode = (
<>
{platformError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{platformError}
</div>
) : null}
{draftTabContent ?? fallbackDraftContent}
</>
);
const profileContent: ReactNode = (
<div className={`${MOBILE_PROFILE_PAGE_STAGE_CLASS} platform-profile-page`}>
@@ -6292,7 +6488,7 @@ export function RpgEntryHomeView({
className="platform-profile-shortcut-panel"
aria-label="常用功能"
>
<div className="platform-profile-shortcut-grid">
<div className="platform-profile-shortcut-grid grid w-full !grid-cols-4">
<ProfileShortcutButton
label="泥点充值"
subLabel="充值泥点"
@@ -6796,6 +6992,15 @@ export function RpgEntryHomeView({
onClose={() => setRechargePaymentResult(null)}
/>
) : null;
const rechargePaymentConfirmationMask: ReactNode =
wechatRechargeOrderConfirmationState ? (
<RechargePaymentConfirmationMask
orderId={wechatRechargeOrderConfirmationState.orderId}
/>
) : null;
const isRechargePaymentConfirmationPending = Boolean(
wechatRechargeOrderConfirmationState,
);
const categoryFilterDialog: ReactNode = isCategoryFilterPanelOpen ? (
<PlatformCategoryFilterDialog
kindFilter={categoryKindFilter}
@@ -6831,93 +7036,100 @@ export function RpgEntryHomeView({
const isMobileRecommendTab = activeTab === 'home';
return (
<div
className={`platform-mobile-entry-shell ${isMobileRecommendTab ? 'platform-mobile-entry-shell--recommend' : ''} flex h-full min-h-0 min-w-0 flex-col overflow-hidden`}
>
{!isMobileRecommendTab ? (
<div className="platform-mobile-topbar mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
<RpgEntryBrandLogo />
{isAuthenticated && activeTab === 'profile' ? (
<div className="flex items-center gap-2.5">
<>
<div
inert={isRechargePaymentConfirmationPending ? true : undefined}
className={`platform-mobile-entry-shell ${isMobileRecommendTab ? 'platform-mobile-entry-shell--recommend' : ''} flex h-full min-h-0 min-w-0 flex-col overflow-hidden`}
>
{!isMobileRecommendTab ? (
<div className="platform-mobile-topbar mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
<RpgEntryBrandLogo />
{isAuthenticated && activeTab === 'profile' ? (
<div className="flex items-center gap-2.5">
<button
type="button"
onClick={openQrScannerPanel}
className="platform-profile-header__icon-button"
aria-label="扫码"
>
<ScanLine className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => authUi?.openSettingsModal()}
className="platform-profile-header__icon-button"
aria-label="打开设置"
>
<Settings className="h-5 w-5" />
</button>
</div>
) : isAuthenticated &&
(activeTab === 'create' || activeTab === 'saves') ? (
<button
type="button"
onClick={openQrScannerPanel}
className="platform-profile-header__icon-button"
aria-label="扫码"
onClick={openRechargeOrRewardCodeModal}
className="platform-mobile-create-wallet-chip inline-flex shrink-0 items-center gap-1.5 rounded-full border border-[#f0cfae] bg-[#fff5eb] px-2.5 py-1.5 text-xs font-black text-[#b65f2c] shadow-[0_10px_22px_rgba(174,111,73,0.12)]"
aria-label={`${formatDashboardCount(remainingNarrativeCoins)}泥点`}
>
<ScanLine className="h-5 w-5" />
<span className="grid h-6 w-6 place-items-center rounded-full bg-[#ffe0ab] text-[#cf7b34]">
<Coins className="h-3.5 w-3.5" />
</span>
<span>{formatDashboardCount(remainingNarrativeCoins)}</span>
</button>
) : !isAuthenticated ? (
<button
type="button"
onClick={() => authUi?.openSettingsModal()}
className="platform-profile-header__icon-button"
aria-label="打开设置"
onClick={openUserSurface}
className="platform-button platform-button--primary shrink-0 px-3 py-2 text-xs"
>
<Settings className="h-5 w-5" />
<LogIn className="h-3.5 w-3.5" />
</button>
</div>
) : isAuthenticated && activeTab === 'create' ? (
<button
type="button"
onClick={openUserSurface}
className="platform-mobile-create-wallet-chip inline-flex shrink-0 items-center gap-1.5 rounded-full border border-[#f0cfae] bg-[#fff5eb] px-2.5 py-1.5 text-xs font-black text-[#b65f2c] shadow-[0_10px_22px_rgba(174,111,73,0.12)]"
aria-label={`${formatDashboardCount(remainingNarrativeCoins)}泥点`}
>
<span className="grid h-6 w-6 place-items-center rounded-full bg-[#ffe0ab] text-[#cf7b34]">
<Coins className="h-3.5 w-3.5" />
</span>
<span>{formatDashboardCount(remainingNarrativeCoins)}</span>
</button>
) : !isAuthenticated ? (
<button
type="button"
onClick={openUserSurface}
className="platform-button platform-button--primary shrink-0 px-3 py-2 text-xs"
>
<LogIn className="h-3.5 w-3.5" />
</button>
) : null}
) : null}
</div>
) : null}
<div className="platform-tab-panel-stack min-w-0 flex-1">
{tabPanels}
</div>
) : null}
<div className="platform-tab-panel-stack min-w-0 flex-1">
{tabPanels}
</div>
<div className="platform-mobile-bottom-dock min-w-0 shrink-0">
<div
className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : visibleTabs.length === 4 ? 'grid-cols-4' : visibleTabs.length === 3 ? 'grid-cols-3' : 'grid-cols-2'}`}
>
{visibleTabs.map((tab) => (
<PlatformTabButton
key={tab}
active={activeTab === tab}
label={
activeTab === 'home' && tab === 'home'
? '下一个'
: tabLabels[tab]
}
icon={
activeTab === 'home' && tab === 'home'
? ChevronDown
: tabIcons[tab]
}
emphasized={tab === 'create'}
showDot={tab === 'saves' && hasUnreadDraftUpdate}
onClick={() => {
if (activeTab === 'home' && tab === 'home') {
selectNextRecommendEntry();
return;
<div className="platform-mobile-bottom-dock min-w-0 shrink-0">
<div
className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : visibleTabs.length === 4 ? 'grid-cols-4' : visibleTabs.length === 3 ? 'grid-cols-3' : 'grid-cols-2'}`}
>
{visibleTabs.map((tab) => (
<PlatformTabButton
key={tab}
active={activeTab === tab}
label={
activeTab === 'home' && tab === 'home'
? '下一个'
: tabLabels[tab]
}
icon={
activeTab === 'home' && tab === 'home'
? ChevronDown
: tabIcons[tab]
}
emphasized={tab === 'create'}
showDot={tab === 'saves' && hasUnreadDraftUpdate}
disabled={isRechargePaymentConfirmationPending}
onClick={() => {
if (isRechargePaymentConfirmationPending) {
return;
}
if (activeTab === 'home' && tab === 'home') {
selectNextRecommendEntry();
return;
}
onTabChange(tab);
}}
/>
))}
onTabChange(tab);
}}
/>
))}
</div>
</div>
</div>
{profilePopupPanel === 'saveArchives' ? (
{profilePopupPanel === 'saveArchives' ? (
<ProfileSaveArchivesModal
saveEntries={saveEntries}
saveError={saveError}
@@ -6989,94 +7201,106 @@ export function RpgEntryHomeView({
/>
{profileEditModals}
</div>
{rechargePaymentConfirmationMask}
</>
);
}
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex h-full min-h-0 flex-col">
<div className="platform-desktop-shell flex h-full min-h-0 flex-col p-5 xl:p-6">
<div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
<div className="flex min-w-0 flex-1 items-center gap-5">
<RpgEntryBrandLogo className="shrink-0" decorative />
<PublicCodeSearchBar
value={desktopSearchKeyword}
onChange={updateDesktopSearchKeyword}
onSubmit={submitDesktopSearch}
isSearching={
!onSearchPublicCode || Boolean(isSearchingPublicCode)
}
className="max-w-[34rem] flex-1"
/>
</div>
<>
<div
inert={isRechargePaymentConfirmationPending ? true : undefined}
className="flex h-full min-h-0 flex-col"
>
<div className="flex h-full min-h-0 flex-col">
<div className="platform-desktop-shell flex h-full min-h-0 flex-col p-5 xl:p-6">
<div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
<div className="flex min-w-0 flex-1 items-center gap-5">
<RpgEntryBrandLogo className="shrink-0" decorative />
<PublicCodeSearchBar
value={desktopSearchKeyword}
onChange={updateDesktopSearchKeyword}
onSubmit={submitDesktopSearch}
isSearching={
!onSearchPublicCode || Boolean(isSearchingPublicCode)
}
className="max-w-[34rem] flex-1"
/>
</div>
<div className="flex items-center gap-3">
{isAuthenticated && activeTab === 'create' ? (
<div className="flex items-center gap-3">
{isAuthenticated &&
(activeTab === 'create' || activeTab === 'saves') ? (
<button
type="button"
onClick={openRechargeOrRewardCodeModal}
className="platform-desktop-create-wallet-chip platform-desktop-search inline-flex items-center gap-2 px-3 py-2.5 text-xs font-black text-[#b65f2c]"
aria-label={`${formatDashboardCount(remainingNarrativeCoins)}泥点`}
>
<span className="grid h-7 w-7 place-items-center rounded-full bg-[#ffe0ab] text-[#cf7b34]">
<Coins className="h-3.5 w-3.5" />
</span>
<span>{formatDashboardCount(remainingNarrativeCoins)}</span>
</button>
) : null}
<button
type="button"
onClick={openUserSurface}
className="platform-desktop-create-wallet-chip platform-desktop-search inline-flex items-center gap-2 px-3 py-2.5 text-xs font-black text-[#b65f2c]"
aria-label={`${formatDashboardCount(remainingNarrativeCoins)}泥点`}
className="platform-desktop-search flex items-center gap-3 px-3 py-2.5 text-left"
>
<span className="grid h-7 w-7 place-items-center rounded-full bg-[#ffe0ab] text-[#cf7b34]">
<Coins className="h-3.5 w-3.5" />
<span
className="flex h-11 w-11 items-center justify-center overflow-hidden rounded-full text-base font-black text-white"
style={{
background: 'var(--platform-profile-avatar-fill)',
boxShadow: 'var(--platform-profile-avatar-shadow)',
}}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
className="h-full w-full object-cover"
/>
) : (
avatarLabel
)}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{authUi?.user?.displayName || '登录'}
</span>
<span className="block truncate text-xs text-[var(--platform-text-soft)]">
{authUi?.user ? publicUserCode : '账号入口'}
</span>
</span>
<span>{formatDashboardCount(remainingNarrativeCoins)}</span>
</button>
) : null}
<button
type="button"
onClick={openUserSurface}
className="platform-desktop-search flex items-center gap-3 px-3 py-2.5 text-left"
>
<span
className="flex h-11 w-11 items-center justify-center overflow-hidden rounded-full text-base font-black text-white"
style={{
background: 'var(--platform-profile-avatar-fill)',
boxShadow: 'var(--platform-profile-avatar-shadow)',
}}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
className="h-full w-full object-cover"
/>
) : (
avatarLabel
)}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{authUi?.user?.displayName || '登录'}
</span>
<span className="block truncate text-xs text-[var(--platform-text-soft)]">
{authUi?.user ? publicUserCode : '账号入口'}
</span>
</span>
</button>
</div>
</div>
</div>
<div className="mt-5 flex min-h-0 gap-5">
<aside className="platform-desktop-rail flex w-[5.8rem] shrink-0 flex-col gap-3 p-3">
{visibleTabs.map((tab) => (
<DesktopTabButton
key={tab}
active={activeTab === tab}
label={tabLabels[tab]}
icon={tabIcons[tab]}
emphasized={tab === 'create'}
showDot={tab === 'saves' && hasUnreadDraftUpdate}
onClick={() => {
onTabChange(tab);
}}
/>
))}
</aside>
<div className="mt-5 flex min-h-0 gap-5">
<aside className="platform-desktop-rail flex w-[5.8rem] shrink-0 flex-col gap-3 p-3">
{visibleTabs.map((tab) => (
<DesktopTabButton
key={tab}
active={activeTab === tab}
label={tabLabels[tab]}
icon={tabIcons[tab]}
emphasized={tab === 'create'}
showDot={tab === 'saves' && hasUnreadDraftUpdate}
disabled={isRechargePaymentConfirmationPending}
onClick={() => {
if (isRechargePaymentConfirmationPending) {
return;
}
onTabChange(tab);
}}
/>
))}
</aside>
<div className="platform-tab-panel-stack min-w-0 flex-1">
{tabPanels}
<div className="platform-tab-panel-stack min-w-0 flex-1">
{tabPanels}
</div>
</div>
</div>
</div>
@@ -7151,7 +7375,8 @@ export function RpgEntryHomeView({
onClose={() => setActiveLegalDocumentId(null)}
/>
{profileEditModals}
</div>
{rechargePaymentConfirmationMask}
</>
);
}

View File

@@ -255,7 +255,7 @@ test('resolves public work author from display name and public user code before
displayName: '公开昵称',
avatarUrl: null,
}),
).toBe('公开昵称 · SY-00000004');
).toBe('公开昵称');
expect(
resolvePlatformWorkAuthorDisplayName(card, {
id: 'user_00000004',
@@ -268,6 +268,36 @@ test('resolves public work author from display name and public user code before
expect(resolvePlatformWorkAuthorDisplayName(card, null)).toBe('敲木鱼玩家');
});
test('public work author display hides phone masks and public user codes on cards', () => {
const card = mapWoodenFishWorkToPlatformGalleryCard({
publicWorkCode: 'WF-AUTHOR2',
workId: 'wooden-fish-work-author-mask',
profileId: 'wooden-fish-profile-author-mask',
ownerUserId: 'user-author-mask',
authorDisplayName: '158****3533 · SY-00000003',
workTitle: '喜气洋洋',
workDescription: '喜庆主题敲木鱼。',
coverImageSrc: null,
themeTags: ['敲木鱼'],
publicationStatus: 'published',
playCount: 0,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: '2026-05-20T00:00:00.000Z',
generationStatus: 'ready',
});
expect(
resolvePlatformWorkAuthorDisplayName(card, {
id: 'user_00000003',
publicUserCode: 'SY-00000003',
username: '158****3533',
displayName: '158****3533',
avatarUrl: null,
}),
).toBe('玩家');
expect(resolvePlatformWorkAuthorDisplayName(card, null)).toBe('玩家');
});
test('keeps baby object match public card code and template label intact', () => {
const card: PlatformEdutainmentGalleryCard = {
sourceType: 'edutainment',

View File

@@ -970,13 +970,31 @@ export function resolvePlatformWorkAuthorDisplayName(
entry: PlatformPublicGalleryCard,
authorSummary?: PublicUserSummary | null,
) {
const displayName = authorSummary?.displayName?.trim();
const publicUserCode = authorSummary?.publicUserCode?.trim();
if (displayName && publicUserCode) {
return `${displayName} · ${publicUserCode}`;
const displayName = normalizePlatformPublicAuthorName(
authorSummary?.displayName,
);
const entryAuthorName = normalizePlatformPublicAuthorName(
entry.authorDisplayName,
);
return displayName || entryAuthorName || '玩家';
}
function normalizePlatformPublicAuthorName(value: string | null | undefined) {
const normalized = value?.trim() ?? '';
if (!normalized || normalized === 'null' || normalized === 'undefined') {
return '';
}
return displayName || publicUserCode || entry.authorDisplayName.trim() || '玩家';
const compact = normalized.replace(/\s+/gu, '');
if (/^\d+\*+\d+(?:[·.-]?SY-\d+)?$/iu.test(compact)) {
return '';
}
if (/^SY-\d+$/iu.test(compact)) {
return '';
}
return normalized;
}
export function buildPlatformWorldDisplayTags(