diff --git a/src/__tests__/payment-flow.test.ts b/src/__tests__/payment-flow.test.ts new file mode 100644 index 0000000..8e4cdc2 --- /dev/null +++ b/src/__tests__/payment-flow.test.ts @@ -0,0 +1,752 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ============================================================ +// Mock: EasyPay +// ============================================================ + +const mockEasyPayCreatePayment = vi.fn(); +vi.mock('@/lib/easy-pay/client', () => ({ + createPayment: (...args: unknown[]) => mockEasyPayCreatePayment(...args), + queryOrder: vi.fn(), + refund: vi.fn(), +})); + +vi.mock('@/lib/easy-pay/sign', () => ({ + verifySign: vi.fn(), + generateSign: vi.fn(), +})); + +// ============================================================ +// Mock: Alipay +// ============================================================ + +const mockAlipayPageExecute = vi.fn(); +vi.mock('@/lib/alipay/client', () => ({ + pageExecute: (...args: unknown[]) => mockAlipayPageExecute(...args), + execute: vi.fn(), +})); + +vi.mock('@/lib/alipay/sign', () => ({ + verifySign: vi.fn(), + generateSign: vi.fn(), +})); + +// ============================================================ +// Mock: Wxpay +// ============================================================ + +const mockWxpayCreatePcOrder = vi.fn(); +const mockWxpayCreateH5Order = vi.fn(); +vi.mock('@/lib/wxpay/client', () => ({ + createPcOrder: (...args: unknown[]) => mockWxpayCreatePcOrder(...args), + createH5Order: (...args: unknown[]) => mockWxpayCreateH5Order(...args), + queryOrder: vi.fn(), + closeOrder: vi.fn(), + createRefund: vi.fn(), + decipherNotify: vi.fn(), + verifyNotifySign: vi.fn(), +})); + +// ============================================================ +// Mock: Config (shared by all providers) +// ============================================================ + +vi.mock('@/lib/config', () => ({ + getEnv: () => ({ + // EasyPay + EASY_PAY_PID: 'test-pid', + EASY_PAY_PKEY: 'test-pkey', + EASY_PAY_API_BASE: 'https://easypay.example.com', + EASY_PAY_NOTIFY_URL: 'https://pay.example.com/api/easypay/notify', + EASY_PAY_RETURN_URL: 'https://pay.example.com/pay/result', + // Alipay + ALIPAY_APP_ID: '2021000000000000', + ALIPAY_PRIVATE_KEY: 'test-private-key', + ALIPAY_PUBLIC_KEY: 'test-public-key', + ALIPAY_NOTIFY_URL: 'https://pay.example.com/api/alipay/notify', + ALIPAY_RETURN_URL: 'https://pay.example.com/pay/result', + // Wxpay + WXPAY_APP_ID: 'wx-test-app-id', + WXPAY_MCH_ID: 'wx-test-mch-id', + WXPAY_PRIVATE_KEY: 'test-private-key', + WXPAY_API_V3_KEY: 'test-api-v3-key', + WXPAY_PUBLIC_KEY: 'test-public-key', + WXPAY_PUBLIC_KEY_ID: 'test-public-key-id', + WXPAY_CERT_SERIAL: 'test-cert-serial', + WXPAY_NOTIFY_URL: 'https://pay.example.com/api/wxpay/notify', + // General + NEXT_PUBLIC_APP_URL: 'https://pay.example.com', + }), +})); + +// ============================================================ +// Imports (must come after mocks) +// ============================================================ + +import { EasyPayProvider } from '@/lib/easy-pay/provider'; +import { AlipayProvider } from '@/lib/alipay/provider'; +import { WxpayProvider } from '@/lib/wxpay/provider'; +import { isStripeType } from '@/lib/pay-utils'; +import { REDIRECT_PAYMENT_TYPES } from '@/lib/constants'; +import type { CreatePaymentRequest } from '@/lib/payment/types'; + +// ============================================================ +// Helper: simulate shouldAutoRedirect logic from PaymentQRCode +// ============================================================ + +function shouldAutoRedirect(opts: { + expired: boolean; + paymentType?: string; + payUrl?: string | null; + qrCode?: string | null; + isMobile: boolean; +}): boolean { + return ( + !opts.expired && + !isStripeType(opts.paymentType) && + !!opts.payUrl && + (opts.isMobile || !opts.qrCode) + ); +} + +// ============================================================ +// Tests +// ============================================================ + +describe('Payment Flow - PC/Mobile, QR/Redirect', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ---------------------------------------------------------- + // EasyPay Provider + // ---------------------------------------------------------- + + describe('EasyPayProvider', () => { + let provider: EasyPayProvider; + + beforeEach(() => { + provider = new EasyPayProvider(); + }); + + it('PC: createPayment returns both payUrl and qrCode', async () => { + mockEasyPayCreatePayment.mockResolvedValue({ + code: 1, + trade_no: 'EP-001', + payurl: 'https://easypay.example.com/pay/EP-001', + qrcode: 'https://qr.alipay.com/fkx12345', + }); + + const request: CreatePaymentRequest = { + orderId: 'order-ep-001', + amount: 50, + paymentType: 'alipay', + subject: 'Test Recharge', + clientIp: '1.2.3.4', + isMobile: false, + }; + + const result = await provider.createPayment(request); + + expect(result.tradeNo).toBe('EP-001'); + expect(result.qrCode).toBe('https://qr.alipay.com/fkx12345'); + expect(result.payUrl).toBe('https://easypay.example.com/pay/EP-001'); + + // PC + has qrCode + has payUrl => shouldAutoRedirect = false (show QR) + expect( + shouldAutoRedirect({ + expired: false, + paymentType: 'alipay', + payUrl: result.payUrl, + qrCode: result.qrCode, + isMobile: false, + }), + ).toBe(false); + }); + + it('Mobile: createPayment returns payUrl for redirect', async () => { + mockEasyPayCreatePayment.mockResolvedValue({ + code: 1, + trade_no: 'EP-002', + payurl: 'https://easypay.example.com/pay/EP-002', + qrcode: 'https://qr.alipay.com/fkx67890', + }); + + const request: CreatePaymentRequest = { + orderId: 'order-ep-002', + amount: 100, + paymentType: 'wxpay', + subject: 'Test Recharge', + clientIp: '1.2.3.4', + isMobile: true, + }; + + const result = await provider.createPayment(request); + + expect(result.tradeNo).toBe('EP-002'); + expect(result.payUrl).toBeDefined(); + + // Mobile + has payUrl => shouldAutoRedirect = true (redirect) + expect( + shouldAutoRedirect({ + expired: false, + paymentType: 'wxpay', + payUrl: result.payUrl, + qrCode: result.qrCode, + isMobile: true, + }), + ).toBe(true); + }); + + it('EasyPay does not use isMobile flag itself (delegates to frontend)', async () => { + mockEasyPayCreatePayment.mockResolvedValue({ + code: 1, + trade_no: 'EP-003', + payurl: 'https://easypay.example.com/pay/EP-003', + qrcode: 'weixin://wxpay/bizpayurl?pr=xxx', + }); + + const request: CreatePaymentRequest = { + orderId: 'order-ep-003', + amount: 10, + paymentType: 'alipay', + subject: 'Test', + clientIp: '1.2.3.4', + isMobile: true, + }; + + await provider.createPayment(request); + + // EasyPay client is called the same way regardless of isMobile + expect(mockEasyPayCreatePayment).toHaveBeenCalledWith( + expect.objectContaining({ + outTradeNo: 'order-ep-003', + paymentType: 'alipay', + }), + ); + // No isMobile parameter forwarded to the underlying client + const callArgs = mockEasyPayCreatePayment.mock.calls[0][0]; + expect(callArgs).not.toHaveProperty('isMobile'); + }); + }); + + // ---------------------------------------------------------- + // Alipay Provider + // ---------------------------------------------------------- + + describe('AlipayProvider', () => { + let provider: AlipayProvider; + + beforeEach(() => { + provider = new AlipayProvider(); + }); + + it('PC: uses alipay.trade.page.pay, returns payUrl only (no qrCode)', async () => { + mockAlipayPageExecute.mockReturnValue( + 'https://openapi.alipay.com/gateway.do?method=alipay.trade.page.pay&sign=xxx', + ); + + const request: CreatePaymentRequest = { + orderId: 'order-ali-001', + amount: 100, + paymentType: 'alipay_direct', + subject: 'Test Recharge', + isMobile: false, + }; + + const result = await provider.createPayment(request); + + expect(result.tradeNo).toBe('order-ali-001'); + expect(result.payUrl).toContain('alipay.trade.page.pay'); + expect(result.qrCode).toBeUndefined(); + + // Verify pageExecute was called with PC method + expect(mockAlipayPageExecute).toHaveBeenCalledWith( + expect.objectContaining({ + product_code: 'FAST_INSTANT_TRADE_PAY', + }), + expect.objectContaining({ + method: 'alipay.trade.page.pay', + }), + ); + + // PC + payUrl only (no qrCode) => shouldAutoRedirect = true (redirect to Alipay cashier page) + expect( + shouldAutoRedirect({ + expired: false, + paymentType: 'alipay_direct', + payUrl: result.payUrl, + qrCode: result.qrCode, + isMobile: false, + }), + ).toBe(true); + }); + + it('Mobile: uses alipay.trade.wap.pay, returns payUrl', async () => { + mockAlipayPageExecute.mockReturnValue( + 'https://openapi.alipay.com/gateway.do?method=alipay.trade.wap.pay&sign=yyy', + ); + + const request: CreatePaymentRequest = { + orderId: 'order-ali-002', + amount: 50, + paymentType: 'alipay_direct', + subject: 'Test Recharge', + isMobile: true, + }; + + const result = await provider.createPayment(request); + + expect(result.tradeNo).toBe('order-ali-002'); + expect(result.payUrl).toContain('alipay.trade.wap.pay'); + expect(result.qrCode).toBeUndefined(); + + // Verify pageExecute was called with H5 method + expect(mockAlipayPageExecute).toHaveBeenCalledWith( + expect.objectContaining({ + product_code: 'QUICK_WAP_WAY', + }), + expect.objectContaining({ + method: 'alipay.trade.wap.pay', + }), + ); + + // Mobile + payUrl => shouldAutoRedirect = true + expect( + shouldAutoRedirect({ + expired: false, + paymentType: 'alipay_direct', + payUrl: result.payUrl, + qrCode: result.qrCode, + isMobile: true, + }), + ).toBe(true); + }); + + it('Mobile: falls back to PC page.pay when wap.pay throws', async () => { + // First call (wap.pay) throws, second call (page.pay) succeeds + mockAlipayPageExecute + .mockImplementationOnce(() => { + throw new Error('WAP pay not available'); + }) + .mockReturnValueOnce( + 'https://openapi.alipay.com/gateway.do?method=alipay.trade.page.pay&sign=fallback', + ); + + const request: CreatePaymentRequest = { + orderId: 'order-ali-003', + amount: 30, + paymentType: 'alipay_direct', + subject: 'Test Recharge', + isMobile: true, + }; + + const result = await provider.createPayment(request); + + expect(result.payUrl).toContain('alipay.trade.page.pay'); + // pageExecute was called twice: first wap.pay (failed), then page.pay + expect(mockAlipayPageExecute).toHaveBeenCalledTimes(2); + expect(mockAlipayPageExecute).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ product_code: 'QUICK_WAP_WAY' }), + expect.objectContaining({ method: 'alipay.trade.wap.pay' }), + ); + expect(mockAlipayPageExecute).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ product_code: 'FAST_INSTANT_TRADE_PAY' }), + expect.objectContaining({ method: 'alipay.trade.page.pay' }), + ); + }); + + it('alipay_direct is in REDIRECT_PAYMENT_TYPES', () => { + expect(REDIRECT_PAYMENT_TYPES.has('alipay_direct')).toBe(true); + }); + }); + + // ---------------------------------------------------------- + // Wxpay Provider + // ---------------------------------------------------------- + + describe('WxpayProvider', () => { + let provider: WxpayProvider; + + beforeEach(() => { + provider = new WxpayProvider(); + }); + + it('PC: uses Native order, returns qrCode (no payUrl)', async () => { + mockWxpayCreatePcOrder.mockResolvedValue('weixin://wxpay/bizpayurl?pr=abc123'); + + const request: CreatePaymentRequest = { + orderId: 'order-wx-001', + amount: 100, + paymentType: 'wxpay_direct', + subject: 'Test Recharge', + clientIp: '1.2.3.4', + isMobile: false, + }; + + const result = await provider.createPayment(request); + + expect(result.tradeNo).toBe('order-wx-001'); + expect(result.qrCode).toBe('weixin://wxpay/bizpayurl?pr=abc123'); + expect(result.payUrl).toBeUndefined(); + + // createPcOrder was called, not createH5Order + expect(mockWxpayCreatePcOrder).toHaveBeenCalledTimes(1); + expect(mockWxpayCreateH5Order).not.toHaveBeenCalled(); + + // PC + qrCode (no payUrl) => shouldAutoRedirect = false (show QR) + expect( + shouldAutoRedirect({ + expired: false, + paymentType: 'wxpay_direct', + payUrl: result.payUrl, + qrCode: result.qrCode, + isMobile: false, + }), + ).toBe(false); + }); + + it('Mobile: uses H5 order, returns payUrl (no qrCode)', async () => { + mockWxpayCreateH5Order.mockResolvedValue( + 'https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx123', + ); + + const request: CreatePaymentRequest = { + orderId: 'order-wx-002', + amount: 50, + paymentType: 'wxpay_direct', + subject: 'Test Recharge', + clientIp: '2.3.4.5', + isMobile: true, + }; + + const result = await provider.createPayment(request); + + expect(result.tradeNo).toBe('order-wx-002'); + expect(result.payUrl).toContain('tenpay.com'); + expect(result.qrCode).toBeUndefined(); + + // createH5Order was called, not createPcOrder + expect(mockWxpayCreateH5Order).toHaveBeenCalledTimes(1); + expect(mockWxpayCreatePcOrder).not.toHaveBeenCalled(); + + // Mobile + payUrl => shouldAutoRedirect = true + expect( + shouldAutoRedirect({ + expired: false, + paymentType: 'wxpay_direct', + payUrl: result.payUrl, + qrCode: result.qrCode, + isMobile: true, + }), + ).toBe(true); + }); + + it('Mobile: falls back to Native qrCode when H5 returns NO_AUTH', async () => { + mockWxpayCreateH5Order.mockRejectedValue(new Error('Wxpay API error: [NO_AUTH] not authorized')); + mockWxpayCreatePcOrder.mockResolvedValue('weixin://wxpay/bizpayurl?pr=fallback123'); + + const request: CreatePaymentRequest = { + orderId: 'order-wx-003', + amount: 30, + paymentType: 'wxpay_direct', + subject: 'Test Recharge', + clientIp: '3.4.5.6', + isMobile: true, + }; + + const result = await provider.createPayment(request); + + expect(result.tradeNo).toBe('order-wx-003'); + expect(result.qrCode).toBe('weixin://wxpay/bizpayurl?pr=fallback123'); + expect(result.payUrl).toBeUndefined(); + + // Both were called: H5 failed, then Native succeeded + expect(mockWxpayCreateH5Order).toHaveBeenCalledTimes(1); + expect(mockWxpayCreatePcOrder).toHaveBeenCalledTimes(1); + + // Mobile + qrCode only (no payUrl) => shouldAutoRedirect = false (show QR) + expect( + shouldAutoRedirect({ + expired: false, + paymentType: 'wxpay_direct', + payUrl: result.payUrl, + qrCode: result.qrCode, + isMobile: true, + }), + ).toBe(false); + }); + + it('Mobile: re-throws non-NO_AUTH errors from H5', async () => { + mockWxpayCreateH5Order.mockRejectedValue(new Error('Wxpay API error: [SYSTEMERROR] system error')); + + const request: CreatePaymentRequest = { + orderId: 'order-wx-004', + amount: 20, + paymentType: 'wxpay_direct', + subject: 'Test Recharge', + clientIp: '4.5.6.7', + isMobile: true, + }; + + await expect(provider.createPayment(request)).rejects.toThrow('SYSTEMERROR'); + // Should not fall back to PC order + expect(mockWxpayCreatePcOrder).not.toHaveBeenCalled(); + }); + + it('Mobile without clientIp: falls back to Native qrCode directly', async () => { + mockWxpayCreatePcOrder.mockResolvedValue('weixin://wxpay/bizpayurl?pr=noip'); + + const request: CreatePaymentRequest = { + orderId: 'order-wx-005', + amount: 10, + paymentType: 'wxpay_direct', + subject: 'Test Recharge', + // No clientIp + isMobile: true, + }; + + const result = await provider.createPayment(request); + + expect(result.qrCode).toBe('weixin://wxpay/bizpayurl?pr=noip'); + expect(result.payUrl).toBeUndefined(); + // H5 was never attempted since clientIp is missing + expect(mockWxpayCreateH5Order).not.toHaveBeenCalled(); + }); + + it('uses request.notifyUrl as fallback when WXPAY_NOTIFY_URL is set', async () => { + mockWxpayCreatePcOrder.mockResolvedValue('weixin://wxpay/bizpayurl?pr=withnotify'); + + const request: CreatePaymentRequest = { + orderId: 'order-wx-006', + amount: 10, + paymentType: 'wxpay_direct', + subject: 'Test', + isMobile: false, + notifyUrl: 'https://pay.example.com/api/wxpay/notify-alt', + }; + + const result = await provider.createPayment(request); + expect(result.qrCode).toBe('weixin://wxpay/bizpayurl?pr=withnotify'); + // WXPAY_NOTIFY_URL from env takes priority over request.notifyUrl + expect(mockWxpayCreatePcOrder).toHaveBeenCalledWith( + expect.objectContaining({ + notify_url: 'https://pay.example.com/api/wxpay/notify', + }), + ); + }); + }); + + // ---------------------------------------------------------- + // shouldAutoRedirect logic (PaymentQRCode component) + // ---------------------------------------------------------- + + describe('shouldAutoRedirect (PaymentQRCode logic)', () => { + it('PC + qrCode + payUrl => false (show QR code, do not redirect)', () => { + expect( + shouldAutoRedirect({ + expired: false, + paymentType: 'alipay', + payUrl: 'https://example.com/pay', + qrCode: 'https://qr.alipay.com/xxx', + isMobile: false, + }), + ).toBe(false); + }); + + it('PC + payUrl only (no qrCode) => true (redirect)', () => { + expect( + shouldAutoRedirect({ + expired: false, + paymentType: 'alipay_direct', + payUrl: 'https://openapi.alipay.com/gateway.do?...', + qrCode: undefined, + isMobile: false, + }), + ).toBe(true); + }); + + it('PC + payUrl + empty qrCode string => true (redirect)', () => { + expect( + shouldAutoRedirect({ + expired: false, + paymentType: 'alipay_direct', + payUrl: 'https://openapi.alipay.com/gateway.do?...', + qrCode: '', + isMobile: false, + }), + ).toBe(true); + }); + + it('PC + payUrl + null qrCode => true (redirect)', () => { + expect( + shouldAutoRedirect({ + expired: false, + paymentType: 'alipay_direct', + payUrl: 'https://openapi.alipay.com/gateway.do?...', + qrCode: null, + isMobile: false, + }), + ).toBe(true); + }); + + it('Mobile + payUrl => true (redirect)', () => { + expect( + shouldAutoRedirect({ + expired: false, + paymentType: 'wxpay_direct', + payUrl: 'https://wx.tenpay.com/...', + qrCode: undefined, + isMobile: true, + }), + ).toBe(true); + }); + + it('Mobile + payUrl + qrCode => true (redirect, mobile always prefers payUrl)', () => { + expect( + shouldAutoRedirect({ + expired: false, + paymentType: 'alipay', + payUrl: 'https://easypay.example.com/pay/xxx', + qrCode: 'https://qr.alipay.com/xxx', + isMobile: true, + }), + ).toBe(true); + }); + + it('Mobile + qrCode only (no payUrl) => false (show QR code)', () => { + expect( + shouldAutoRedirect({ + expired: false, + paymentType: 'wxpay_direct', + payUrl: undefined, + qrCode: 'weixin://wxpay/bizpayurl?pr=xxx', + isMobile: true, + }), + ).toBe(false); + }); + + it('Stripe => false (never redirect, uses Payment Element)', () => { + expect( + shouldAutoRedirect({ + expired: false, + paymentType: 'stripe', + payUrl: 'https://checkout.stripe.com/xxx', + qrCode: undefined, + isMobile: false, + }), + ).toBe(false); + }); + + it('Stripe on mobile => false (still no redirect)', () => { + expect( + shouldAutoRedirect({ + expired: false, + paymentType: 'stripe', + payUrl: 'https://checkout.stripe.com/xxx', + qrCode: undefined, + isMobile: true, + }), + ).toBe(false); + }); + + it('Expired order => false (never redirect expired orders)', () => { + expect( + shouldAutoRedirect({ + expired: true, + paymentType: 'alipay', + payUrl: 'https://example.com/pay', + qrCode: undefined, + isMobile: true, + }), + ).toBe(false); + }); + + it('No payUrl at all => false (nothing to redirect to)', () => { + expect( + shouldAutoRedirect({ + expired: false, + paymentType: 'alipay', + payUrl: undefined, + qrCode: undefined, + isMobile: true, + }), + ).toBe(false); + }); + + it('Empty payUrl string => false (treated as falsy)', () => { + expect( + shouldAutoRedirect({ + expired: false, + paymentType: 'alipay', + payUrl: '', + qrCode: undefined, + isMobile: true, + }), + ).toBe(false); + }); + + it('Null payUrl => false (treated as falsy)', () => { + expect( + shouldAutoRedirect({ + expired: false, + paymentType: 'alipay', + payUrl: null, + qrCode: undefined, + isMobile: true, + }), + ).toBe(false); + }); + }); + + // ---------------------------------------------------------- + // Utility function tests + // ---------------------------------------------------------- + + describe('isStripeType', () => { + it('returns true for "stripe"', () => { + expect(isStripeType('stripe')).toBe(true); + }); + + it('returns true for stripe-prefixed types', () => { + expect(isStripeType('stripe_card')).toBe(true); + }); + + it('returns false for alipay', () => { + expect(isStripeType('alipay')).toBe(false); + }); + + it('returns false for wxpay', () => { + expect(isStripeType('wxpay')).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isStripeType(undefined)).toBe(false); + }); + + it('returns false for null', () => { + expect(isStripeType(null)).toBe(false); + }); + }); + + describe('REDIRECT_PAYMENT_TYPES', () => { + it('includes alipay_direct', () => { + expect(REDIRECT_PAYMENT_TYPES.has('alipay_direct')).toBe(true); + }); + + it('does not include alipay (easypay version)', () => { + expect(REDIRECT_PAYMENT_TYPES.has('alipay')).toBe(false); + }); + + it('does not include wxpay types', () => { + expect(REDIRECT_PAYMENT_TYPES.has('wxpay')).toBe(false); + expect(REDIRECT_PAYMENT_TYPES.has('wxpay_direct')).toBe(false); + }); + + it('does not include stripe', () => { + expect(REDIRECT_PAYMENT_TYPES.has('stripe')).toBe(false); + }); + }); +}); diff --git a/src/app/admin/dashboard/page.tsx b/src/app/admin/dashboard/page.tsx index 22e5d96..d30095d 100644 --- a/src/app/admin/dashboard/page.tsx +++ b/src/app/admin/dashboard/page.tsx @@ -7,6 +7,7 @@ import DashboardStats from '@/components/admin/DashboardStats'; import DailyChart from '@/components/admin/DailyChart'; import Leaderboard from '@/components/admin/Leaderboard'; import PaymentMethodChart from '@/components/admin/PaymentMethodChart'; +import { resolveLocale, type Locale } from '@/lib/locale'; interface DashboardData { summary: { @@ -34,9 +35,38 @@ function DashboardContent() { const token = searchParams.get('token'); const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light'; const uiMode = searchParams.get('ui_mode') || 'standalone'; + const locale = resolveLocale(searchParams.get('lang')); const isDark = theme === 'dark'; const isEmbedded = uiMode === 'embedded'; + const text = locale === 'en' + ? { + missingToken: 'Missing admin token', + missingTokenHint: 'Please access the admin page from the Sub2API platform.', + invalidToken: 'Invalid admin token', + requestFailed: 'Request failed', + loadFailed: 'Failed to load data', + title: 'Dashboard', + subtitle: 'Recharge order analytics and insights', + daySuffix: 'd', + orders: 'Order Management', + refresh: 'Refresh', + loading: 'Loading...', + } + : { + missingToken: '缺少管理员凭证', + missingTokenHint: '请从 Sub2API 平台正确访问管理页面', + invalidToken: '管理员凭证无效', + requestFailed: '请求失败', + loadFailed: '加载数据失败', + title: '数据概览', + subtitle: '充值订单统计与分析', + daySuffix: '天', + orders: '订单管理', + refresh: '刷新', + loading: '加载中...', + }; + const [days, setDays] = useState(30); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -50,14 +80,14 @@ function DashboardContent() { const res = await fetch(`/api/admin/dashboard?token=${encodeURIComponent(token)}&days=${days}`); if (!res.ok) { if (res.status === 401) { - setError('管理员凭证无效'); + setError(text.invalidToken); return; } - throw new Error('请求失败'); + throw new Error(text.requestFailed); } setData(await res.json()); } catch { - setError('加载数据失败'); + setError(text.loadFailed); } finally { setLoading(false); } @@ -71,8 +101,8 @@ function DashboardContent() { return (
-

