+ {{ deepLinkState === 'backgrounded' ? t('payment.qr.alipayContinueInApp') : t('payment.qr.alipayOpening') }} +
+{{ t('payment.qr.alipayWaitingHint') }}
+ +{{ t('payment.qr.expiresIn') }}
+{{ countdownDisplay }}
+{{ t('payment.qr.waitingPayment') }}
+{{ t('payment.qr.alipayFallbackTitle') }}
+{{ t('payment.qr.alipayFallbackHint') }}
++ {{ t('payment.qr.alipaySaveAndScanHint') }} +
+{{ scanTitle }}
@@ -122,7 +218,7 @@ diff --git a/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts b/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts index d5919867c5..09c5067172 100644 --- a/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts +++ b/frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts @@ -184,4 +184,132 @@ describe('PaymentStatusPanel', () => { expect(wrapper.text()).toContain('payment.result.success') expect(wrapper.emitted('success')).toHaveLength(1) }) + + it('actively verifies a pending mobile Alipay precreate order', async () => { + const originalLocation = window.location + const originalHidden = Object.getOwnPropertyDescriptor(document, 'hidden') + Object.defineProperty(window, 'location', { + configurable: true, + value: { assign: vi.fn() }, + }) + Object.defineProperty(document, 'hidden', { + configurable: true, + get: () => false, + }) + pollOrderStatus.mockResolvedValue(orderFactory('PENDING')) + verifyOrder.mockResolvedValue({ data: orderFactory('COMPLETED') }) + + const wrapper = mount(PaymentStatusPanel, { + props: { + orderId: 42, + amount: 88, + payAmount: 88, + qrCode: 'https://qr.alipay.com/dynamic-order-42', + expiresAt: '2099-01-01T12:30:00Z', + paymentType: 'alipay', + orderType: 'balance', + outTradeNo: 'sub2_20260420abcd1234', + mobileAlipayDeepLink: true, + }, + global: { stubs: { Icon: true } }, + }) + + await flushPromises() + await vi.advanceTimersByTimeAsync(3000) + await flushPromises() + + expect(verifyOrder).toHaveBeenCalledWith('sub2_20260420abcd1234') + expect(wrapper.emitted('success')).toHaveLength(1) + + wrapper.unmount() + Object.defineProperty(window, 'location', { configurable: true, value: originalLocation }) + if (originalHidden) Object.defineProperty(document, 'hidden', originalHidden) + }) + + it('keeps the QR fallback hidden until the Alipay app launch times out', async () => { + const originalLocation = window.location + const originalHidden = Object.getOwnPropertyDescriptor(document, 'hidden') + const assign = vi.fn() + Object.defineProperty(window, 'location', { + configurable: true, + value: { assign }, + }) + Object.defineProperty(document, 'hidden', { + configurable: true, + get: () => false, + }) + + const wrapper = mount(PaymentStatusPanel, { + props: { + orderId: 42, + amount: 88, + payAmount: 88, + qrCode: 'https://qr.alipay.com/dynamic-order-42', + expiresAt: '2099-01-01T12:30:00Z', + paymentType: 'alipay', + orderType: 'balance', + outTradeNo: 'sub2_20260420abcd1234', + mobileAlipayDeepLink: true, + }, + global: { stubs: { Icon: true } }, + }) + + await flushPromises() + expect(assign).toHaveBeenCalledWith(expect.stringContaining('alipays://platformapi/startapp?saId=10000007&qrcode=')) + expect(wrapper.find('[data-test="alipay-qr-fallback"]').exists()).toBe(false) + + await vi.advanceTimersByTimeAsync(2200) + await flushPromises() + + expect(wrapper.find('[data-test="alipay-qr-fallback"]').exists()).toBe(true) + expect(wrapper.text()).toContain('payment.qr.saveQRCode') + expect(wrapper.text()).toContain('sub2_20260420abcd1234') + expect(toCanvas).toHaveBeenCalledWith(expect.any(HTMLCanvasElement), 'https://qr.alipay.com/dynamic-order-42', expect.any(Object)) + + wrapper.unmount() + Object.defineProperty(window, 'location', { configurable: true, value: originalLocation }) + if (originalHidden) Object.defineProperty(document, 'hidden', originalHidden) + }) + + it('does not show the QR fallback after the page enters the background', async () => { + const originalLocation = window.location + const originalHidden = Object.getOwnPropertyDescriptor(document, 'hidden') + let hidden = false + Object.defineProperty(window, 'location', { + configurable: true, + value: { assign: vi.fn() }, + }) + Object.defineProperty(document, 'hidden', { + configurable: true, + get: () => hidden, + }) + + const wrapper = mount(PaymentStatusPanel, { + props: { + orderId: 42, + amount: 88, + payAmount: 88, + qrCode: 'https://qr.alipay.com/dynamic-order-42', + expiresAt: '2099-01-01T12:30:00Z', + paymentType: 'alipay', + orderType: 'balance', + outTradeNo: 'sub2_20260420abcd1234', + mobileAlipayDeepLink: true, + }, + global: { stubs: { Icon: true } }, + }) + + await flushPromises() + hidden = true + document.dispatchEvent(new Event('visibilitychange')) + await vi.advanceTimersByTimeAsync(2200) + await flushPromises() + + expect(wrapper.find('[data-test="alipay-qr-fallback"]').exists()).toBe(false) + expect(wrapper.text()).toContain('payment.qr.alipayContinueInApp') + + wrapper.unmount() + Object.defineProperty(window, 'location', { configurable: true, value: originalLocation }) + if (originalHidden) Object.defineProperty(document, 'hidden', originalHidden) + }) }) diff --git a/frontend/src/components/payment/__tests__/alipayDeepLink.spec.ts b/frontend/src/components/payment/__tests__/alipayDeepLink.spec.ts new file mode 100644 index 0000000000..bbc52e200e --- /dev/null +++ b/frontend/src/components/payment/__tests__/alipayDeepLink.spec.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + ALIPAY_DEEP_LINK_FALLBACK_DELAY_MS, + ALIPAY_EMBEDDED_BROWSER_FALLBACK_DELAY_MS, + buildAlipayDeepLink, + createAlipayDeepLinkLauncher, +} from '../alipayDeepLink' + +class FakeEventTarget { + private readonly listeners = new Map