缺少管理员凭证

-

请从 Sub2API 平台正确访问管理页面

+

{text.missingToken}

+

{text.missingTokenHint}

); @@ -80,6 +110,7 @@ function DashboardContent() { const navParams = new URLSearchParams(); navParams.set('token', token); + if (locale === 'en') navParams.set('lang', 'en'); if (theme === 'dark') navParams.set('theme', 'dark'); if (isEmbedded) navParams.set('ui_mode', 'embedded'); @@ -100,20 +131,21 @@ function DashboardContent() { isDark={isDark} isEmbedded={isEmbedded} maxWidth="full" - title="数据概览" - subtitle="充值订单统计与分析" + title={text.title} + subtitle={text.subtitle} + locale={locale} actions={ <> {DAYS_OPTIONS.map((d) => ( ))} - 订单管理 + {text.orders} } @@ -130,14 +162,14 @@ function DashboardContent() { )} {loading ? ( -
加载中...
+
{text.loading}
) : data ? (
- - + +
- - + +
) : null} @@ -145,14 +177,21 @@ function DashboardContent() { ); } +function DashboardPageFallback() { + const searchParams = useSearchParams(); + const locale = resolveLocale(searchParams.get('lang')); + + return ( +
+
{locale === 'en' ? 'Loading...' : '加载中...'}
+
+ ); +} + export default function DashboardPage() { return ( -
加载中...
- - } + fallback={} >
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 30da18c..8bebbd1 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -6,6 +6,7 @@ import OrderTable from '@/components/admin/OrderTable'; import OrderDetail from '@/components/admin/OrderDetail'; import PaginationBar from '@/components/PaginationBar'; import PayPageLayout from '@/components/PayPageLayout'; +import { resolveLocale, type Locale } from '@/lib/locale'; interface AdminOrder { id: string; @@ -47,9 +48,72 @@ function AdminContent() { const token = searchParams.get('token'); const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light'; const uiMode = searchParams.get('ui_mode') || 'standalone'; + const locale = resolveLocale(searchParams.get('lang')); const isDark = theme === 'dark'; const isEmbedded = uiMode === 'embedded'; + const text = locale === 'en' + ? { + missingToken: 'Missing admin token', + missingTokenHint: 'Please access the admin page from the Sub2API platform.', + invalidToken: 'Invalid admin token', + requestFailed: 'Request failed', + loadOrdersFailed: 'Failed to load orders', + retryConfirm: 'Retry recharge for this order?', + retryFailed: 'Retry failed', + retryRequestFailed: 'Retry request failed', + cancelConfirm: 'Cancel this order?', + cancelFailed: 'Cancel failed', + cancelRequestFailed: 'Cancel request failed', + loadDetailFailed: 'Failed to load order details', + title: 'Order Management', + subtitle: 'View and manage all recharge orders', + dashboard: 'Dashboard', + refresh: 'Refresh', + loading: 'Loading...', + statuses: { + '': 'All', + PENDING: 'Pending', + PAID: 'Paid', + RECHARGING: 'Recharging', + COMPLETED: 'Completed', + EXPIRED: 'Expired', + CANCELLED: 'Cancelled', + FAILED: 'Recharge failed', + REFUNDED: 'Refunded', + }, + } + : { + missingToken: '缺少管理员凭证', + missingTokenHint: '请从 Sub2API 平台正确访问管理页面', + invalidToken: '管理员凭证无效', + requestFailed: '请求失败', + loadOrdersFailed: '加载订单列表失败', + retryConfirm: '确认重试充值?', + retryFailed: '重试失败', + retryRequestFailed: '重试请求失败', + cancelConfirm: '确认取消该订单?', + cancelFailed: '取消失败', + cancelRequestFailed: '取消请求失败', + loadDetailFailed: '加载订单详情失败', + title: '订单管理', + subtitle: '查看和管理所有充值订单', + dashboard: '数据概览', + refresh: '刷新', + loading: '加载中...', + statuses: { + '': '全部', + PENDING: '待支付', + PAID: '已支付', + RECHARGING: '充值中', + COMPLETED: '已完成', + EXPIRED: '已超时', + CANCELLED: '已取消', + FAILED: '充值失败', + REFUNDED: '已退款', + }, + }; + const [orders, setOrders] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); @@ -71,18 +135,18 @@ function AdminContent() { const res = await fetch(`/api/admin/orders?${params}`); if (!res.ok) { if (res.status === 401) { - setError('管理员凭证无效'); + setError(text.invalidToken); return; } - throw new Error('请求失败'); + throw new Error(text.requestFailed); } const data = await res.json(); setOrders(data.orders); setTotal(data.total); setTotalPages(data.total_pages); - } catch (e) { - setError('加载订单列表失败'); + } catch { + setError(text.loadOrdersFailed); } finally { setLoading(false); } @@ -96,15 +160,15 @@ function AdminContent() { return (
-

缺少管理员凭证

-

请从 Sub2API 平台正确访问管理页面

+

{text.missingToken}

+

{text.missingTokenHint}

); } const handleRetry = async (orderId: string) => { - if (!confirm('确认重试充值?')) return; + if (!confirm(text.retryConfirm)) return; try { const res = await fetch(`/api/admin/orders/${orderId}/retry?token=${token}`, { method: 'POST', @@ -113,15 +177,15 @@ function AdminContent() { fetchOrders(); } else { const data = await res.json(); - setError(data.error || '重试失败'); + setError(data.error || text.retryFailed); } } catch { - setError('重试请求失败'); + setError(text.retryRequestFailed); } }; const handleCancel = async (orderId: string) => { - if (!confirm('确认取消该订单?')) return; + if (!confirm(text.cancelConfirm)) return; try { const res = await fetch(`/api/admin/orders/${orderId}/cancel?token=${token}`, { method: 'POST', @@ -130,10 +194,10 @@ function AdminContent() { fetchOrders(); } else { const data = await res.json(); - setError(data.error || '取消失败'); + setError(data.error || text.cancelFailed); } } catch { - setError('取消请求失败'); + setError(text.cancelRequestFailed); } }; @@ -145,25 +209,16 @@ function AdminContent() { setDetailOrder(data); } } catch { - setError('加载订单详情失败'); + setError(text.loadDetailFailed); } }; const statuses = ['', 'PENDING', 'PAID', 'RECHARGING', 'COMPLETED', 'EXPIRED', 'CANCELLED', 'FAILED', 'REFUNDED']; - const statusLabels: Record = { - '': '全部', - PENDING: '待支付', - PAID: '已支付', - RECHARGING: '充值中', - COMPLETED: '已完成', - EXPIRED: '已超时', - CANCELLED: '已取消', - FAILED: '充值失败', - REFUNDED: '已退款', - }; + const statusLabels: Record = text.statuses; const navParams = new URLSearchParams(); if (token) navParams.set('token', token); + if (locale === 'en') navParams.set('lang', 'en'); if (isDark) navParams.set('theme', 'dark'); if (isEmbedded) navParams.set('ui_mode', 'embedded'); @@ -179,15 +234,16 @@ function AdminContent() { isDark={isDark} isEmbedded={isEmbedded} maxWidth="full" - title="订单管理" - subtitle="查看和管理所有充值订单" + title={text.title} + subtitle={text.subtitle} + locale={locale} actions={ <> - 数据概览 + {text.dashboard} } @@ -236,7 +292,7 @@ function AdminContent() { ].join(' ')} > {loading ? ( -
加载中...
+
{text.loading}
) : ( )} @@ -259,23 +316,31 @@ function AdminContent() { setPageSize(s); setPage(1); }} + locale={locale} isDark={isDark} /> {/* Order Detail */} - {detailOrder && setDetailOrder(null)} dark={isDark} />} + {detailOrder && setDetailOrder(null)} dark={isDark} locale={locale} />} ); } +function AdminPageFallback() { + const searchParams = useSearchParams(); + const locale = resolveLocale(searchParams.get('lang')); + + return ( +
+
{locale === 'en' ? 'Loading...' : '加载中...'}
+
+ ); +} + export default function AdminPage() { return ( -
加载中...
- - } + fallback={} >
diff --git a/src/app/api/admin/orders/[id]/cancel/route.ts b/src/app/api/admin/orders/[id]/cancel/route.ts index e200bfd..4068a8e 100644 --- a/src/app/api/admin/orders/[id]/cancel/route.ts +++ b/src/app/api/admin/orders/[id]/cancel/route.ts @@ -1,19 +1,26 @@ import { NextRequest, NextResponse } from 'next/server'; import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth'; +import { resolveLocale } from '@/lib/locale'; import { adminCancelOrder } from '@/lib/order/service'; import { handleApiError } from '@/lib/utils/api'; export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - if (!(await verifyAdminToken(request))) return unauthorizedResponse(); + if (!(await verifyAdminToken(request))) return unauthorizedResponse(request); + + const locale = resolveLocale(request.nextUrl.searchParams.get('lang')); try { const { id } = await params; - const outcome = await adminCancelOrder(id); + const outcome = await adminCancelOrder(id, locale); if (outcome === 'already_paid') { - return NextResponse.json({ success: true, status: 'PAID', message: '订单已支付完成' }); + return NextResponse.json({ + success: true, + status: 'PAID', + message: locale === 'en' ? 'Order has already been paid' : '订单已支付完成', + }); } return NextResponse.json({ success: true }); } catch (error) { - return handleApiError(error, '取消订单失败'); + return handleApiError(error, locale === 'en' ? 'Cancel order failed' : '取消订单失败', request); } } diff --git a/src/app/api/admin/orders/[id]/retry/route.ts b/src/app/api/admin/orders/[id]/retry/route.ts index c67ae94..262f11a 100644 --- a/src/app/api/admin/orders/[id]/retry/route.ts +++ b/src/app/api/admin/orders/[id]/retry/route.ts @@ -1,16 +1,19 @@ import { NextRequest, NextResponse } from 'next/server'; import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth'; +import { resolveLocale } from '@/lib/locale'; import { retryRecharge } from '@/lib/order/service'; import { handleApiError } from '@/lib/utils/api'; export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - if (!(await verifyAdminToken(request))) return unauthorizedResponse(); + if (!(await verifyAdminToken(request))) return unauthorizedResponse(request); + + const locale = resolveLocale(request.nextUrl.searchParams.get('lang')); try { const { id } = await params; - await retryRecharge(id); + await retryRecharge(id, locale); return NextResponse.json({ success: true }); } catch (error) { - return handleApiError(error, '重试充值失败'); + return handleApiError(error, locale === 'en' ? 'Recharge retry failed' : '重试充值失败', request); } } diff --git a/src/app/api/admin/orders/[id]/route.ts b/src/app/api/admin/orders/[id]/route.ts index 313bda2..bc9c2c4 100644 --- a/src/app/api/admin/orders/[id]/route.ts +++ b/src/app/api/admin/orders/[id]/route.ts @@ -1,11 +1,13 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/db'; import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth'; +import { resolveLocale } from '@/lib/locale'; export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - if (!(await verifyAdminToken(request))) return unauthorizedResponse(); + if (!(await verifyAdminToken(request))) return unauthorizedResponse(request); const { id } = await params; + const locale = resolveLocale(request.nextUrl.searchParams.get('lang')); const order = await prisma.order.findUnique({ where: { id }, @@ -17,7 +19,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ }); if (!order) { - return NextResponse.json({ error: '订单不存在' }, { status: 404 }); + return NextResponse.json({ error: locale === 'en' ? 'Order not found' : '订单不存在' }, { status: 404 }); } return NextResponse.json({ diff --git a/src/app/api/admin/refund/route.ts b/src/app/api/admin/refund/route.ts index 414eb8e..6b5cd55 100644 --- a/src/app/api/admin/refund/route.ts +++ b/src/app/api/admin/refund/route.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth'; import { processRefund } from '@/lib/order/service'; import { handleApiError } from '@/lib/utils/api'; +import { resolveLocale } from '@/lib/locale'; const refundSchema = z.object({ order_id: z.string().min(1), @@ -11,24 +12,30 @@ const refundSchema = z.object({ }); export async function POST(request: NextRequest) { - if (!(await verifyAdminToken(request))) return unauthorizedResponse(); + if (!(await verifyAdminToken(request))) return unauthorizedResponse(request); + + const locale = resolveLocale(request.nextUrl.searchParams.get('lang')); try { const body = await request.json(); const parsed = refundSchema.safeParse(body); if (!parsed.success) { - return NextResponse.json({ error: '参数错误', details: parsed.error.flatten().fieldErrors }, { status: 400 }); + return NextResponse.json( + { error: locale === 'en' ? 'Invalid parameters' : '参数错误', details: parsed.error.flatten().fieldErrors }, + { status: 400 }, + ); } const result = await processRefund({ orderId: parsed.data.order_id, reason: parsed.data.reason, force: parsed.data.force, + locale, }); return NextResponse.json(result); } catch (error) { - return handleApiError(error, '退款失败'); + return handleApiError(error, locale === 'en' ? 'Refund failed' : '退款失败', request); } } diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts index 1c0536c..fe4c262 100644 --- a/src/app/api/user/route.ts +++ b/src/app/api/user/route.ts @@ -3,17 +3,19 @@ import { getUser, getCurrentUserByToken } from '@/lib/sub2api/client'; import { getEnv } from '@/lib/config'; import { queryMethodLimits } from '@/lib/order/limits'; import { initPaymentProviders, paymentRegistry } from '@/lib/payment'; -import { PAYMENT_TYPE_META } from '@/lib/pay-utils'; +import { getPaymentDisplayInfo } from '@/lib/pay-utils'; +import { resolveLocale } from '@/lib/locale'; export async function GET(request: NextRequest) { + const locale = resolveLocale(request.nextUrl.searchParams.get('lang')); const userId = Number(request.nextUrl.searchParams.get('user_id')); if (!userId || isNaN(userId) || userId <= 0) { - return NextResponse.json({ error: '无效的用户 ID' }, { status: 400 }); + return NextResponse.json({ error: locale === 'en' ? 'Invalid user ID' : '无效的用户 ID' }, { status: 400 }); } const token = request.nextUrl.searchParams.get('token')?.trim(); if (!token) { - return NextResponse.json({ error: '缺少 token 参数' }, { status: 401 }); + return NextResponse.json({ error: locale === 'en' ? 'Missing token parameter' : '缺少 token 参数' }, { status: 401 }); } try { @@ -22,11 +24,11 @@ export async function GET(request: NextRequest) { try { tokenUser = await getCurrentUserByToken(token); } catch { - return NextResponse.json({ error: '无效的 token' }, { status: 401 }); + return NextResponse.json({ error: locale === 'en' ? 'Invalid token' : '无效的 token' }, { status: 401 }); } if (tokenUser.id !== userId) { - return NextResponse.json({ error: '无权访问该用户信息' }, { status: 403 }); + return NextResponse.json({ error: locale === 'en' ? 'Forbidden to access this user' : '无权访问该用户信息' }, { status: 403 }); } const env = getEnv(); @@ -40,17 +42,16 @@ export async function GET(request: NextRequest) { // 1. 检测同 label 冲突:多个启用渠道有相同的显示名,自动标记默认 sublabel(provider 名) const labelCount = new Map(); for (const type of enabledTypes) { - const meta = PAYMENT_TYPE_META[type]; - if (!meta) continue; - const types = labelCount.get(meta.label) || []; + const { channel } = getPaymentDisplayInfo(type, locale); + const types = labelCount.get(channel) || []; types.push(type); - labelCount.set(meta.label, types); + labelCount.set(channel, types); } for (const [, types] of labelCount) { if (types.length > 1) { for (const type of types) { - const meta = PAYMENT_TYPE_META[type]; - if (meta) sublabelOverrides[type] = meta.provider; + const { provider } = getPaymentDisplayInfo(type, locale); + if (provider) sublabelOverrides[type] = provider; } } } @@ -85,9 +86,9 @@ export async function GET(request: NextRequest) { } catch (error) { const message = error instanceof Error ? error.message : String(error); if (message === 'USER_NOT_FOUND') { - return NextResponse.json({ error: '用户不存在' }, { status: 404 }); + return NextResponse.json({ error: locale === 'en' ? 'User not found' : '用户不存在' }, { status: 404 }); } console.error('Get user error:', error); - return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 }); + return NextResponse.json({ error: locale === 'en' ? 'Failed to fetch user info' : '获取用户信息失败' }, { status: 500 }); } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index cd5a5ce..22b5fc8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,18 +1,25 @@ import type { Metadata } from 'next'; +import { headers } from 'next/headers'; import './globals.css'; export const metadata: Metadata = { - title: 'Sub2API 充值', - description: 'Sub2API 余额充值平台', + title: 'Sub2API Recharge', + description: 'Sub2API balance recharge platform', }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const headerStore = await headers(); + const pathname = headerStore.get('x-pathname') || ''; + const search = headerStore.get('x-search') || ''; + const locale = new URLSearchParams(search).get('lang')?.trim().toLowerCase() === 'en' ? 'en' : 'zh'; + const htmlLang = locale === 'en' ? 'en' : 'zh-CN'; + return ( - + {children} ); diff --git a/src/app/page.tsx b/src/app/page.tsx index f048501..81d29f3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,11 @@ import { redirect } from 'next/navigation'; -export default function Home() { - redirect('/pay'); +export default async function Home({ + searchParams, +}: { + searchParams?: Promise>; +}) { + const params = await searchParams; + const lang = Array.isArray(params?.lang) ? params?.lang[0] : params?.lang; + redirect(lang === 'en' ? '/pay?lang=en' : '/pay'); } diff --git a/src/app/pay/orders/page.tsx b/src/app/pay/orders/page.tsx index b2fe8f1..3a1bcac 100644 --- a/src/app/pay/orders/page.tsx +++ b/src/app/pay/orders/page.tsx @@ -7,6 +7,7 @@ import OrderFilterBar from '@/components/OrderFilterBar'; import OrderSummaryCards from '@/components/OrderSummaryCards'; import OrderTable from '@/components/OrderTable'; import PaginationBar from '@/components/PaginationBar'; +import { applyLocaleToSearchParams, pickLocaleText, resolveLocale } from '@/lib/locale'; import { detectDeviceIsMobile, type UserInfo, type MyOrder, type OrderStatusFilter } from '@/lib/pay-utils'; const PAGE_SIZE_OPTIONS = [20, 50, 100]; @@ -24,8 +25,24 @@ function OrdersContent() { const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light'; const uiMode = searchParams.get('ui_mode') || 'standalone'; const srcHost = searchParams.get('src_host') || ''; + const locale = resolveLocale(searchParams.get('lang')); const isDark = theme === 'dark'; + const text = { + missingAuth: pickLocaleText(locale, '缺少认证信息', 'Missing authentication information'), + visitOrders: pickLocaleText(locale, '请从 Sub2API 平台正确访问订单页面', 'Please open the orders page from Sub2API'), + sessionExpired: pickLocaleText(locale, '登录态已失效,请从 Sub2API 重新进入支付页。', 'Session expired. Please re-enter from Sub2API.'), + loadFailed: pickLocaleText(locale, '订单加载失败,请稍后重试。', 'Failed to load orders. Please try again later.'), + networkError: pickLocaleText(locale, '网络错误,请稍后重试。', 'Network error. Please try again later.'), + switchingMobileTab: pickLocaleText(locale, '正在切换到移动端订单 Tab...', 'Switching to mobile orders tab...'), + myOrders: pickLocaleText(locale, '我的订单', 'My Orders'), + refresh: pickLocaleText(locale, '刷新', 'Refresh'), + backToPay: pickLocaleText(locale, '返回充值', 'Back to Top Up'), + loading: pickLocaleText(locale, '加载中...', 'Loading...'), + userPrefix: pickLocaleText(locale, '用户', 'User'), + authError: pickLocaleText(locale, '缺少认证信息,请从 Sub2API 平台正确访问订单页面', 'Missing authentication information. Please open the orders page from Sub2API.'), + }; + const [isIframeContext, setIsIframeContext] = useState(true); const [isMobile, setIsMobile] = useState(false); const [userInfo, setUserInfo] = useState(null); @@ -56,9 +73,9 @@ function OrdersContent() { params.set('theme', theme); params.set('ui_mode', uiMode); params.set('tab', 'orders'); + applyLocaleToSearchParams(params, locale); window.location.replace(`/pay?${params.toString()}`); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isMobile, isEmbedded]); + }, [isMobile, isEmbedded, token, theme, uiMode, locale]); const loadOrders = async (targetPage = page, targetPageSize = pageSize) => { setLoading(true); @@ -66,7 +83,7 @@ function OrdersContent() { try { if (!hasToken) { setOrders([]); - setError('缺少认证信息,请从 Sub2API 平台正确访问订单页面。'); + setError(text.authError); return; } @@ -77,7 +94,7 @@ function OrdersContent() { }); const res = await fetch(`/api/orders/my?${params}`); if (!res.ok) { - setError(res.status === 401 ? '登录态已失效,请从 Sub2API 重新进入支付页。' : '订单加载失败,请稍后重试。'); + setError(res.status === 401 ? text.sessionExpired : text.loadFailed); setOrders([]); return; } @@ -92,7 +109,7 @@ function OrdersContent() { username: (typeof meUser.displayName === 'string' && meUser.displayName.trim()) || (typeof meUser.username === 'string' && meUser.username.trim()) || - `用户 #${meId}`, + `${text.userPrefix} #${meId}`, balance: typeof meUser.balance === 'number' ? meUser.balance : 0, }); @@ -102,7 +119,7 @@ function OrdersContent() { setTotalPages(data.total_pages ?? 1); } catch { setOrders([]); - setError('网络错误,请稍后重试。'); + setError(text.networkError); } finally { setLoading(false); } @@ -111,7 +128,6 @@ function OrdersContent() { useEffect(() => { if (isMobile && !isEmbedded) return; loadOrders(1, pageSize); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [token, isMobile, isEmbedded]); const handlePageChange = (newPage: number) => { @@ -139,7 +155,7 @@ function OrdersContent() {
- 正在切换到移动端订单 Tab... + {text.switchingMobileTab}
); } @@ -148,8 +164,8 @@ function OrdersContent() { return (
-

缺少认证信息

-

请从 Sub2API 平台正确访问订单页面

+

{text.missingAuth}

+

{text.visitOrders}

); @@ -160,6 +176,7 @@ function OrdersContent() { if (token) params.set('token', token); params.set('theme', theme); params.set('ui_mode', uiMode); + applyLocaleToSearchParams(params, locale); return `${path}?${params.toString()}`; }; @@ -167,28 +184,28 @@ function OrdersContent() { {!srcHost && ( - 返回充值 + {text.backToPay} )} } > - +
- +
- + +
{pickLocaleText(locale, '加载中...', 'Loading...')}
+ + ); +} + export default function OrdersPage() { return ( - -
加载中...
- - } - > + }> ); diff --git a/src/app/pay/page.tsx b/src/app/pay/page.tsx index d626925..48c4e03 100644 --- a/src/app/pay/page.tsx +++ b/src/app/pay/page.tsx @@ -7,6 +7,7 @@ import PaymentQRCode from '@/components/PaymentQRCode'; import OrderStatus from '@/components/OrderStatus'; import PayPageLayout from '@/components/PayPageLayout'; import MobileOrderList from '@/components/MobileOrderList'; +import { resolveLocale, pickLocaleText, applyLocaleToSearchParams } from '@/lib/locale'; import { detectDeviceIsMobile, applySublabelOverrides, type UserInfo, type MyOrder } from '@/lib/pay-utils'; import type { MethodLimitInfo } from '@/components/PaymentForm'; @@ -41,6 +42,7 @@ function PayContent() { const tab = searchParams.get('tab'); const srcHost = searchParams.get('src_host') || undefined; const srcUrl = searchParams.get('src_url') || undefined; + const locale = resolveLocale(searchParams.get('lang')); const isDark = theme === 'dark'; const [isIframeContext, setIsIframeContext] = useState(true); @@ -97,7 +99,6 @@ function PayContent() { setUserNotFound(false); try { - // 通过 token 获取用户详情和订单 const meRes = await fetch(`/api/orders/my?token=${encodeURIComponent(token)}`); if (!meRes.ok) { setUserNotFound(true); @@ -120,7 +121,7 @@ function PayContent() { username: (typeof meUser.displayName === 'string' && meUser.displayName.trim()) || (typeof meUser.username === 'string' && meUser.username.trim()) || - `用户 #${meId}`, + pickLocaleText(locale, `用户 #${meId}`, `User #${meId}`), balance: typeof meUser.balance === 'number' ? meUser.balance : undefined, }); @@ -134,7 +135,6 @@ function PayContent() { setOrdersHasMore(false); } - // 获取服务端支付配置 const cfgRes = await fetch(`/api/user?user_id=${meId}&token=${encodeURIComponent(token)}`); if (cfgRes.ok) { const cfgData = await cfgRes.json(); @@ -155,7 +155,6 @@ function PayContent() { } } } catch { - // ignore and keep page usable } }; @@ -175,7 +174,6 @@ function PayContent() { setOrdersHasMore(false); } } catch { - // ignore } finally { setOrdersLoadingMore(false); } @@ -183,12 +181,10 @@ function PayContent() { useEffect(() => { loadUserAndOrders(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [token]); + }, [token, locale]); useEffect(() => { if (step !== 'result' || finalStatus !== 'COMPLETED') return; - // 立即在后台刷新余额,2.2s 显示结果页后再切回表单(届时余额已更新) loadUserAndOrders(); const timer = setTimeout(() => { setStep('form'); @@ -197,15 +193,16 @@ function PayContent() { setError(''); }, 2200); return () => clearTimeout(timer); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [step, finalStatus]); if (!hasToken) { return (
-

缺少认证信息

-

请从 Sub2API 平台正确访问充值页面

+

{pickLocaleText(locale, '缺少认证信息', 'Missing authentication info')}

+

+ {pickLocaleText(locale, '请从 Sub2API 平台正确访问充值页面', 'Please open the recharge page from the Sub2API platform')} +

); @@ -215,8 +212,10 @@ function PayContent() { return (
-

用户不存在

-

请检查链接是否正确,或联系管理员

+

{pickLocaleText(locale, '用户不存在', 'User not found')}

+

+ {pickLocaleText(locale, '请检查链接是否正确,或联系管理员', 'Please check whether the link is correct or contact the administrator')} +

); @@ -228,6 +227,9 @@ function PayContent() { params.set('theme', theme); params.set('ui_mode', uiMode); if (forceOrdersTab) params.set('tab', 'orders'); + if (srcHost) params.set('src_host', srcHost); + if (srcUrl) params.set('src_url', srcUrl); + applyLocaleToSearchParams(params, locale); return `${path}?${params.toString()}`; }; @@ -237,7 +239,13 @@ function PayContent() { const handleSubmit = async (amount: number, paymentType: string) => { if (pendingBlocked) { - setError(`您有 ${pendingCount} 个待支付订单,请先完成或取消后再试(最多 ${MAX_PENDING} 个)`); + setError( + pickLocaleText( + locale, + `您有 ${pendingCount} 个待支付订单,请先完成或取消后再试(最多 ${MAX_PENDING} 个)`, + `You have ${pendingCount} pending orders. Please complete or cancel them first (maximum ${MAX_PENDING}).`, + ), + ); return; } @@ -262,15 +270,15 @@ function PayContent() { if (!res.ok) { const codeMessages: Record = { - INVALID_TOKEN: '认证已失效,请重新从平台进入充值页面', - USER_INACTIVE: '账户已被禁用,无法充值,请联系管理员', - TOO_MANY_PENDING: '您有过多待支付订单,请先完成或取消现有订单后再试', - USER_NOT_FOUND: '用户不存在,请检查链接是否正确', + INVALID_TOKEN: pickLocaleText(locale, '认证已失效,请重新从平台进入充值页面', 'Authentication expired. Please re-enter the recharge page from the platform'), + USER_INACTIVE: pickLocaleText(locale, '账户已被禁用,无法充值,请联系管理员', 'This account is disabled and cannot be recharged. Please contact the administrator'), + TOO_MANY_PENDING: pickLocaleText(locale, '您有过多待支付订单,请先完成或取消现有订单后再试', 'You have too many pending orders. Please complete or cancel existing orders first'), + USER_NOT_FOUND: pickLocaleText(locale, '用户不存在,请检查链接是否正确', 'User not found. Please check whether the link is correct'), DAILY_LIMIT_EXCEEDED: data.error, METHOD_DAILY_LIMIT_EXCEEDED: data.error, PAYMENT_GATEWAY_ERROR: data.error, }; - setError(codeMessages[data.code] || data.error || '创建订单失败'); + setError(codeMessages[data.code] || data.error || pickLocaleText(locale, '创建订单失败', 'Failed to create order')); return; } @@ -288,7 +296,7 @@ function PayContent() { setStep('paying'); } catch { - setError('网络错误,请稍后重试'); + setError(pickLocaleText(locale, '网络错误,请稍后重试', 'Network error. Please try again later')); } finally { setLoading(false); } @@ -314,8 +322,9 @@ function PayContent() { isDark={isDark} isEmbedded={isEmbedded} maxWidth={isMobile ? 'sm' : 'lg'} - title="Sub2API 余额充值" - subtitle="安全支付,自动到账" + title={pickLocaleText(locale, 'Sub2API 余额充值', 'Sub2API Balance Recharge')} + subtitle={pickLocaleText(locale, '安全支付,自动到账', 'Secure payment, automatic crediting')} + locale={locale} actions={ !isMobile ? ( <> @@ -329,7 +338,7 @@ function PayContent() { : 'border-slate-300 text-slate-700 hover:bg-slate-100', ].join(' ')} > - 刷新 + {pickLocaleText(locale, '刷新', 'Refresh')} - 我的订单 + {pickLocaleText(locale, '我的订单', 'My Orders')} ) : undefined @@ -378,7 +387,7 @@ function PayContent() { : 'text-slate-500 hover:text-slate-700', ].join(' ')} > - 充值 + {pickLocaleText(locale, '充值', 'Recharge')} )} @@ -402,7 +411,9 @@ function PayContent() { {step === 'form' && config.enabledPaymentTypes.length === 0 && (
- 加载中... + + {pickLocaleText(locale, '加载中...', 'Loading...')} +
)} @@ -423,6 +434,7 @@ function PayContent() { dark={isDark} pendingBlocked={pendingBlocked} pendingCount={pendingCount} + locale={locale} /> ) : ( ) ) : ( @@ -451,6 +464,7 @@ function PayContent() { dark={isDark} pendingBlocked={pendingBlocked} pendingCount={pendingCount} + locale={locale} />
@@ -460,11 +474,17 @@ function PayContent() { isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50', ].join(' ')} > -
支付说明
+
+ {pickLocaleText(locale, '支付说明', 'Payment Notes')} +
    -
  • 订单完成后会自动到账
  • -
  • 如需历史记录请查看「我的订单」
  • - {config.maxDailyAmount > 0 &&
  • 每日最大充值 ¥{config.maxDailyAmount.toFixed(2)}
  • } +
  • {pickLocaleText(locale, '订单完成后会自动到账', 'Balance will be credited automatically after the order completes')}
  • +
  • {pickLocaleText(locale, '如需历史记录请查看「我的订单」', 'Check "My Orders" for payment history')}
  • + {config.maxDailyAmount > 0 && ( +
  • + {pickLocaleText(locale, '每日最大充值', 'Maximum daily recharge')} ¥{config.maxDailyAmount.toFixed(2)} +
  • + )}
@@ -475,7 +495,9 @@ function PayContent() { isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50', ].join(' ')} > -
Support
+
+ {pickLocaleText(locale, '帮助', 'Support')} +
{helpImageUrl && ( - {helpText.split('\\n').map((line, i) => ( + {helpText.split('\n').map((line, i) => (

{line}

))} @@ -521,10 +543,11 @@ function PayContent() { dark={isDark} isEmbedded={isEmbedded} isMobile={isMobile} + locale={locale} /> )} - {step === 'result' && } + {step === 'result' && } {helpImageOpen && helpImageUrl && (
+
{pickLocaleText(locale, '加载中...', 'Loading...')}
+
+ ); +} + export default function PayPage() { return ( -
加载中...
- - } + fallback={} >
diff --git a/src/app/pay/result/page.tsx b/src/app/pay/result/page.tsx index a7c2d14..bd9bf02 100644 --- a/src/app/pay/result/page.tsx +++ b/src/app/pay/result/page.tsx @@ -2,22 +2,43 @@ import { useSearchParams } from 'next/navigation'; import { useEffect, useState, Suspense } from 'react'; +import { applyLocaleToSearchParams, pickLocaleText, resolveLocale } from '@/lib/locale'; function ResultContent() { const searchParams = useSearchParams(); - // Support both ZPAY (out_trade_no) and Stripe (order_id) callback params const outTradeNo = searchParams.get('out_trade_no') || searchParams.get('order_id'); - const tradeStatus = searchParams.get('trade_status') || searchParams.get('status'); const isPopup = searchParams.get('popup') === '1'; const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light'; + const locale = resolveLocale(searchParams.get('lang')); const isDark = theme === 'dark'; + const text = { + checking: pickLocaleText(locale, '查询支付结果中...', 'Checking payment result...'), + success: pickLocaleText(locale, '充值成功', 'Top-up successful'), + processing: pickLocaleText(locale, '充值处理中', 'Top-up processing'), + successMessage: pickLocaleText(locale, '余额已成功到账!', 'Balance has been credited successfully!'), + processingMessage: pickLocaleText(locale, '支付成功,余额正在充值中...', 'Payment succeeded, balance is being credited...'), + returning: pickLocaleText(locale, '正在返回...', 'Returning...'), + returnNow: pickLocaleText(locale, '立即返回', 'Return now'), + pending: pickLocaleText(locale, '等待支付', 'Awaiting payment'), + pendingMessage: pickLocaleText(locale, '订单尚未完成支付', 'The order has not been paid yet'), + expired: pickLocaleText(locale, '订单已超时', 'Order expired'), + cancelled: pickLocaleText(locale, '订单已取消', 'Order cancelled'), + abnormal: pickLocaleText(locale, '支付异常', 'Payment error'), + expiredMessage: pickLocaleText(locale, '订单已超时,请重新充值', 'This order has expired. Please create a new one.'), + cancelledMessage: pickLocaleText(locale, '订单已被取消', 'This order has been cancelled.'), + abnormalMessage: pickLocaleText(locale, '请联系管理员处理', 'Please contact the administrator.'), + back: pickLocaleText(locale, '返回', 'Back'), + orderId: pickLocaleText(locale, '订单号', 'Order ID'), + unknown: pickLocaleText(locale, '未知', 'Unknown'), + loading: pickLocaleText(locale, '加载中...', 'Loading...'), + }; + const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const [isInPopup, setIsInPopup] = useState(false); const [countdown, setCountdown] = useState(5); - // Detect if opened as a popup window (from stripe-popup or via popup=1 param) useEffect(() => { if (isPopup || window.opener) { setIsInPopup(true); @@ -38,14 +59,12 @@ function ResultContent() { setStatus(data.status); } } catch { - // ignore } finally { setLoading(false); } }; checkOrder(); - // Poll a few times in case status hasn't updated yet const timer = setInterval(checkOrder, 3000); const timeout = setTimeout(() => clearInterval(timer), 30000); return () => { @@ -59,14 +78,20 @@ function ResultContent() { const goBack = () => { if (isInPopup) { window.close(); - } else if (window.history.length > 1) { - window.history.back(); - } else { - window.close(); + return; } + + if (window.history.length > 1) { + window.history.back(); + return; + } + + const params = new URLSearchParams(); + params.set('theme', theme); + applyLocaleToSearchParams(params, locale); + window.location.replace(`/pay?${params.toString()}`); }; - // Countdown auto-return on success useEffect(() => { if (!isSuccess) return; setCountdown(5); @@ -81,18 +106,18 @@ function ResultContent() { }); }, 1000); return () => clearInterval(timer); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSuccess, isInPopup]); if (loading) { return (
-
查询支付结果中...
+
{text.checking}
); } const isPending = status === 'PENDING'; + const countdownText = countdown > 0 ? pickLocaleText(locale, `${countdown} 秒后自动返回`, `${countdown} seconds before returning`) : text.returning; return (
@@ -105,78 +130,79 @@ function ResultContent() { {isSuccess ? ( <>
-

- {status === 'COMPLETED' ? '充值成功' : '充值处理中'} -

+

{status === 'COMPLETED' ? text.success : text.processing}

- {status === 'COMPLETED' ? '余额已成功到账!' : '支付成功,余额正在充值中...'} + {status === 'COMPLETED' ? text.successMessage : text.processingMessage}

-

- {countdown > 0 ? `${countdown} 秒后自动返回` : '正在返回...'} -

+

{countdownText}

) : isPending ? ( <>
-

等待支付

-

订单尚未完成支付

+

{text.pending}

+

{text.pendingMessage}

) : ( <>

- {status === 'EXPIRED' ? '订单已超时' : status === 'CANCELLED' ? '订单已取消' : '支付异常'} + {status === 'EXPIRED' ? text.expired : status === 'CANCELLED' ? text.cancelled : text.abnormal}

{status === 'EXPIRED' - ? '订单已超时,请重新充值' + ? text.expiredMessage : status === 'CANCELLED' - ? '订单已被取消' - : '请联系管理员处理'} + ? text.cancelledMessage + : text.abnormalMessage}

)}

- 订单号: {outTradeNo || '未知'} + {text.orderId}: {outTradeNo || text.unknown}

); } +function ResultPageFallback() { + const searchParams = useSearchParams(); + const locale = resolveLocale(searchParams.get('lang')); + + return ( +
+
{pickLocaleText(locale, '加载中...', 'Loading...')}
+
+ ); +} + export default function PayResultPage() { return ( - -
加载中...
- - } - > + }> ); diff --git a/src/app/pay/stripe-popup/page.tsx b/src/app/pay/stripe-popup/page.tsx index 33910fb..0a78b4d 100644 --- a/src/app/pay/stripe-popup/page.tsx +++ b/src/app/pay/stripe-popup/page.tsx @@ -2,6 +2,7 @@ import { useSearchParams } from 'next/navigation'; import { useEffect, useState, useCallback, Suspense } from 'react'; +import { applyLocaleToSearchParams, pickLocaleText, resolveLocale } from '@/lib/locale'; import { getPaymentMeta } from '@/lib/pay-utils'; function StripePopupContent() { @@ -10,10 +11,24 @@ function StripePopupContent() { const amount = parseFloat(searchParams.get('amount') || '0') || 0; const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light'; const method = searchParams.get('method') || ''; + const locale = resolveLocale(searchParams.get('lang')); const isDark = theme === 'dark'; const isAlipay = method === 'alipay'; - // Sensitive data received via postMessage from parent, NOT from URL + const text = { + init: pickLocaleText(locale, '正在初始化...', 'Initializing...'), + orderId: pickLocaleText(locale, '订单号', 'Order ID'), + loadFailed: pickLocaleText(locale, '支付组件加载失败,请关闭窗口重试', 'Failed to load payment component. Please close the window and try again.'), + payFailed: pickLocaleText(locale, '支付失败,请重试', 'Payment failed. Please try again.'), + closeWindow: pickLocaleText(locale, '关闭窗口', 'Close window'), + redirecting: pickLocaleText(locale, '正在跳转到支付页面...', 'Redirecting to payment page...'), + loadingForm: pickLocaleText(locale, '正在加载支付表单...', 'Loading payment form...'), + successClosing: pickLocaleText(locale, '支付成功,窗口即将自动关闭...', 'Payment successful. This window will close automatically...'), + closeWindowManually: pickLocaleText(locale, '手动关闭窗口', 'Close window manually'), + processing: pickLocaleText(locale, '处理中...', 'Processing...'), + payAmount: pickLocaleText(locale, `支付 ¥${amount.toFixed(2)}`, `Pay ¥${amount.toFixed(2)}`), + }; + const [credentials, setCredentials] = useState<{ clientSecret: string; publishableKey: string; @@ -34,10 +49,11 @@ function StripePopupContent() { returnUrl.searchParams.set('order_id', orderId); returnUrl.searchParams.set('status', 'success'); returnUrl.searchParams.set('popup', '1'); + returnUrl.searchParams.set('theme', theme); + applyLocaleToSearchParams(returnUrl.searchParams, locale); return returnUrl.toString(); - }, [orderId]); + }, [orderId, theme, locale]); - // Listen for credentials from parent window via postMessage useEffect(() => { const handler = (event: MessageEvent) => { if (event.origin !== window.location.origin) return; @@ -48,14 +64,12 @@ function StripePopupContent() { } }; window.addEventListener('message', handler); - // Signal parent that popup is ready to receive data if (window.opener) { window.opener.postMessage({ type: 'STRIPE_POPUP_READY' }, window.location.origin); } return () => window.removeEventListener('message', handler); }, []); - // Initialize Stripe once credentials are received useEffect(() => { if (!credentials) return; let cancelled = false; @@ -65,14 +79,13 @@ function StripePopupContent() { loadStripe(publishableKey).then((stripe) => { if (cancelled || !stripe) { if (!cancelled) { - setStripeError('支付组件加载失败,请关闭窗口重试'); + setStripeError(text.loadFailed); setStripeLoaded(true); } return; } if (isAlipay) { - // Alipay: confirm directly and redirect, no Payment Element needed stripe .confirmAlipayPayment(clientSecret, { return_url: buildReturnUrl(), @@ -80,15 +93,13 @@ function StripePopupContent() { .then((result) => { if (cancelled) return; if (result.error) { - setStripeError(result.error.message || '支付失败,请重试'); + setStripeError(result.error.message || text.payFailed); setStripeLoaded(true); } - // If no error, the page has already been redirected }); return; } - // Fallback: create Elements for Payment Element flow const elements = stripe.elements({ clientSecret, appearance: { @@ -103,9 +114,8 @@ function StripePopupContent() { return () => { cancelled = true; }; - }, [credentials, isDark, isAlipay, buildReturnUrl]); + }, [credentials, isDark, isAlipay, buildReturnUrl, text.loadFailed, text.payFailed]); - // Mount Payment Element (only for non-alipay methods) const stripeContainerRef = useCallback( (node: HTMLDivElement | null) => { if (!node || !stripeLib) return; @@ -135,7 +145,7 @@ function StripePopupContent() { }); if (error) { - setStripeError(error.message || '支付失败,请重试'); + setStripeError(error.message || text.payFailed); setStripeSubmitting(false); } else { setStripeSuccess(true); @@ -143,7 +153,6 @@ function StripePopupContent() { } }; - // Auto-close after success useEffect(() => { if (!stripeSuccess) return; const timer = setTimeout(() => { @@ -152,7 +161,6 @@ function StripePopupContent() { return () => clearTimeout(timer); }, [stripeSuccess]); - // Waiting for credentials from parent if (!credentials) { return (
@@ -161,14 +169,13 @@ function StripePopupContent() { >
- 正在初始化... + {text.init}
); } - // Alipay direct confirm: show loading/redirecting state if (isAlipay) { return (
@@ -180,7 +187,7 @@ function StripePopupContent() { {'¥'} {amount.toFixed(2)}
-

订单号: {orderId}

+

{text.orderId}: {orderId}

{stripeError ? (
@@ -190,14 +197,14 @@ function StripePopupContent() { onClick={() => window.close()} className="w-full text-sm text-blue-600 underline hover:text-blue-700" > - 关闭窗口 + {text.closeWindow}
) : (
- 正在跳转到支付页面... + {text.redirecting}
)} @@ -216,26 +223,26 @@ function StripePopupContent() { {'¥'} {amount.toFixed(2)}
-

订单号: {orderId}

+

{text.orderId}: {orderId}

{!stripeLoaded ? (
- 正在加载支付表单... + {text.loadingForm}
) : stripeSuccess ? (
{'✓'}

- 支付成功,窗口即将自动关闭... + {text.successClosing}

) : ( @@ -261,10 +268,10 @@ function StripePopupContent() { {stripeSubmitting ? ( - 处理中... + {text.processing} ) : ( - `支付 ¥${amount.toFixed(2)}` + text.payAmount )} @@ -274,15 +281,20 @@ function StripePopupContent() { ); } +function StripePopupFallback() { + const searchParams = useSearchParams(); + const locale = resolveLocale(searchParams.get('lang')); + + return ( +
+
{pickLocaleText(locale, '加载中...', 'Loading...')}
+
+ ); +} + export default function StripePopupPage() { return ( - -
加载中...
-
- } - > + }> ); diff --git a/src/components/MobileOrderList.tsx b/src/components/MobileOrderList.tsx index a6867bc..f74bbf7 100644 --- a/src/components/MobileOrderList.tsx +++ b/src/components/MobileOrderList.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import OrderFilterBar from '@/components/OrderFilterBar'; +import type { Locale } from '@/lib/locale'; import { formatStatus, formatCreatedAt, @@ -19,6 +20,7 @@ interface MobileOrderListProps { loadingMore: boolean; onRefresh: () => void; onLoadMore: () => void; + locale?: Locale; } export default function MobileOrderList({ @@ -29,6 +31,7 @@ export default function MobileOrderList({ loadingMore, onRefresh, onLoadMore, + locale = 'zh', }: MobileOrderListProps) { const [activeFilter, setActiveFilter] = useState('ALL'); const sentinelRef = useRef(null); @@ -59,7 +62,7 @@ export default function MobileOrderList({

- 我的订单 + {locale === 'en' ? 'My Orders' : '我的订单'}

- + {!hasToken ? (
- 当前链接未携带登录 token,无法查询"我的订单"。 + {locale === 'en' + ? 'The current link does not include a login token, so "My Orders" is unavailable.' + : '当前链接未携带登录 token,无法查询"我的订单"。'}
) : filteredOrders.length === 0 ? (
- 暂无符合条件的订单记录 + {locale === 'en' ? 'No matching orders found' : '暂无符合条件的订单记录'}
) : (
@@ -110,26 +115,27 @@ export default function MobileOrderList({ - {formatStatus(order.status)} + {formatStatus(order.status, locale)}
- {getPaymentDisplayInfo(order.paymentType).channel} + {getPaymentDisplayInfo(order.paymentType, locale).channel}
- {formatCreatedAt(order.createdAt)} + {formatCreatedAt(order.createdAt, locale)}
))} - {/* 无限滚动哨兵 */} {hasMore && (
{loadingMore ? ( - 加载中... + + {locale === 'en' ? 'Loading...' : '加载中...'} + ) : ( - 上滑加载更多 + {locale === 'en' ? 'Scroll up to load more' : '上滑加载更多'} )}
@@ -137,7 +143,7 @@ export default function MobileOrderList({ {!hasMore && orders.length > 0 && (
- 已显示全部订单 + {locale === 'en' ? 'All orders loaded' : '已显示全部订单'}
)} diff --git a/src/components/OrderFilterBar.tsx b/src/components/OrderFilterBar.tsx index 05b0e6d..e612e10 100644 --- a/src/components/OrderFilterBar.tsx +++ b/src/components/OrderFilterBar.tsx @@ -1,15 +1,17 @@ -import { FILTER_OPTIONS, type OrderStatusFilter } from '@/lib/pay-utils'; +import type { Locale } from '@/lib/locale'; +import { getFilterOptions, type OrderStatusFilter } from '@/lib/pay-utils'; interface OrderFilterBarProps { isDark: boolean; + locale: Locale; activeFilter: OrderStatusFilter; onChange: (filter: OrderStatusFilter) => void; } -export default function OrderFilterBar({ isDark, activeFilter, onChange }: OrderFilterBarProps) { +export default function OrderFilterBar({ isDark, locale, activeFilter, onChange }: OrderFilterBarProps) { return (
- {FILTER_OPTIONS.map((item) => ( + {getFilterOptions(locale).map((item) => (
); diff --git a/src/components/OrderSummaryCards.tsx b/src/components/OrderSummaryCards.tsx index e784b25..22d758f 100644 --- a/src/components/OrderSummaryCards.tsx +++ b/src/components/OrderSummaryCards.tsx @@ -1,3 +1,5 @@ +import type { Locale } from '@/lib/locale'; + interface Summary { total: number; pending: number; @@ -7,32 +9,47 @@ interface Summary { interface OrderSummaryCardsProps { isDark: boolean; + locale: Locale; summary: Summary; } -export default function OrderSummaryCards({ isDark, summary }: OrderSummaryCardsProps) { +export default function OrderSummaryCards({ isDark, locale, summary }: OrderSummaryCardsProps) { const cardClass = [ 'rounded-xl border p-3', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50', ].join(' '); const labelClass = ['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' '); + const labels = + locale === 'en' + ? { + total: 'Total Orders', + pending: 'Pending', + completed: 'Completed', + failed: 'Closed/Failed', + } + : { + total: '总订单', + pending: '待支付', + completed: '已完成', + failed: '异常/关闭', + }; return (
-
总订单
+
{labels.total}
{summary.total}
-
待支付
+
{labels.pending}
{summary.pending}
-
已完成
+
{labels.completed}
{summary.completed}
-
异常/关闭
+
{labels.failed}
{summary.failed}
diff --git a/src/components/OrderTable.tsx b/src/components/OrderTable.tsx index 327616a..dc642e6 100644 --- a/src/components/OrderTable.tsx +++ b/src/components/OrderTable.tsx @@ -1,13 +1,34 @@ +import type { Locale } from '@/lib/locale'; import { formatStatus, formatCreatedAt, getStatusBadgeClass, getPaymentDisplayInfo, type MyOrder } from '@/lib/pay-utils'; interface OrderTableProps { isDark: boolean; + locale: Locale; loading: boolean; error: string; orders: MyOrder[]; } -export default function OrderTable({ isDark, loading, error, orders }: OrderTableProps) { +export default function OrderTable({ isDark, locale, loading, error, orders }: OrderTableProps) { + const text = + locale === 'en' + ? { + empty: 'No matching orders found', + orderId: 'Order ID', + amount: 'Amount', + payment: 'Payment Method', + status: 'Status', + createdAt: 'Created At', + } + : { + empty: '暂无符合条件的订单记录', + orderId: '订单号', + amount: '金额', + payment: '支付方式', + status: '状态', + createdAt: '创建时间', + }; + return (
- 暂无符合条件的订单记录 + {text.empty}
) : ( <> @@ -50,11 +71,11 @@ export default function OrderTable({ isDark, loading, error, orders }: OrderTabl isDark ? 'text-slate-300' : 'text-slate-600', ].join(' ')} > - 订单号 - 金额 - 支付方式 - 状态 - 创建时间 + {text.orderId} + {text.amount} + {text.payment} + {text.status} + {text.createdAt}
{orders.map((order) => ( @@ -67,19 +88,17 @@ export default function OrderTable({ isDark, loading, error, orders }: OrderTabl >
#{order.id.slice(0, 12)}
¥{order.amount.toFixed(2)}
-
- {getPaymentDisplayInfo(order.paymentType).channel} -
+
{getPaymentDisplayInfo(order.paymentType, locale).channel}
- {formatStatus(order.status)} + {formatStatus(order.status, locale)}
-
{formatCreatedAt(order.createdAt)}
+
{formatCreatedAt(order.createdAt, locale)}
))} diff --git a/src/components/PaginationBar.tsx b/src/components/PaginationBar.tsx index a0866fb..4b2ae66 100644 --- a/src/components/PaginationBar.tsx +++ b/src/components/PaginationBar.tsx @@ -1,9 +1,12 @@ +import type { Locale } from '@/lib/locale'; + interface PaginationBarProps { page: number; totalPages: number; total: number; pageSize: number; pageSizeOptions?: number[]; + locale?: Locale; isDark?: boolean; loading?: boolean; onPageChange: (newPage: number) => void; @@ -16,6 +19,7 @@ export default function PaginationBar({ total, pageSize, pageSizeOptions = [20, 50, 100], + locale, isDark = false, loading = false, onPageChange, @@ -30,17 +34,29 @@ export default function PaginationBar({ : 'border-slate-300 text-slate-600 hover:bg-slate-100', ].join(' '); + const text = + locale === 'en' + ? { + total: `Total ${total}${totalPages > 1 ? `, Page ${page} / ${totalPages}` : ''}`, + perPage: 'Per page', + previous: 'Previous', + next: 'Next', + } + : { + total: `共 ${total} 条${totalPages > 1 ? `,第 ${page} / ${totalPages} 页` : ''}`, + perPage: '每页', + previous: '上一页', + next: '下一页', + }; + return (
- {/* 左侧:统计 + 每页大小 */}
- - 共 {total} 条{totalPages > 1 && `,第 ${page} / ${totalPages} 页`} - + {text.total} {onPageSizeChange && ( <> - 每页 + {text.perPage} {pageSizeOptions.map((s) => (

(''); const [paymentType, setPaymentType] = useState(enabledPaymentTypes[0] || 'alipay'); const [customAmount, setCustomAmount] = useState(''); - // Reset paymentType when enabledPaymentTypes changes (e.g. after config loads) const effectivePaymentType = enabledPaymentTypes.includes(paymentType) ? paymentType : enabledPaymentTypes[0] || 'stripe'; @@ -107,7 +109,7 @@ export default function PaymentForm({ if (iconType === 'alipay') { return ( - 支 + {locale === 'en' ? 'A' : '支'} ); } @@ -144,7 +146,6 @@ export default function PaymentForm({ return (
- {/* User Info */}
- 充值账户 + {locale === 'en' ? 'Recharge Account' : '充值账户'}
- {userName || `用户 #${userId}`} + {userName || (locale === 'en' ? `User #${userId}` : `用户 #${userId}`)}
{userBalance !== undefined && (
- 当前余额: {userBalance.toFixed(2)} + {locale === 'en' ? 'Current Balance:' : '当前余额:'}{' '} + {userBalance.toFixed(2)}
)}
- {/* Quick Amount Selection */}
{QUICK_AMOUNTS.filter((val) => val >= minAmount && val <= effectiveMax).map((val) => ( @@ -189,10 +190,9 @@ export default function PaymentForm({
- {/* Custom Amount */}
{ const num = parseFloat(customAmount); - let msg = '金额需在范围内,且最多支持 2 位小数(精确到分)'; + let msg = locale === 'en' + ? 'Amount must be within range and support up to 2 decimal places' + : '金额需在范围内,且最多支持 2 位小数(精确到分)'; if (!isNaN(num)) { - if (num < minAmount) msg = `单笔最低充值 ¥${minAmount}`; - else if (num > effectiveMax) msg = `单笔最高充值 ¥${effectiveMax}`; + if (num < minAmount) msg = locale === 'en' ? `Minimum per transaction: ¥${minAmount}` : `单笔最低充值 ¥${minAmount}`; + else if (num > effectiveMax) msg = locale === 'en' ? `Maximum per transaction: ¥${effectiveMax}` : `单笔最高充值 ¥${effectiveMax}`; } return
{msg}
; })()} - {/* Payment Type — only show when multiple types available */} {enabledPaymentTypes.length > 1 && (
{enabledPaymentTypes.map((type) => { const meta = PAYMENT_TYPE_META[type]; + const displayInfo = getPaymentDisplayInfo(type, locale); const isSelected = effectivePaymentType === type; const limitInfo = methodLimits?.[type]; const isUnavailable = limitInfo !== undefined && !limitInfo.available; @@ -250,7 +252,7 @@ export default function PaymentForm({ type="button" disabled={isUnavailable} onClick={() => !isUnavailable && setPaymentType(type)} - title={isUnavailable ? '今日充值额度已满,请使用其他支付方式' : undefined} + title={isUnavailable ? (locale === 'en' ? 'Daily limit reached, please use another payment method' : '今日充值额度已满,请使用其他支付方式') : undefined} className={[ 'relative flex h-[58px] flex-col items-center justify-center rounded-lg border px-3 transition-all sm:flex-1', isUnavailable @@ -267,14 +269,14 @@ export default function PaymentForm({ {renderPaymentIcon(type)} - {meta?.label || type} + {displayInfo.channel || type} {isUnavailable ? ( - 今日额度已满 - ) : meta?.sublabel ? ( + {locale === 'en' ? 'Daily limit reached' : '今日额度已满'} + ) : displayInfo.sublabel ? ( - {meta.sublabel} + {displayInfo.sublabel} ) : null} @@ -284,20 +286,20 @@ export default function PaymentForm({ })}
- {/* 当前选中渠道额度不足时的提示 */} {(() => { const limitInfo = methodLimits?.[effectivePaymentType]; if (!limitInfo || limitInfo.available) return null; return (

- 所选支付方式今日额度已满,请切换到其他支付方式 + {locale === 'en' + ? 'The selected payment method has reached today\'s limit. Please switch to another method.' + : '所选支付方式今日额度已满,请切换到其他支付方式'}

); })()}
)} - {/* Fee Detail */} {feeRate > 0 && selectedAmount > 0 && (
- 充值金额 + {locale === 'en' ? 'Recharge Amount' : '充值金额'} ¥{selectedAmount.toFixed(2)}
-
- 手续费({feeRate}%) +
+ {locale === 'en' ? `Fee (${feeRate}%)` : `手续费(${feeRate}%)`} ¥{feeAmount.toFixed(2)}
- 实付金额 + {locale === 'en' ? 'Amount to Pay' : '实付金额'} ¥{payAmount.toFixed(2)}
)} - {/* Pending order limit warning */} {pendingBlocked && (
- 您有 {pendingCount} 个待支付订单,请先完成或取消后再充值 + {locale === 'en' + ? `You have ${pendingCount} pending orders. Please complete or cancel them before recharging.` + : `您有 ${pendingCount} 个待支付订单,请先完成或取消后再充值`}
)} - {/* Submit */} ); diff --git a/src/components/PaymentQRCode.tsx b/src/components/PaymentQRCode.tsx index 46a8fe8..b56fba0 100644 --- a/src/components/PaymentQRCode.tsx +++ b/src/components/PaymentQRCode.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, useCallback, useRef } from 'react'; import QRCode from 'qrcode'; +import type { Locale } from '@/lib/locale'; import { isStripeType, getPaymentMeta, @@ -26,16 +27,9 @@ interface PaymentQRCodeProps { dark?: boolean; isEmbedded?: boolean; isMobile?: boolean; + locale?: Locale; } -const TEXT_EXPIRED = '订单已超时'; -const TEXT_REMAINING = '剩余支付时间'; -const TEXT_GO_PAY = '点击前往支付'; -const TEXT_SCAN_PAY = '请使用支付应用扫码支付'; -const TEXT_BACK = '返回'; -const TEXT_CANCEL_ORDER = '取消订单'; -const TEXT_H5_HINT = '支付完成后请返回此页面,系统将自动确认'; - export default function PaymentQRCode({ orderId, token, @@ -52,6 +46,7 @@ export default function PaymentQRCode({ dark = false, isEmbedded = false, isMobile = false, + locale = 'zh', }: PaymentQRCodeProps) { const displayAmount = payAmountProp ?? amount; const hasFeeDiff = payAmountProp !== undefined && payAmountProp !== amount; @@ -63,7 +58,6 @@ export default function PaymentQRCode({ const [cancelBlocked, setCancelBlocked] = useState(false); const [redirected, setRedirected] = useState(false); - // Stripe Payment Element state const [stripeLoaded, setStripeLoaded] = useState(false); const [stripeSubmitting, setStripeSubmitting] = useState(false); const [stripeError, setStripeError] = useState(''); @@ -72,12 +66,41 @@ export default function PaymentQRCode({ stripe: import('@stripe/stripe-js').Stripe; elements: import('@stripe/stripe-js').StripeElements; } | null>(null); - // Track selected payment method in Payment Element (for embedded popup decision) const [stripePaymentMethod, setStripePaymentMethod] = useState('card'); const [popupBlocked, setPopupBlocked] = useState(false); const paymentMethodListenerAdded = useRef(false); - // PC 端有二维码时优先展示二维码;仅移动端或无二维码时才跳转 + const t = { + expired: locale === 'en' ? 'Order Expired' : '订单已超时', + remaining: locale === 'en' ? 'Time Remaining' : '剩余支付时间', + scanPay: locale === 'en' ? 'Please scan with your payment app' : '请使用支付应用扫码支付', + back: locale === 'en' ? 'Back' : '返回', + cancelOrder: locale === 'en' ? 'Cancel Order' : '取消订单', + h5Hint: locale === 'en' ? 'After payment, please return to this page. The system will confirm automatically.' : '支付完成后请返回此页面,系统将自动确认', + paid: locale === 'en' ? 'Order Paid' : '订单已支付', + paidCancelBlocked: + locale === 'en' ? 'This order has already been paid and cannot be cancelled. The recharge will be credited automatically.' : '该订单已支付完成,无法取消。充值将自动到账。', + backToRecharge: locale === 'en' ? 'Back to Recharge' : '返回充值', + credited: locale === 'en' ? 'Credited ¥' : '到账 ¥', + stripeLoadFailed: locale === 'en' ? 'Failed to load payment component. Please refresh and try again.' : '支付组件加载失败,请刷新页面重试', + initFailed: locale === 'en' ? 'Payment initialization failed. Please go back and try again.' : '支付初始化失败,请返回重试', + loadingForm: locale === 'en' ? 'Loading payment form...' : '正在加载支付表单...', + payFailed: locale === 'en' ? 'Payment failed. Please try again.' : '支付失败,请重试', + successProcessing: locale === 'en' ? 'Payment successful, processing your order...' : '支付成功,正在处理订单...', + processing: locale === 'en' ? 'Processing...' : '处理中...', + payNow: locale === 'en' ? 'Pay' : '支付', + popupBlocked: + locale === 'en' ? 'Popup was blocked by your browser. Please allow popups for this site and try again.' : '弹出窗口被浏览器拦截,请允许本站弹出窗口后重试', + redirectingPrefix: locale === 'en' ? 'Redirecting to ' : '正在跳转到', + redirectingSuffix: locale === 'en' ? '...' : '...', + notRedirectedPrefix: locale === 'en' ? 'Not redirected? Open ' : '未跳转?点击前往', + goPaySuffix: locale === 'en' ? '' : '', + gotoPrefix: locale === 'en' ? 'Open ' : '前往', + gotoSuffix: locale === 'en' ? ' to pay' : '支付', + openScanPrefix: locale === 'en' ? 'Open ' : '请打开', + openScanSuffix: locale === 'en' ? ' and scan to complete payment' : '扫一扫完成支付', + }; + const shouldAutoRedirect = !expired && !isStripeType(paymentType) && !!payUrl && (isMobile || !qrCode); useEffect(() => { @@ -128,7 +151,6 @@ export default function PaymentQRCode({ }; }, [qrPayload]); - // Initialize Stripe Payment Element const isStripe = isStripeType(paymentType); useEffect(() => { @@ -139,7 +161,7 @@ export default function PaymentQRCode({ loadStripe(stripePublishableKey).then((stripe) => { if (cancelled) return; if (!stripe) { - setStripeError('支付组件加载失败,请刷新页面重试'); + setStripeError(t.stripeLoadFailed); setStripeLoaded(true); return; } @@ -160,9 +182,8 @@ export default function PaymentQRCode({ return () => { cancelled = true; }; - }, [isStripe, clientSecret, stripePublishableKey, dark]); + }, [isStripe, clientSecret, stripePublishableKey, dark, t.stripeLoadFailed]); - // Mount Payment Element when container is available const stripeContainerRef = useCallback( (node: HTMLDivElement | null) => { if (!node || !stripeLib) return; @@ -188,7 +209,6 @@ export default function PaymentQRCode({ const handleStripeSubmit = async () => { if (!stripeLib || stripeSubmitting) return; - // In embedded mode, Alipay redirects to a page with X-Frame-Options that breaks iframe if (isEmbedded && stripePaymentMethod === 'alipay') { handleOpenPopup(); return; @@ -203,6 +223,9 @@ export default function PaymentQRCode({ returnUrl.search = ''; returnUrl.searchParams.set('order_id', orderId); returnUrl.searchParams.set('status', 'success'); + if (locale === 'en') { + returnUrl.searchParams.set('lang', 'en'); + } const { error } = await stripe.confirmPayment({ elements, @@ -213,20 +236,17 @@ export default function PaymentQRCode({ }); if (error) { - setStripeError(error.message || '支付失败,请重试'); + setStripeError(error.message || t.payFailed); setStripeSubmitting(false); } else { - // Payment succeeded (or no redirect needed) setStripeSuccess(true); setStripeSubmitting(false); - // Polling will pick up the status change } }; const handleOpenPopup = () => { if (!clientSecret || !stripePublishableKey) return; setPopupBlocked(false); - // Only pass display params in URL — sensitive data sent via postMessage const popupUrl = new URL(window.location.href); popupUrl.pathname = '/pay/stripe-popup'; popupUrl.search = ''; @@ -234,13 +254,15 @@ export default function PaymentQRCode({ popupUrl.searchParams.set('amount', String(amount)); popupUrl.searchParams.set('theme', dark ? 'dark' : 'light'); popupUrl.searchParams.set('method', stripePaymentMethod); + if (locale === 'en') { + popupUrl.searchParams.set('lang', 'en'); + } const popup = window.open(popupUrl.toString(), 'stripe_payment', 'width=500,height=700,scrollbars=yes'); if (!popup || popup.closed) { setPopupBlocked(true); return; } - // Send sensitive data via postMessage after popup loads const onReady = (event: MessageEvent) => { if (event.source !== popup || event.data?.type !== 'STRIPE_POPUP_READY') return; window.removeEventListener('message', onReady); @@ -263,7 +285,7 @@ export default function PaymentQRCode({ const diff = expiry - now; if (diff <= 0) { - setTimeLeft(TEXT_EXPIRED); + setTimeLeft(t.expired); setTimeLeftSeconds(0); setExpired(true); return; @@ -279,7 +301,7 @@ export default function PaymentQRCode({ updateTimer(); const timer = setInterval(updateTimer, 1000); return () => clearInterval(timer); - }, [expiresAt]); + }, [expiresAt, t.expired]); const pollStatus = useCallback(async () => { try { @@ -291,7 +313,6 @@ export default function PaymentQRCode({ } } } catch { - // ignore polling errors } }, [orderId, onStatusChange]); @@ -305,7 +326,6 @@ export default function PaymentQRCode({ const handleCancel = async () => { if (!token) return; try { - // 先检查当前订单状态 const res = await fetch(`/api/orders/${orderId}`); if (!res.ok) return; const data = await res.json(); @@ -331,28 +351,27 @@ export default function PaymentQRCode({ await pollStatus(); } } catch { - // ignore } }; const meta = getPaymentMeta(paymentType || 'alipay'); const iconSrc = getPaymentIconSrc(paymentType || 'alipay'); - const channelLabel = getPaymentChannelLabel(paymentType || 'alipay'); + const channelLabel = getPaymentChannelLabel(paymentType || 'alipay', locale); const iconBgClass = meta.iconBg; if (cancelBlocked) { return (
{'✓'}
-

{'订单已支付'}

+

{t.paid}

- {'该订单已支付完成,无法取消。充值将自动到账。'} + {t.paidCancelBlocked}

); @@ -367,11 +386,12 @@ export default function PaymentQRCode({
{hasFeeDiff && (
- 到账 ¥{amount.toFixed(2)} + {t.credited} + {amount.toFixed(2)}
)}
- {expired ? TEXT_EXPIRED : `${TEXT_REMAINING}: ${timeLeft}`} + {expired ? t.expired : `${t.remaining}: ${timeLeft}`}
@@ -387,14 +407,14 @@ export default function PaymentQRCode({ ].join(' ')} >

- 支付初始化失败,请返回重试 + {t.initFailed}

) : !stripeLoaded ? (
- 正在加载支付表单... + {t.loadingForm}
) : stripeError && !stripeLib ? ( @@ -420,7 +440,7 @@ export default function PaymentQRCode({
{'✓'}

- 支付成功,正在处理订单... + {t.successProcessing}

) : ( @@ -431,17 +451,17 @@ export default function PaymentQRCode({ className={[ 'w-full rounded-lg py-3 font-medium text-white shadow-md transition-colors', stripeSubmitting - ? 'bg-gray-400 cursor-not-allowed' + ? 'cursor-not-allowed bg-gray-400' : meta.buttonClass, ].join(' ')} > {stripeSubmitting ? ( - 处理中... + {t.processing} ) : ( - `支付 ¥${amount.toFixed(2)}` + `${t.payNow} ¥${amount.toFixed(2)}` )} )} @@ -454,7 +474,7 @@ export default function PaymentQRCode({ : 'border-amber-200 bg-amber-50 text-amber-700', ].join(' ')} > - 弹出窗口被浏览器拦截,请允许本站弹出窗口后重试 + {t.popupBlocked}
)} @@ -465,7 +485,7 @@ export default function PaymentQRCode({
- 正在跳转到{channelLabel}... + {`${t.redirectingPrefix}${channelLabel}${t.redirectingSuffix}`}
{iconSrc && {channelLabel}} - {redirected ? `未跳转?点击前往${channelLabel}` : `前往${channelLabel}支付`} + {redirected ? `${t.notRedirectedPrefix}${channelLabel}` : `${t.gotoPrefix}${channelLabel}${t.gotoSuffix}`}

- {TEXT_H5_HINT} + {t.h5Hint}

) : ( @@ -512,13 +532,13 @@ export default function PaymentQRCode({ dark ? 'border-slate-700' : 'border-gray-300', ].join(' ')} > -

{TEXT_SCAN_PAY}

+

{t.scanPay}

)}

- {`请打开${channelLabel}扫一扫完成支付`} + {`${t.openScanPrefix}${channelLabel}${t.openScanSuffix}`}

)} @@ -535,7 +555,7 @@ export default function PaymentQRCode({ : 'border-gray-300 text-gray-600 hover:bg-gray-50', ].join(' ')} > - {TEXT_BACK} + {t.back} {!expired && token && ( )} diff --git a/src/components/admin/DailyChart.tsx b/src/components/admin/DailyChart.tsx index 692777b..4d7a7e7 100644 --- a/src/components/admin/DailyChart.tsx +++ b/src/components/admin/DailyChart.tsx @@ -1,6 +1,7 @@ 'use client'; import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts'; +import type { Locale } from '@/lib/locale'; interface DailyData { date: string; @@ -11,6 +12,7 @@ interface DailyData { interface DailyChartProps { data: DailyData[]; dark?: boolean; + locale?: Locale; } function formatDate(dateStr: string) { @@ -34,11 +36,17 @@ function CustomTooltip({ payload, label, dark, + currency, + amountLabel, + countLabel, }: { active?: boolean; payload?: TooltipPayload[]; label?: string; dark?: boolean; + currency: string; + amountLabel: string; + countLabel: string; }) { if (!active || !payload?.length) return null; return ( @@ -51,16 +59,20 @@ function CustomTooltip({

{label}

{payload.map((p) => (

- {p.dataKey === 'amount' ? '金额' : '笔数'}:{' '} - {p.dataKey === 'amount' ? `¥${p.value.toLocaleString()}` : p.value} + {p.dataKey === 'amount' ? amountLabel : countLabel}:{' '} + {p.dataKey === 'amount' ? `${currency}${p.value.toLocaleString()}` : p.value}

))} ); } -export default function DailyChart({ data, dark }: DailyChartProps) { - // Auto-calculate tick interval: show ~10-15 labels max +export default function DailyChart({ data, dark, locale = 'zh' }: DailyChartProps) { + const currency = locale === 'en' ? '$' : '¥'; + const chartTitle = locale === 'en' ? 'Daily Recharge Trend' : '每日充值趋势'; + const emptyText = locale === 'en' ? 'No data' : '暂无数据'; + const amountLabel = locale === 'en' ? 'Amount' : '金额'; + const countLabel = locale === 'en' ? 'Orders' : '笔数'; const tickInterval = data.length > 30 ? Math.ceil(data.length / 12) - 1 : 0; if (data.length === 0) { return ( @@ -71,9 +83,9 @@ export default function DailyChart({ data, dark }: DailyChartProps) { ].join(' ')} >

- 每日充值趋势 + {chartTitle}

-

暂无数据

+

{emptyText}

); } @@ -89,7 +101,7 @@ export default function DailyChart({ data, dark }: DailyChartProps) { ].join(' ')} >

- 每日充值趋势 + {chartTitle}

@@ -109,7 +121,7 @@ export default function DailyChart({ data, dark }: DailyChartProps) { tickLine={false} width={60} /> - } /> + } /> = { @@ -19,7 +22,13 @@ const RANK_STYLES: Record = { 3: { light: 'bg-orange-100 text-orange-700', dark: 'bg-orange-500/20 text-orange-300' }, }; -export default function Leaderboard({ data, dark }: LeaderboardProps) { +export default function Leaderboard({ data, dark, locale = 'zh' }: LeaderboardProps) { + const title = locale === 'en' ? 'Recharge Leaderboard (Top 10)' : '充值排行榜 (Top 10)'; + const emptyText = locale === 'en' ? 'No data' : '暂无数据'; + const userLabel = locale === 'en' ? 'User' : '用户'; + const amountLabel = locale === 'en' ? 'Total Amount' : '累计金额'; + const orderCountLabel = locale === 'en' ? 'Orders' : '订单数'; + const currency = locale === 'en' ? '$' : '¥'; const thCls = `px-4 py-3 text-left text-xs font-medium uppercase ${dark ? 'text-slate-400' : 'text-gray-500'}`; const tdCls = `whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-300' : 'text-slate-700'}`; const tdMuted = `whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-400' : 'text-gray-500'}`; @@ -33,9 +42,9 @@ export default function Leaderboard({ data, dark }: LeaderboardProps) { ].join(' ')} >

- 充值排行榜 (Top 10) + {title}

-

暂无数据

+

{emptyText}

); } @@ -48,16 +57,16 @@ export default function Leaderboard({ data, dark }: LeaderboardProps) { ].join(' ')} >

- 充值排行榜 (Top 10) + {title}

- - - + + + @@ -88,7 +97,7 @@ export default function Leaderboard({ data, dark }: LeaderboardProps) { diff --git a/src/components/admin/OrderDetail.tsx b/src/components/admin/OrderDetail.tsx index e76096b..e0c916c 100644 --- a/src/components/admin/OrderDetail.tsx +++ b/src/components/admin/OrderDetail.tsx @@ -1,7 +1,8 @@ 'use client'; import { useEffect } from 'react'; -import { getPaymentDisplayInfo } from '@/lib/pay-utils'; +import { getPaymentDisplayInfo, formatCreatedAt } from '@/lib/pay-utils'; +import type { Locale } from '@/lib/locale'; interface AuditLog { id: string; @@ -43,9 +44,83 @@ interface OrderDetailProps { }; onClose: () => void; dark?: boolean; + locale?: Locale; } -export default function OrderDetail({ order, onClose, dark }: OrderDetailProps) { +export default function OrderDetail({ order, onClose, dark, locale = 'zh' }: OrderDetailProps) { + const currency = locale === 'en' ? '$' : '¥'; + const text = locale === 'en' + ? { + title: 'Order Details', + auditLogs: 'Audit Logs', + operator: 'Operator', + emptyLogs: 'No logs', + close: 'Close', + yes: 'Yes', + no: 'No', + orderId: 'Order ID', + userId: 'User ID', + userName: 'Username', + email: 'Email', + amount: 'Amount', + status: 'Status', + paymentSuccess: 'Payment Success', + rechargeSuccess: 'Recharge Success', + rechargeStatus: 'Recharge Status', + paymentChannel: 'Payment Channel', + provider: 'Provider', + rechargeCode: 'Recharge Code', + paymentTradeNo: 'Payment Trade No.', + clientIp: 'Client IP', + sourceHost: 'Source Host', + sourcePage: 'Source Page', + createdAt: 'Created At', + expiresAt: 'Expires At', + paidAt: 'Paid At', + completedAt: 'Completed At', + failedAt: 'Failed At', + failedReason: 'Failure Reason', + refundAmount: 'Refund Amount', + refundReason: 'Refund Reason', + refundAt: 'Refunded At', + forceRefund: 'Force Refund', + } + : { + title: '订单详情', + auditLogs: '审计日志', + operator: '操作者', + emptyLogs: '暂无日志', + close: '关闭', + yes: '是', + no: '否', + orderId: '订单号', + userId: '用户ID', + userName: '用户名', + email: '邮箱', + amount: '金额', + status: '状态', + paymentSuccess: '支付成功', + rechargeSuccess: '充值成功', + rechargeStatus: '充值状态', + paymentChannel: '支付渠道', + provider: '提供商', + rechargeCode: '充值码', + paymentTradeNo: '支付单号', + clientIp: '客户端IP', + sourceHost: '来源域名', + sourcePage: '来源页面', + createdAt: '创建时间', + expiresAt: '过期时间', + paidAt: '支付时间', + completedAt: '完成时间', + failedAt: '失败时间', + failedReason: '失败原因', + refundAmount: '退款金额', + refundReason: '退款原因', + refundAt: '退款时间', + forceRefund: '强制退款', + }; + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); @@ -54,37 +129,39 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps) return () => document.removeEventListener('keydown', handleKeyDown); }, [onClose]); + const paymentInfo = getPaymentDisplayInfo(order.paymentType, locale); + const fields = [ - { label: '订单号', value: order.id }, - { label: '用户ID', value: order.userId }, - { label: '用户名', value: order.userName || '-' }, - { label: '邮箱', value: order.userEmail || '-' }, - { label: '金额', value: `¥${order.amount.toFixed(2)}` }, - { label: '状态', value: order.status }, - { label: '支付成功', value: order.paymentSuccess ? 'yes' : 'no' }, - { label: '充值成功', value: order.rechargeSuccess ? 'yes' : 'no' }, - { label: '充值状态', value: order.rechargeStatus || '-' }, - { label: '支付渠道', value: getPaymentDisplayInfo(order.paymentType).channel }, - { label: '提供商', value: getPaymentDisplayInfo(order.paymentType).provider || '-' }, - { label: '充值码', value: order.rechargeCode }, - { label: '支付单号', value: order.paymentTradeNo || '-' }, - { label: '客户端IP', value: order.clientIp || '-' }, - { label: '来源域名', value: order.srcHost || '-' }, - { label: '来源页面', value: order.srcUrl || '-' }, - { label: '创建时间', value: new Date(order.createdAt).toLocaleString('zh-CN') }, - { label: '过期时间', value: new Date(order.expiresAt).toLocaleString('zh-CN') }, - { label: '支付时间', value: order.paidAt ? new Date(order.paidAt).toLocaleString('zh-CN') : '-' }, - { label: '完成时间', value: order.completedAt ? new Date(order.completedAt).toLocaleString('zh-CN') : '-' }, - { label: '失败时间', value: order.failedAt ? new Date(order.failedAt).toLocaleString('zh-CN') : '-' }, - { label: '失败原因', value: order.failedReason || '-' }, + { label: text.orderId, value: order.id }, + { label: text.userId, value: order.userId }, + { label: text.userName, value: order.userName || '-' }, + { label: text.email, value: order.userEmail || '-' }, + { label: text.amount, value: `${currency}${order.amount.toFixed(2)}` }, + { label: text.status, value: order.status }, + { label: text.paymentSuccess, value: order.paymentSuccess ? text.yes : text.no }, + { label: text.rechargeSuccess, value: order.rechargeSuccess ? text.yes : text.no }, + { label: text.rechargeStatus, value: order.rechargeStatus || '-' }, + { label: text.paymentChannel, value: paymentInfo.channel }, + { label: text.provider, value: paymentInfo.provider || '-' }, + { label: text.rechargeCode, value: order.rechargeCode }, + { label: text.paymentTradeNo, value: order.paymentTradeNo || '-' }, + { label: text.clientIp, value: order.clientIp || '-' }, + { label: text.sourceHost, value: order.srcHost || '-' }, + { label: text.sourcePage, value: order.srcUrl || '-' }, + { label: text.createdAt, value: formatCreatedAt(order.createdAt, locale) }, + { label: text.expiresAt, value: formatCreatedAt(order.expiresAt, locale) }, + { label: text.paidAt, value: order.paidAt ? formatCreatedAt(order.paidAt, locale) : '-' }, + { label: text.completedAt, value: order.completedAt ? formatCreatedAt(order.completedAt, locale) : '-' }, + { label: text.failedAt, value: order.failedAt ? formatCreatedAt(order.failedAt, locale) : '-' }, + { label: text.failedReason, value: order.failedReason || '-' }, ]; if (order.refundAmount) { fields.push( - { label: '退款金额', value: `¥${order.refundAmount.toFixed(2)}` }, - { label: '退款原因', value: order.refundReason || '-' }, - { label: '退款时间', value: order.refundAt ? new Date(order.refundAt).toLocaleString('zh-CN') : '-' }, - { label: '强制退款', value: order.forceRefund ? '是' : '否' }, + { label: text.refundAmount, value: `${currency}${order.refundAmount.toFixed(2)}` }, + { label: text.refundReason, value: order.refundReason || '-' }, + { label: text.refundAt, value: order.refundAt ? formatCreatedAt(order.refundAt, locale) : '-' }, + { label: text.forceRefund, value: order.forceRefund ? text.yes : text.no }, ); } @@ -95,7 +172,7 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps) onClick={(e) => e.stopPropagation()} >
-

订单详情

+

{text.title}

@@ -150,7 +227,7 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps) onClick={onClose} className={`mt-6 w-full rounded-lg border py-2 text-sm ${dark ? 'border-slate-600 text-slate-300 hover:bg-slate-700' : 'border-gray-300 text-gray-600 hover:bg-gray-50'}`} > - 关闭 + {text.close} diff --git a/src/components/admin/OrderTable.tsx b/src/components/admin/OrderTable.tsx index ea2f7f7..465463c 100644 --- a/src/components/admin/OrderTable.tsx +++ b/src/components/admin/OrderTable.tsx @@ -1,6 +1,7 @@ 'use client'; -import { getPaymentDisplayInfo } from '@/lib/pay-utils'; +import { getPaymentDisplayInfo, formatStatus, formatCreatedAt } from '@/lib/pay-utils'; +import type { Locale } from '@/lib/locale'; interface Order { id: string; @@ -26,22 +27,43 @@ interface OrderTableProps { onCancel: (orderId: string) => void; onViewDetail: (orderId: string) => void; dark?: boolean; + locale?: Locale; } -const STATUS_LABELS: Record = { - PENDING: { label: '待支付', light: 'bg-yellow-100 text-yellow-800', dark: 'bg-yellow-500/20 text-yellow-300' }, - PAID: { label: '已支付', light: 'bg-blue-100 text-blue-800', dark: 'bg-blue-500/20 text-blue-300' }, - RECHARGING: { label: '充值中', light: 'bg-blue-100 text-blue-800', dark: 'bg-blue-500/20 text-blue-300' }, - COMPLETED: { label: '已完成', light: 'bg-green-100 text-green-800', dark: 'bg-green-500/20 text-green-300' }, - EXPIRED: { label: '已超时', light: 'bg-gray-100 text-gray-800', dark: 'bg-slate-600/30 text-slate-400' }, - CANCELLED: { label: '已取消', light: 'bg-gray-100 text-gray-800', dark: 'bg-slate-600/30 text-slate-400' }, - FAILED: { label: '充值失败', light: 'bg-red-100 text-red-800', dark: 'bg-red-500/20 text-red-300' }, - REFUNDING: { label: '退款中', light: 'bg-orange-100 text-orange-800', dark: 'bg-orange-500/20 text-orange-300' }, - REFUNDED: { label: '已退款', light: 'bg-purple-100 text-purple-800', dark: 'bg-purple-500/20 text-purple-300' }, - REFUND_FAILED: { label: '退款失败', light: 'bg-red-100 text-red-800', dark: 'bg-red-500/20 text-red-300' }, -}; +export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, dark, locale = 'zh' }: OrderTableProps) { + const currency = locale === 'en' ? '$' : '¥'; + const text = locale === 'en' + ? { + orderId: 'Order ID', + userName: 'Username', + email: 'Email', + notes: 'Notes', + amount: 'Amount', + status: 'Status', + paymentMethod: 'Payment', + source: 'Source', + createdAt: 'Created At', + actions: 'Actions', + retry: 'Retry', + cancel: 'Cancel', + empty: 'No orders', + } + : { + orderId: '订单号', + userName: '用户名', + email: '邮箱', + notes: '备注', + amount: '金额', + status: '状态', + paymentMethod: '支付方式', + source: '来源', + createdAt: '创建时间', + actions: '操作', + retry: '重试', + cancel: '取消', + empty: '暂无订单', + }; -export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, dark }: OrderTableProps) { const thCls = `px-4 py-3 text-left text-xs font-medium uppercase ${dark ? 'text-slate-400' : 'text-gray-500'}`; const tdMuted = `whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-400' : 'text-gray-500'}`; @@ -50,24 +72,50 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
#用户累计金额订单数{userLabel}{amountLabel}{orderCountLabel}
- ¥{entry.totalAmount.toLocaleString()} + {currency}{entry.totalAmount.toLocaleString()} {entry.orderCount}
- - - - - - - - - - + + + + + + + + + + {orders.map((order) => { - const statusInfo = STATUS_LABELS[order.status] || { - label: order.status, - light: 'bg-gray-100 text-gray-800', - dark: 'bg-slate-600/30 text-slate-400', + const statusInfo = { + label: formatStatus(order.status, locale), + light: + order.status === 'FAILED' || order.status === 'REFUND_FAILED' + ? 'bg-red-100 text-red-800' + : order.status === 'REFUNDED' + ? 'bg-purple-100 text-purple-800' + : order.status === 'REFUNDING' + ? 'bg-orange-100 text-orange-800' + : order.status === 'COMPLETED' + ? 'bg-green-100 text-green-800' + : order.status === 'PAID' || order.status === 'RECHARGING' + ? 'bg-blue-100 text-blue-800' + : order.status === 'PENDING' + ? 'bg-yellow-100 text-yellow-800' + : 'bg-gray-100 text-gray-800', + dark: + order.status === 'FAILED' || order.status === 'REFUND_FAILED' + ? 'bg-red-500/20 text-red-300' + : order.status === 'REFUNDED' + ? 'bg-purple-500/20 text-purple-300' + : order.status === 'REFUNDING' + ? 'bg-orange-500/20 text-orange-300' + : order.status === 'COMPLETED' + ? 'bg-green-500/20 text-green-300' + : order.status === 'PAID' || order.status === 'RECHARGING' + ? 'bg-blue-500/20 text-blue-300' + : order.status === 'PENDING' + ? 'bg-yellow-500/20 text-yellow-300' + : 'bg-slate-600/30 text-slate-400', }; return ( @@ -85,7 +133,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da - +
订单号用户名邮箱备注金额状态支付方式来源创建时间操作{text.orderId}{text.userName}{text.email}{text.notes}{text.amount}{text.status}{text.paymentMethod}{text.source}{text.createdAt}{text.actions}
{order.userEmail || '-'} {order.userNotes || '-'} - ¥{order.amount.toFixed(2)} + {currency}{order.amount.toFixed(2)} {(() => { - const { channel, provider } = getPaymentDisplayInfo(order.paymentType); + const { channel, provider } = getPaymentDisplayInfo(order.paymentType, locale); return ( <> {channel} @@ -110,7 +158,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da })()} {order.srcHost || '-'}{new Date(order.createdAt).toLocaleString('zh-CN')}{formatCreatedAt(order.createdAt, locale)}
{order.rechargeRetryable && ( @@ -118,7 +166,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da onClick={() => onRetry(order.id)} className={`rounded px-2 py-1 text-xs ${dark ? 'bg-blue-500/20 text-blue-300 hover:bg-blue-500/30' : 'bg-blue-100 text-blue-700 hover:bg-blue-200'}`} > - 重试 + {text.retry} )} {order.status === 'PENDING' && ( @@ -126,7 +174,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da onClick={() => onCancel(order.id)} className={`rounded px-2 py-1 text-xs ${dark ? 'bg-red-500/20 text-red-300 hover:bg-red-500/30' : 'bg-red-100 text-red-700 hover:bg-red-200'}`} > - 取消 + {text.cancel} )}
@@ -137,7 +185,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
{orders.length === 0 && ( -
暂无订单
+
{text.empty}
)}
); diff --git a/src/components/admin/PaymentMethodChart.tsx b/src/components/admin/PaymentMethodChart.tsx index 3d16fab..5352f35 100644 --- a/src/components/admin/PaymentMethodChart.tsx +++ b/src/components/admin/PaymentMethodChart.tsx @@ -1,6 +1,7 @@ 'use client'; import { getPaymentTypeLabel, getPaymentMeta } from '@/lib/pay-utils'; +import type { Locale } from '@/lib/locale'; interface PaymentMethod { paymentType: string; @@ -12,9 +13,14 @@ interface PaymentMethod { interface PaymentMethodChartProps { data: PaymentMethod[]; dark?: boolean; + locale?: Locale; } -export default function PaymentMethodChart({ data, dark }: PaymentMethodChartProps) { +export default function PaymentMethodChart({ data, dark, locale = 'zh' }: PaymentMethodChartProps) { + const title = locale === 'en' ? 'Payment Method Distribution' : '支付方式分布'; + const emptyText = locale === 'en' ? 'No data' : '暂无数据'; + const currency = locale === 'en' ? '$' : '¥'; + if (data.length === 0) { return (

- 支付方式分布 + {title}

-

暂无数据

+

{emptyText}

); } @@ -39,18 +45,18 @@ export default function PaymentMethodChart({ data, dark }: PaymentMethodChartPro ].join(' ')} >

- 支付方式分布 + {title}

{data.map((method) => { const meta = getPaymentMeta(method.paymentType); - const label = getPaymentTypeLabel(method.paymentType); + const label = getPaymentTypeLabel(method.paymentType, locale); return (
{label} - ¥{method.amount.toLocaleString()} · {method.percentage}% + {currency}{method.amount.toLocaleString()} · {method.percentage}%
{ const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onCancel(); @@ -51,17 +80,17 @@ export default function RefundDialog({ ].join(' ')} onClick={(e) => e.stopPropagation()} > -

确认退款

+

{text.title}

-
订单号
+
{text.orderId}
{orderId}
-
退款金额
-
¥{amount.toFixed(2)}
+
{text.amount}
+
{currency}{amount.toFixed(2)}
{warning && ( @@ -77,13 +106,13 @@ export default function RefundDialog({
setReason(e.target.value)} - placeholder="请输入退款原因(可选)" + placeholder={text.reasonPlaceholder} className={[ 'w-full rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none', dark ? 'border-slate-600 bg-slate-800 text-slate-100' : 'border-gray-300 bg-white text-gray-900', @@ -99,7 +128,7 @@ export default function RefundDialog({ onChange={(e) => setForce(e.target.checked)} className={['rounded', dark ? 'border-slate-600' : 'border-gray-300'].join(' ')} /> - 强制退款(余额可能扣为负数) + {text.forceRefund} )}
@@ -114,14 +143,14 @@ export default function RefundDialog({ : 'border-gray-300 text-gray-600 hover:bg-gray-50', ].join(' ')} > - 取消 + {text.cancel}
diff --git a/src/lib/admin-auth.ts b/src/lib/admin-auth.ts index 9a190de..43651eb 100644 --- a/src/lib/admin-auth.ts +++ b/src/lib/admin-auth.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getEnv } from '@/lib/config'; import crypto from 'crypto'; +import { resolveLocale } from '@/lib/locale'; function isLocalAdminToken(token: string): boolean { const env = getEnv(); @@ -56,6 +57,7 @@ export async function verifyAdminToken(request: NextRequest): Promise { return isSub2ApiAdmin(token); } -export function unauthorizedResponse() { - return NextResponse.json({ error: '未授权' }, { status: 401 }); +export function unauthorizedResponse(request?: NextRequest) { + const locale = resolveLocale(request?.nextUrl.searchParams.get('lang')); + return NextResponse.json({ error: locale === 'en' ? 'Unauthorized' : '未授权' }, { status: 401 }); } diff --git a/src/lib/locale.ts b/src/lib/locale.ts new file mode 100644 index 0000000..10aadae --- /dev/null +++ b/src/lib/locale.ts @@ -0,0 +1,20 @@ +export type Locale = 'zh' | 'en'; + +export function resolveLocale(lang: string | null | undefined): Locale { + return lang?.trim().toLowerCase() === 'en' ? 'en' : 'zh'; +} + +export function isEnglish(locale: Locale): boolean { + return locale === 'en'; +} + +export function pickLocaleText(locale: Locale, zh: T, en: T): T { + return locale === 'en' ? en : zh; +} + +export function applyLocaleToSearchParams(params: URLSearchParams, locale: Locale): URLSearchParams { + if (locale === 'en') { + params.set('lang', 'en'); + } + return params; +} diff --git a/src/lib/order/service.ts b/src/lib/order/service.ts index e617f67..962636f 100644 --- a/src/lib/order/service.ts +++ b/src/lib/order/service.ts @@ -9,9 +9,14 @@ import type { PaymentType, PaymentNotification } from '@/lib/payment'; import { getUser, createAndRedeem, subtractBalance, addBalance } from '@/lib/sub2api/client'; import { Prisma } from '@prisma/client'; import { deriveOrderState, isRefundStatus } from './status'; +import { pickLocaleText, type Locale } from '@/lib/locale'; const MAX_PENDING_ORDERS = 3; +function message(locale: Locale, zh: string, en: string): string { + return pickLocaleText(locale, zh, en); +} + export interface CreateOrderInput { userId: number; amount: number; @@ -20,6 +25,7 @@ export interface CreateOrderInput { isMobile?: boolean; srcHost?: string; srcUrl?: string; + locale?: Locale; } export interface CreateOrderResult { @@ -39,17 +45,22 @@ export interface CreateOrderResult { export async function createOrder(input: CreateOrderInput): Promise { const env = getEnv(); + const locale = input.locale ?? 'zh'; const user = await getUser(input.userId); if (user.status !== 'active') { - throw new OrderError('USER_INACTIVE', 'User account is disabled', 422); + throw new OrderError('USER_INACTIVE', message(locale, '用户账号已被禁用', 'User account is disabled'), 422); } const pendingCount = await prisma.order.count({ where: { userId: input.userId, status: ORDER_STATUS.PENDING }, }); if (pendingCount >= MAX_PENDING_ORDERS) { - throw new OrderError('TOO_MANY_PENDING', `Too many pending orders (${MAX_PENDING_ORDERS})`, 429); + throw new OrderError( + 'TOO_MANY_PENDING', + message(locale, `待支付订单过多(最多 ${MAX_PENDING_ORDERS} 笔)`, `Too many pending orders (${MAX_PENDING_ORDERS})`), + 429, + ); } // 每日累计充值限额校验(0 = 不限制) @@ -67,7 +78,15 @@ export async function createOrder(input: CreateOrderInput): Promise env.MAX_DAILY_RECHARGE_AMOUNT) { const remaining = Math.max(0, env.MAX_DAILY_RECHARGE_AMOUNT - alreadyPaid); - throw new OrderError('DAILY_LIMIT_EXCEEDED', `今日累计充值已达上限,剩余可充值 ${remaining.toFixed(2)} 元`, 429); + throw new OrderError( + 'DAILY_LIMIT_EXCEEDED', + message( + locale, + `今日累计充值已达上限,剩余可充值 ${remaining.toFixed(2)} 元`, + `Daily recharge limit reached. Remaining amount: ${remaining.toFixed(2)} CNY`, + ), + 429, + ); } } @@ -90,8 +109,16 @@ export async function createOrder(input: CreateOrderInput): Promise 0 - ? `${input.paymentType} 今日剩余额度 ${remaining.toFixed(2)} 元,请减少充值金额或使用其他支付方式` - : `${input.paymentType} 今日充值额度已满,请使用其他支付方式`, + ? message( + locale, + `${input.paymentType} 今日剩余额度 ${remaining.toFixed(2)} 元,请减少充值金额或使用其他支付方式`, + `${input.paymentType} remaining daily quota: ${remaining.toFixed(2)} CNY. Reduce the amount or use another payment method`, + ) + : message( + locale, + `${input.paymentType} 今日充值额度已满,请使用其他支付方式`, + `${input.paymentType} daily quota is full. Please use another payment method`, + ), 429, ); } @@ -195,9 +222,17 @@ export async function createOrder(input: CreateOrderInput): Promise { +export async function cancelOrder(orderId: string, userId: number, locale: Locale = 'zh'): Promise { const order = await prisma.order.findUnique({ where: { id: orderId }, select: { id: true, userId: true, status: true, paymentTradeNo: true, paymentType: true }, }); - if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404); - if (order.userId !== userId) throw new OrderError('FORBIDDEN', 'Forbidden', 403); - if (order.status !== ORDER_STATUS.PENDING) throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400); + if (!order) throw new OrderError('NOT_FOUND', message(locale, '订单不存在', 'Order not found'), 404); + if (order.userId !== userId) throw new OrderError('FORBIDDEN', message(locale, '无权操作该订单', 'Forbidden'), 403); + if (order.status !== ORDER_STATUS.PENDING) + throw new OrderError('INVALID_STATUS', message(locale, '订单当前状态不可取消', 'Order cannot be cancelled'), 400); return cancelOrderCore({ orderId: order.id, @@ -284,18 +320,19 @@ export async function cancelOrder(orderId: string, userId: number): Promise { +export async function adminCancelOrder(orderId: string, locale: Locale = 'zh'): Promise { const order = await prisma.order.findUnique({ where: { id: orderId }, select: { id: true, status: true, paymentTradeNo: true, paymentType: true }, }); - if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404); - if (order.status !== ORDER_STATUS.PENDING) throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400); + if (!order) throw new OrderError('NOT_FOUND', message(locale, '订单不存在', 'Order not found'), 404); + if (order.status !== ORDER_STATUS.PENDING) + throw new OrderError('INVALID_STATUS', message(locale, '订单当前状态不可取消', 'Order cannot be cancelled'), 400); return cancelOrderCore({ orderId: order.id, @@ -303,7 +340,7 @@ export async function adminCancelOrder(orderId: string): Promise paymentType: order.paymentType, finalStatus: ORDER_STATUS.CANCELLED, operator: 'admin', - auditDetail: 'Admin cancelled order', + auditDetail: message(locale, '管理员取消订单', 'Admin cancelled order'), }); } @@ -531,13 +568,13 @@ export async function executeRecharge(orderId: string): Promise { } } -function assertRetryAllowed(order: { status: string; paidAt: Date | null }): void { +function assertRetryAllowed(order: { status: string; paidAt: Date | null }, locale: Locale): void { if (!order.paidAt) { - throw new OrderError('INVALID_STATUS', 'Order is not paid, retry denied', 400); + throw new OrderError('INVALID_STATUS', message(locale, '订单未支付,不允许重试', 'Order is not paid, retry denied'), 400); } if (isRefundStatus(order.status)) { - throw new OrderError('INVALID_STATUS', 'Refund-related order cannot retry', 400); + throw new OrderError('INVALID_STATUS', message(locale, '退款相关订单不允许重试', 'Refund-related order cannot retry'), 400); } if (order.status === ORDER_STATUS.FAILED || order.status === ORDER_STATUS.PAID) { @@ -545,17 +582,17 @@ function assertRetryAllowed(order: { status: string; paidAt: Date | null }): voi } if (order.status === ORDER_STATUS.RECHARGING) { - throw new OrderError('CONFLICT', 'Order is recharging, retry later', 409); + throw new OrderError('CONFLICT', message(locale, '订单正在充值中,请稍后重试', 'Order is recharging, retry later'), 409); } if (order.status === ORDER_STATUS.COMPLETED) { - throw new OrderError('INVALID_STATUS', 'Order already completed', 400); + throw new OrderError('INVALID_STATUS', message(locale, '订单已完成', 'Order already completed'), 400); } - throw new OrderError('INVALID_STATUS', 'Only paid and failed orders can retry', 400); + throw new OrderError('INVALID_STATUS', message(locale, '仅已支付和失败订单允许重试', 'Only paid and failed orders can retry'), 400); } -export async function retryRecharge(orderId: string): Promise { +export async function retryRecharge(orderId: string, locale: Locale = 'zh'): Promise { const order = await prisma.order.findUnique({ where: { id: orderId }, select: { @@ -567,10 +604,10 @@ export async function retryRecharge(orderId: string): Promise { }); if (!order) { - throw new OrderError('NOT_FOUND', 'Order not found', 404); + throw new OrderError('NOT_FOUND', message(locale, '订单不存在', 'Order not found'), 404); } - assertRetryAllowed(order); + assertRetryAllowed(order, locale); const result = await prisma.order.updateMany({ where: { @@ -592,30 +629,30 @@ export async function retryRecharge(orderId: string): Promise { }); if (!latest) { - throw new OrderError('NOT_FOUND', 'Order not found', 404); + throw new OrderError('NOT_FOUND', message(locale, '订单不存在', 'Order not found'), 404); } const derived = deriveOrderState(latest); if (derived.rechargeStatus === 'recharging' || latest.status === ORDER_STATUS.PAID) { - throw new OrderError('CONFLICT', 'Order is recharging, retry later', 409); + throw new OrderError('CONFLICT', message(locale, '订单正在充值中,请稍后重试', 'Order is recharging, retry later'), 409); } if (derived.rechargeStatus === 'success') { - throw new OrderError('INVALID_STATUS', 'Order already completed', 400); + throw new OrderError('INVALID_STATUS', message(locale, '订单已完成', 'Order already completed'), 400); } if (isRefundStatus(latest.status)) { - throw new OrderError('INVALID_STATUS', 'Refund-related order cannot retry', 400); + throw new OrderError('INVALID_STATUS', message(locale, '退款相关订单不允许重试', 'Refund-related order cannot retry'), 400); } - throw new OrderError('CONFLICT', 'Order status changed, refresh and retry', 409); + throw new OrderError('CONFLICT', message(locale, '订单状态已变更,请刷新后重试', 'Order status changed, refresh and retry'), 409); } await prisma.auditLog.create({ data: { orderId, action: 'RECHARGE_RETRY', - detail: 'Admin manual retry recharge', + detail: message(locale, '管理员手动重试充值', 'Admin manual retry recharge'), operator: 'admin', }, }); @@ -627,6 +664,7 @@ export interface RefundInput { orderId: string; reason?: string; force?: boolean; + locale?: Locale; } export interface RefundResult { @@ -636,10 +674,11 @@ export interface RefundResult { } export async function processRefund(input: RefundInput): Promise { + const locale = input.locale ?? 'zh'; const order = await prisma.order.findUnique({ where: { id: input.orderId } }); - if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404); + if (!order) throw new OrderError('NOT_FOUND', message(locale, '订单不存在', 'Order not found'), 404); if (order.status !== ORDER_STATUS.COMPLETED) { - throw new OrderError('INVALID_STATUS', 'Only completed orders can be refunded', 400); + throw new OrderError('INVALID_STATUS', message(locale, '仅已完成订单允许退款', 'Only completed orders can be refunded'), 400); } const rechargeAmount = Number(order.amount); @@ -651,14 +690,18 @@ export async function processRefund(input: RefundInput): Promise { if (user.balance < rechargeAmount) { return { success: false, - warning: `User balance ${user.balance} is lower than refund ${rechargeAmount}`, + warning: message( + locale, + `用户余额 ${user.balance} 小于需退款的充值金额 ${rechargeAmount}`, + `User balance ${user.balance} is lower than refund ${rechargeAmount}`, + ), requireForce: true, }; } } catch { return { success: false, - warning: 'Cannot fetch user balance, use force=true', + warning: message(locale, '无法获取用户余额,请使用 force=true', 'Cannot fetch user balance, use force=true'), requireForce: true, }; } @@ -669,7 +712,7 @@ export async function processRefund(input: RefundInput): Promise { data: { status: ORDER_STATUS.REFUNDING }, }); if (lockResult.count === 0) { - throw new OrderError('CONFLICT', 'Order status changed, refresh and retry', 409); + throw new OrderError('CONFLICT', message(locale, '订单状态已变更,请刷新后重试', 'Order status changed, refresh and retry'), 409); } try { diff --git a/src/lib/pay-utils.ts b/src/lib/pay-utils.ts index f852fc6..6b349e0 100644 --- a/src/lib/pay-utils.ts +++ b/src/lib/pay-utils.ts @@ -4,6 +4,7 @@ import { PAYMENT_PREFIX, REDIRECT_PAYMENT_TYPES, } from './constants'; +import type { Locale } from './locale'; export interface UserInfo { id?: number; @@ -21,73 +22,90 @@ export interface MyOrder { export type OrderStatusFilter = 'ALL' | 'PENDING' | 'PAID' | 'COMPLETED' | 'CANCELLED' | 'EXPIRED' | 'FAILED'; -export const STATUS_TEXT_MAP: Record = { - [ORDER_STATUS.PENDING]: '待支付', - [ORDER_STATUS.PAID]: '已支付', - [ORDER_STATUS.RECHARGING]: '充值中', - [ORDER_STATUS.COMPLETED]: '已完成', - [ORDER_STATUS.EXPIRED]: '已超时', - [ORDER_STATUS.CANCELLED]: '已取消', - [ORDER_STATUS.FAILED]: '失败', - [ORDER_STATUS.REFUNDING]: '退款中', - [ORDER_STATUS.REFUNDED]: '已退款', - [ORDER_STATUS.REFUND_FAILED]: '退款失败', +const STATUS_TEXT_MAP: Record> = { + zh: { + [ORDER_STATUS.PENDING]: '待支付', + [ORDER_STATUS.PAID]: '已支付', + [ORDER_STATUS.RECHARGING]: '充值中', + [ORDER_STATUS.COMPLETED]: '已完成', + [ORDER_STATUS.EXPIRED]: '已超时', + [ORDER_STATUS.CANCELLED]: '已取消', + [ORDER_STATUS.FAILED]: '失败', + [ORDER_STATUS.REFUNDING]: '退款中', + [ORDER_STATUS.REFUNDED]: '已退款', + [ORDER_STATUS.REFUND_FAILED]: '退款失败', + }, + en: { + [ORDER_STATUS.PENDING]: 'Pending', + [ORDER_STATUS.PAID]: 'Paid', + [ORDER_STATUS.RECHARGING]: 'Recharging', + [ORDER_STATUS.COMPLETED]: 'Completed', + [ORDER_STATUS.EXPIRED]: 'Expired', + [ORDER_STATUS.CANCELLED]: 'Cancelled', + [ORDER_STATUS.FAILED]: 'Failed', + [ORDER_STATUS.REFUNDING]: 'Refunding', + [ORDER_STATUS.REFUNDED]: 'Refunded', + [ORDER_STATUS.REFUND_FAILED]: 'Refund failed', + }, }; -export const FILTER_OPTIONS: { key: OrderStatusFilter; label: string }[] = [ - { key: 'ALL', label: '全部' }, - { key: 'PENDING', label: '待支付' }, - { key: 'COMPLETED', label: '已完成' }, - { key: 'CANCELLED', label: '已取消' }, - { key: 'EXPIRED', label: '已超时' }, -]; +const FILTER_OPTIONS_MAP: Record = { + zh: [ + { key: 'ALL', label: '全部' }, + { key: 'PENDING', label: '待支付' }, + { key: 'COMPLETED', label: '已完成' }, + { key: 'CANCELLED', label: '已取消' }, + { key: 'EXPIRED', label: '已超时' }, + ], + en: [ + { key: 'ALL', label: 'All' }, + { key: 'PENDING', label: 'Pending' }, + { key: 'COMPLETED', label: 'Completed' }, + { key: 'CANCELLED', label: 'Cancelled' }, + { key: 'EXPIRED', label: 'Expired' }, + ], +}; + +export function getFilterOptions(locale: Locale = 'zh'): { key: OrderStatusFilter; label: string }[] { + return FILTER_OPTIONS_MAP[locale]; +} export function detectDeviceIsMobile(): boolean { if (typeof window === 'undefined') return false; - // 1. 现代 API(Chromium 系浏览器,最准确) const uad = (navigator as Navigator & { userAgentData?: { mobile: boolean } }).userAgentData; if (uad !== undefined) return uad.mobile; - // 2. UA 正则兜底(Safari / Firefox 等) const ua = navigator.userAgent || ''; const mobileUA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Windows Phone|Mobile/i.test(ua); if (mobileUA) return true; - // 3. 触控 + 小屏兜底(新版 iPad UA 伪装成 Mac 的情况) const smallPhysicalScreen = Math.min(window.screen.width, window.screen.height) <= 768; const touchCapable = navigator.maxTouchPoints > 1; return touchCapable && smallPhysicalScreen; } -export function formatStatus(status: string): string { - return STATUS_TEXT_MAP[status] || status; +export function formatStatus(status: string, locale: Locale = 'zh'): string { + return STATUS_TEXT_MAP[locale][status] || status; } -export function formatCreatedAt(value: string): string { +export function formatCreatedAt(value: string, locale: Locale = 'zh'): string { const date = new Date(value); if (Number.isNaN(date.getTime())) return value; - return date.toLocaleString(); + return date.toLocaleString(locale === 'en' ? 'en-US' : 'zh-CN'); } export interface PaymentTypeMeta { - /** 支付渠道名(用户看到的:支付宝 / 微信支付 / Stripe) */ label: string; - /** 选择器中的辅助说明(易支付 / 官方 / 信用卡 / 借记卡) */ sublabel?: string; - /** 提供商名称(易支付 / 支付宝 / 微信支付 / Stripe) */ provider: string; color: string; selectedBorder: string; selectedBg: string; - /** 暗色模式选中背景 */ selectedBgDark: string; iconBg: string; - /** 图标路径(Stripe 不使用外部图标) */ iconSrc?: string; - /** 图表条形颜色 class */ chartBar: { light: string; dark: string }; - /** 按钮颜色 class(含 hover/active 状态) */ buttonClass: string; } @@ -153,26 +171,51 @@ export const PAYMENT_TYPE_META: Record = { }, }; -/** 获取支付方式的显示名称(如 '支付宝(易支付)'),用于管理后台等需要区分的场景 */ -export function getPaymentTypeLabel(type: string): string { +const PAYMENT_TEXT_MAP: Record> = { + zh: { + [PAYMENT_TYPE.ALIPAY]: { label: '支付宝', provider: '易支付' }, + [PAYMENT_TYPE.ALIPAY_DIRECT]: { label: '支付宝', provider: '支付宝' }, + [PAYMENT_TYPE.WXPAY]: { label: '微信支付', provider: '易支付' }, + [PAYMENT_TYPE.WXPAY_DIRECT]: { label: '微信支付', provider: '微信支付' }, + [PAYMENT_TYPE.STRIPE]: { label: 'Stripe', provider: 'Stripe' }, + }, + en: { + [PAYMENT_TYPE.ALIPAY]: { label: 'Alipay', provider: 'EasyPay' }, + [PAYMENT_TYPE.ALIPAY_DIRECT]: { label: 'Alipay', provider: 'Alipay' }, + [PAYMENT_TYPE.WXPAY]: { label: 'WeChat Pay', provider: 'EasyPay' }, + [PAYMENT_TYPE.WXPAY_DIRECT]: { label: 'WeChat Pay', provider: 'WeChat Pay' }, + [PAYMENT_TYPE.STRIPE]: { label: 'Stripe', provider: 'Stripe' }, + }, +}; + +function getPaymentText(type: string, locale: Locale = 'zh'): { label: string; provider: string; sublabel?: string } { const meta = PAYMENT_TYPE_META[type]; + if (!meta) return { label: type, provider: '' }; + const baseText = PAYMENT_TEXT_MAP[locale][type] || { label: meta.label, provider: meta.provider }; + return { + ...baseText, + sublabel: meta.sublabel, + }; +} + +export function getPaymentTypeLabel(type: string, locale: Locale = 'zh'): string { + const meta = getPaymentText(type, locale); if (!meta) return type; - if (meta.sublabel) return `${meta.label}(${meta.sublabel})`; - // 无 sublabel 时,检查是否有同名渠道需要用 provider 区分 - const hasDuplicate = Object.entries(PAYMENT_TYPE_META).some( - ([k, m]) => k !== type && m.label === meta.label, + if (meta.sublabel) { + return locale === 'en' ? `${meta.label} (${meta.sublabel})` : `${meta.label}(${meta.sublabel})`; + } + const hasDuplicate = Object.keys(PAYMENT_TYPE_META).some( + (key) => key !== type && getPaymentText(key, locale).label === meta.label, ); - return hasDuplicate ? `${meta.label}(${meta.provider})` : meta.label; + if (!hasDuplicate || !meta.provider) return meta.label; + return locale === 'en' ? `${meta.label} (${meta.provider})` : `${meta.label}(${meta.provider})`; } -/** 获取支付渠道和提供商的结构化信息 */ -export function getPaymentDisplayInfo(type: string): { channel: string; provider: string } { - const meta = PAYMENT_TYPE_META[type]; - if (!meta) return { channel: type, provider: '' }; - return { channel: meta.label, provider: meta.provider }; +export function getPaymentDisplayInfo(type: string, locale: Locale = 'zh'): { channel: string; provider: string; sublabel?: string } { + const meta = getPaymentText(type, locale); + return { channel: meta.label, provider: meta.provider, sublabel: meta.sublabel }; } -/** 获取基础支付方式图标类型(alipay_direct → alipay) */ export function getPaymentIconType(type: string): string { if (type.startsWith(PAYMENT_PREFIX.ALIPAY)) return PAYMENT_PREFIX.ALIPAY; if (type.startsWith(PAYMENT_PREFIX.WXPAY)) return PAYMENT_PREFIX.WXPAY; @@ -180,23 +223,19 @@ export function getPaymentIconType(type: string): string { return type; } -/** 获取支付方式的元数据,带合理的 fallback */ export function getPaymentMeta(type: string): PaymentTypeMeta { const base = getPaymentIconType(type); return PAYMENT_TYPE_META[type] || PAYMENT_TYPE_META[base] || PAYMENT_TYPE_META[PAYMENT_TYPE.ALIPAY]; } -/** 获取支付方式图标路径 */ export function getPaymentIconSrc(type: string): string { return getPaymentMeta(type).iconSrc || ''; } -/** 获取支付方式简短标签(如 '支付宝'、'微信'、'Stripe') */ -export function getPaymentChannelLabel(type: string): string { - return getPaymentMeta(type).label; +export function getPaymentChannelLabel(type: string, locale: Locale = 'zh'): string { + return getPaymentDisplayInfo(type, locale).channel; } -/** 支付类型谓词函数 */ export function isStripeType(type: string | undefined | null): boolean { return !!type?.startsWith(PAYMENT_PREFIX.STRIPE); } @@ -209,12 +248,10 @@ export function isAlipayType(type: string | undefined | null): boolean { return !!type?.startsWith(PAYMENT_PREFIX.ALIPAY); } -/** 该支付方式需要页面跳转(而非二维码) */ export function isRedirectPayment(type: string | undefined | null): boolean { return !!type && REDIRECT_PAYMENT_TYPES.has(type); } -/** 用自定义 sublabel 覆盖默认值 */ export function applySublabelOverrides(overrides: Record): void { for (const [type, sublabel] of Object.entries(overrides)) { if (PAYMENT_TYPE_META[type]) { diff --git a/src/lib/utils/api.ts b/src/lib/utils/api.ts index 88aff57..3432e3c 100644 --- a/src/lib/utils/api.ts +++ b/src/lib/utils/api.ts @@ -1,13 +1,31 @@ import { NextRequest, NextResponse } from 'next/server'; import { OrderError } from '@/lib/order/service'; +import { resolveLocale } from '@/lib/locale'; /** 统一处理 OrderError 和未知错误 */ -export function handleApiError(error: unknown, fallbackMessage: string): NextResponse { +export function handleApiError(error: unknown, fallbackMessage: string, request?: NextRequest): NextResponse { if (error instanceof OrderError) { return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode }); } - console.error(`${fallbackMessage}:`, error); - return NextResponse.json({ error: fallbackMessage }, { status: 500 }); + const locale = resolveLocale(request?.nextUrl.searchParams.get('lang')); + const resolvedFallback = locale === 'en' ? translateFallbackMessage(fallbackMessage) : fallbackMessage; + console.error(`${resolvedFallback}:`, error); + return NextResponse.json({ error: resolvedFallback }, { status: 500 }); +} + +function translateFallbackMessage(message: string): string { + switch (message) { + case '退款失败': + return 'Refund failed'; + case '重试充值失败': + return 'Recharge retry failed'; + case '取消订单失败': + return 'Cancel order failed'; + case '获取用户信息失败': + return 'Failed to fetch user info'; + default: + return message; + } } /** 从 NextRequest 提取 headers 为普通对象 */