feat: 全站多语言支持 (i18n),lang=en 显示英文,其余默认中文
新增 src/lib/locale.ts 作为统一多语言入口,覆盖前台支付链路、 管理后台、API/服务层错误文案,共 35 个文件。URL 参数 lang 全链路透传, 包括 Stripe return_url、页面跳转、layout html lang 属性等。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
752
src/__tests__/payment-flow.test.ts
Normal file
752
src/__tests__/payment-flow.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<number>(30);
|
||||
const [data, setData] = useState<DashboardData | null>(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 (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">缺少管理员凭证</p>
|
||||
<p className="mt-2 text-sm text-gray-500">请从 Sub2API 平台正确访问管理页面</p>
|
||||
<p className="text-lg font-medium">{text.missingToken}</p>
|
||||
<p className="mt-2 text-sm text-gray-500">{text.missingTokenHint}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -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) => (
|
||||
<button key={d} type="button" onClick={() => setDays(d)} className={days === d ? btnActive : btnBase}>
|
||||
{d}天
|
||||
{d}{text.daySuffix}
|
||||
</button>
|
||||
))}
|
||||
<a href={`/admin?${navParams}`} className={btnBase}>
|
||||
订单管理
|
||||
{text.orders}
|
||||
</a>
|
||||
<button type="button" onClick={fetchData} className={btnBase}>
|
||||
刷新
|
||||
{text.refresh}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
@@ -130,14 +162,14 @@ function DashboardContent() {
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className={`py-24 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>加载中...</div>
|
||||
<div className={`py-24 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.loading}</div>
|
||||
) : data ? (
|
||||
<div className="space-y-6">
|
||||
<DashboardStats summary={data.summary} dark={isDark} />
|
||||
<DailyChart data={data.dailySeries} dark={isDark} />
|
||||
<DashboardStats summary={data.summary} dark={isDark} locale={locale} />
|
||||
<DailyChart data={data.dailySeries} dark={isDark} locale={locale} />
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Leaderboard data={data.leaderboard} dark={isDark} />
|
||||
<PaymentMethodChart data={data.paymentMethods} dark={isDark} />
|
||||
<Leaderboard data={data.leaderboard} dark={isDark} locale={locale} />
|
||||
<PaymentMethodChart data={data.paymentMethods} dark={isDark} locale={locale} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -145,14 +177,21 @@ function DashboardContent() {
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardPageFallback() {
|
||||
const searchParams = useSearchParams();
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
}
|
||||
fallback={<DashboardPageFallback />}
|
||||
>
|
||||
<DashboardContent />
|
||||
</Suspense>
|
||||
|
||||
@@ -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<AdminOrder[]>([]);
|
||||
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 (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">缺少管理员凭证</p>
|
||||
<p className="mt-2 text-sm text-gray-500">请从 Sub2API 平台正确访问管理页面</p>
|
||||
<p className="text-lg font-medium">{text.missingToken}</p>
|
||||
<p className="mt-2 text-sm text-gray-500">{text.missingTokenHint}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, string> = {
|
||||
'': '全部',
|
||||
PENDING: '待支付',
|
||||
PAID: '已支付',
|
||||
RECHARGING: '充值中',
|
||||
COMPLETED: '已完成',
|
||||
EXPIRED: '已超时',
|
||||
CANCELLED: '已取消',
|
||||
FAILED: '充值失败',
|
||||
REFUNDED: '已退款',
|
||||
};
|
||||
const statusLabels: Record<string, string> = 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={
|
||||
<>
|
||||
<a href={`/admin/dashboard?${navParams}`} className={btnBase}>
|
||||
数据概览
|
||||
{text.dashboard}
|
||||
</a>
|
||||
<button type="button" onClick={fetchOrders} className={btnBase}>
|
||||
刷新
|
||||
{text.refresh}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
@@ -236,7 +292,7 @@ function AdminContent() {
|
||||
].join(' ')}
|
||||
>
|
||||
{loading ? (
|
||||
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>加载中...</div>
|
||||
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.loading}</div>
|
||||
) : (
|
||||
<OrderTable
|
||||
orders={orders}
|
||||
@@ -244,6 +300,7 @@ function AdminContent() {
|
||||
onCancel={handleCancel}
|
||||
onViewDetail={handleViewDetail}
|
||||
dark={isDark}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -259,23 +316,31 @@ function AdminContent() {
|
||||
setPageSize(s);
|
||||
setPage(1);
|
||||
}}
|
||||
locale={locale}
|
||||
isDark={isDark}
|
||||
/>
|
||||
|
||||
{/* Order Detail */}
|
||||
{detailOrder && <OrderDetail order={detailOrder} onClose={() => setDetailOrder(null)} dark={isDark} />}
|
||||
{detailOrder && <OrderDetail order={detailOrder} onClose={() => setDetailOrder(null)} dark={isDark} locale={locale} />}
|
||||
</PayPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminPageFallback() {
|
||||
const searchParams = useSearchParams();
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
}
|
||||
fallback={<AdminPageFallback />}
|
||||
>
|
||||
<AdminContent />
|
||||
</Suspense>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string[]>();
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<html lang="zh-CN">
|
||||
<html lang={htmlLang} data-pathname={pathname}>
|
||||
<body className="antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
redirect('/pay');
|
||||
export default async function Home({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const lang = Array.isArray(params?.lang) ? params?.lang[0] : params?.lang;
|
||||
redirect(lang === 'en' ? '/pay?lang=en' : '/pay');
|
||||
}
|
||||
|
||||
@@ -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<UserInfo | null>(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() {
|
||||
<div
|
||||
className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-50 text-slate-900'}`}
|
||||
>
|
||||
正在切换到移动端订单 Tab...
|
||||
{text.switchingMobileTab}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -148,8 +164,8 @@ function OrdersContent() {
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">缺少认证信息</p>
|
||||
<p className="mt-2 text-sm text-gray-500">请从 Sub2API 平台正确访问订单页面</p>
|
||||
<p className="text-lg font-medium">{text.missingAuth}</p>
|
||||
<p className="mt-2 text-sm text-gray-500">{text.visitOrders}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -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() {
|
||||
<PayPageLayout
|
||||
isDark={isDark}
|
||||
isEmbedded={isEmbedded}
|
||||
title="我的订单"
|
||||
subtitle={userInfo?.username || '我的订单'}
|
||||
title={text.myOrders}
|
||||
subtitle={userInfo?.username || text.myOrders}
|
||||
actions={
|
||||
<>
|
||||
<button type="button" onClick={() => loadOrders(page, pageSize)} className={btnClass}>
|
||||
刷新
|
||||
{text.refresh}
|
||||
</button>
|
||||
{!srcHost && (
|
||||
<a href={buildScopedUrl('/pay')} className={btnClass}>
|
||||
返回充值
|
||||
{text.backToPay}
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<OrderSummaryCards isDark={isDark} summary={summary} />
|
||||
<OrderSummaryCards isDark={isDark} locale={locale} summary={summary} />
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<OrderFilterBar isDark={isDark} activeFilter={activeFilter} onChange={setActiveFilter} />
|
||||
<OrderFilterBar isDark={isDark} locale={locale} activeFilter={activeFilter} onChange={setActiveFilter} />
|
||||
</div>
|
||||
|
||||
<OrderTable isDark={isDark} loading={loading} error={error} orders={filteredOrders} />
|
||||
<OrderTable isDark={isDark} locale={locale} loading={loading} error={error} orders={filteredOrders} />
|
||||
|
||||
<PaginationBar
|
||||
page={page}
|
||||
@@ -196,6 +213,7 @@ function OrdersContent() {
|
||||
total={summary.total}
|
||||
pageSize={pageSize}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
locale={locale}
|
||||
isDark={isDark}
|
||||
loading={loading}
|
||||
onPageChange={handlePageChange}
|
||||
@@ -205,15 +223,20 @@ function OrdersContent() {
|
||||
);
|
||||
}
|
||||
|
||||
function OrdersPageFallback() {
|
||||
const searchParams = useSearchParams();
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">{pickLocaleText(locale, '加载中...', 'Loading...')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrdersPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<OrdersPageFallback />}>
|
||||
<OrdersContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">缺少认证信息</p>
|
||||
<p className="mt-2 text-sm text-gray-500">请从 Sub2API 平台正确访问充值页面</p>
|
||||
<p className="text-lg font-medium">{pickLocaleText(locale, '缺少认证信息', 'Missing authentication info')}</p>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
{pickLocaleText(locale, '请从 Sub2API 平台正确访问充值页面', 'Please open the recharge page from the Sub2API platform')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -215,8 +212,10 @@ function PayContent() {
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">用户不存在</p>
|
||||
<p className="mt-2 text-sm text-gray-500">请检查链接是否正确,或联系管理员</p>
|
||||
<p className="text-lg font-medium">{pickLocaleText(locale, '用户不存在', 'User not found')}</p>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
{pickLocaleText(locale, '请检查链接是否正确,或联系管理员', 'Please check whether the link is correct or contact the administrator')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -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<string, string> = {
|
||||
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')}
|
||||
</button>
|
||||
<a
|
||||
href={ordersUrl}
|
||||
@@ -340,7 +349,7 @@ function PayContent() {
|
||||
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
].join(' ')}
|
||||
>
|
||||
我的订单
|
||||
{pickLocaleText(locale, '我的订单', 'My Orders')}
|
||||
</a>
|
||||
</>
|
||||
) : undefined
|
||||
@@ -378,7 +387,7 @@ function PayContent() {
|
||||
: 'text-slate-500 hover:text-slate-700',
|
||||
].join(' ')}
|
||||
>
|
||||
充值
|
||||
{pickLocaleText(locale, '充值', 'Recharge')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -394,7 +403,7 @@ function PayContent() {
|
||||
: 'text-slate-500 hover:text-slate-700',
|
||||
].join(' ')}
|
||||
>
|
||||
我的订单
|
||||
{pickLocaleText(locale, '我的订单', 'My Orders')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -402,7 +411,9 @@ function PayContent() {
|
||||
{step === 'form' && config.enabledPaymentTypes.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
<span className={['ml-3 text-sm', isDark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>加载中...</span>
|
||||
<span className={['ml-3 text-sm', isDark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{pickLocaleText(locale, '加载中...', 'Loading...')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -423,6 +434,7 @@ function PayContent() {
|
||||
dark={isDark}
|
||||
pendingBlocked={pendingBlocked}
|
||||
pendingCount={pendingCount}
|
||||
locale={locale}
|
||||
/>
|
||||
) : (
|
||||
<MobileOrderList
|
||||
@@ -433,6 +445,7 @@ function PayContent() {
|
||||
loadingMore={ordersLoadingMore}
|
||||
onRefresh={loadUserAndOrders}
|
||||
onLoadMore={loadMoreOrders}
|
||||
locale={locale}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
@@ -451,6 +464,7 @@ function PayContent() {
|
||||
dark={isDark}
|
||||
pendingBlocked={pendingBlocked}
|
||||
pendingCount={pendingCount}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
@@ -460,11 +474,17 @@ function PayContent() {
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>支付说明</div>
|
||||
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{pickLocaleText(locale, '支付说明', 'Payment Notes')}
|
||||
</div>
|
||||
<ul className={['mt-2 space-y-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
<li>订单完成后会自动到账</li>
|
||||
<li>如需历史记录请查看「我的订单」</li>
|
||||
{config.maxDailyAmount > 0 && <li>每日最大充值 ¥{config.maxDailyAmount.toFixed(2)}</li>}
|
||||
<li>{pickLocaleText(locale, '订单完成后会自动到账', 'Balance will be credited automatically after the order completes')}</li>
|
||||
<li>{pickLocaleText(locale, '如需历史记录请查看「我的订单」', 'Check "My Orders" for payment history')}</li>
|
||||
{config.maxDailyAmount > 0 && (
|
||||
<li>
|
||||
{pickLocaleText(locale, '每日最大充值', 'Maximum daily recharge')} ¥{config.maxDailyAmount.toFixed(2)}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -475,7 +495,9 @@ function PayContent() {
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>Support</div>
|
||||
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{pickLocaleText(locale, '帮助', 'Support')}
|
||||
</div>
|
||||
{helpImageUrl && (
|
||||
<img
|
||||
src={helpImageUrl}
|
||||
@@ -491,7 +513,7 @@ function PayContent() {
|
||||
isDark ? 'text-slate-300' : 'text-slate-600',
|
||||
].join(' ')}
|
||||
>
|
||||
{helpText.split('\\n').map((line, i) => (
|
||||
{helpText.split('\n').map((line, i) => (
|
||||
<p key={i}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
@@ -521,10 +543,11 @@ function PayContent() {
|
||||
dark={isDark}
|
||||
isEmbedded={isEmbedded}
|
||||
isMobile={isMobile}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'result' && <OrderStatus status={finalStatus} onBack={handleBack} dark={isDark} />}
|
||||
{step === 'result' && <OrderStatus status={finalStatus} onBack={handleBack} dark={isDark} locale={locale} />}
|
||||
|
||||
{helpImageOpen && helpImageUrl && (
|
||||
<div
|
||||
@@ -543,14 +566,21 @@ function PayContent() {
|
||||
);
|
||||
}
|
||||
|
||||
function PayPageFallback() {
|
||||
const searchParams = useSearchParams();
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">{pickLocaleText(locale, '加载中...', 'Loading...')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PayPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
}
|
||||
fallback={<PayPageFallback />}
|
||||
>
|
||||
<PayContent />
|
||||
</Suspense>
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<div className={`flex min-h-screen items-center justify-center ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className={isDark ? 'text-slate-400' : 'text-gray-500'}>查询支付结果中...</div>
|
||||
<div className={isDark ? 'text-slate-400' : 'text-gray-500'}>{text.checking}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isPending = status === 'PENDING';
|
||||
const countdownText = countdown > 0 ? pickLocaleText(locale, `${countdown} 秒后自动返回`, `${countdown} seconds before returning`) : text.returning;
|
||||
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
@@ -105,78 +130,79 @@ function ResultContent() {
|
||||
{isSuccess ? (
|
||||
<>
|
||||
<div className="text-6xl text-green-500">✓</div>
|
||||
<h1 className="mt-4 text-xl font-bold text-green-600">
|
||||
{status === 'COMPLETED' ? '充值成功' : '充值处理中'}
|
||||
</h1>
|
||||
<h1 className="mt-4 text-xl font-bold text-green-600">{status === 'COMPLETED' ? text.success : text.processing}</h1>
|
||||
<p className={isDark ? 'mt-2 text-slate-400' : 'mt-2 text-gray-500'}>
|
||||
{status === 'COMPLETED' ? '余额已成功到账!' : '支付成功,余额正在充值中...'}
|
||||
{status === 'COMPLETED' ? text.successMessage : text.processingMessage}
|
||||
</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className={isDark ? 'text-sm text-slate-500' : 'text-sm text-gray-400'}>
|
||||
{countdown > 0 ? `${countdown} 秒后自动返回` : '正在返回...'}
|
||||
</p>
|
||||
<p className={isDark ? 'text-sm text-slate-500' : 'text-sm text-gray-400'}>{countdownText}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
className="text-sm text-blue-600 underline hover:text-blue-700"
|
||||
>
|
||||
立即返回
|
||||
{text.returnNow}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : isPending ? (
|
||||
<>
|
||||
<div className="text-6xl text-yellow-500">⏳</div>
|
||||
<h1 className="mt-4 text-xl font-bold text-yellow-600">等待支付</h1>
|
||||
<p className={isDark ? 'mt-2 text-slate-400' : 'mt-2 text-gray-500'}>订单尚未完成支付</p>
|
||||
<h1 className="mt-4 text-xl font-bold text-yellow-600">{text.pending}</h1>
|
||||
<p className={isDark ? 'mt-2 text-slate-400' : 'mt-2 text-gray-500'}>{text.pendingMessage}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
className="mt-4 text-sm text-blue-600 underline hover:text-blue-700"
|
||||
>
|
||||
返回
|
||||
{text.back}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-6xl text-red-500">✗</div>
|
||||
<h1 className="mt-4 text-xl font-bold text-red-600">
|
||||
{status === 'EXPIRED' ? '订单已超时' : status === 'CANCELLED' ? '订单已取消' : '支付异常'}
|
||||
{status === 'EXPIRED' ? text.expired : status === 'CANCELLED' ? text.cancelled : text.abnormal}
|
||||
</h1>
|
||||
<p className={isDark ? 'mt-2 text-slate-400' : 'mt-2 text-gray-500'}>
|
||||
{status === 'EXPIRED'
|
||||
? '订单已超时,请重新充值'
|
||||
? text.expiredMessage
|
||||
: status === 'CANCELLED'
|
||||
? '订单已被取消'
|
||||
: '请联系管理员处理'}
|
||||
? text.cancelledMessage
|
||||
: text.abnormalMessage}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
className="mt-4 text-sm text-blue-600 underline hover:text-blue-700"
|
||||
>
|
||||
返回
|
||||
{text.back}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className={isDark ? 'mt-4 text-xs text-slate-500' : 'mt-4 text-xs text-gray-400'}>
|
||||
订单号: {outTradeNo || '未知'}
|
||||
{text.orderId}: {outTradeNo || text.unknown}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultPageFallback() {
|
||||
const searchParams = useSearchParams();
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50">
|
||||
<div className="text-gray-500">{pickLocaleText(locale, '加载中...', 'Loading...')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PayResultPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<ResultPageFallback />}>
|
||||
<ResultContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
@@ -161,14 +169,13 @@ function StripePopupContent() {
|
||||
>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
|
||||
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>正在初始化...</span>
|
||||
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.init}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Alipay direct confirm: show loading/redirecting state
|
||||
if (isAlipay) {
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
@@ -180,7 +187,7 @@ function StripePopupContent() {
|
||||
{'¥'}
|
||||
{amount.toFixed(2)}
|
||||
</div>
|
||||
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>订单号: {orderId}</p>
|
||||
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.orderId}: {orderId}</p>
|
||||
</div>
|
||||
{stripeError ? (
|
||||
<div className="space-y-3">
|
||||
@@ -190,14 +197,14 @@ function StripePopupContent() {
|
||||
onClick={() => window.close()}
|
||||
className="w-full text-sm text-blue-600 underline hover:text-blue-700"
|
||||
>
|
||||
关闭窗口
|
||||
{text.closeWindow}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
|
||||
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
正在跳转到支付页面...
|
||||
{text.redirecting}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -216,26 +223,26 @@ function StripePopupContent() {
|
||||
{'¥'}
|
||||
{amount.toFixed(2)}
|
||||
</div>
|
||||
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>订单号: {orderId}</p>
|
||||
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.orderId}: {orderId}</p>
|
||||
</div>
|
||||
|
||||
{!stripeLoaded ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
|
||||
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>正在加载支付表单...</span>
|
||||
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.loadingForm}</span>
|
||||
</div>
|
||||
) : stripeSuccess ? (
|
||||
<div className="py-6 text-center">
|
||||
<div className="text-5xl text-green-600">{'✓'}</div>
|
||||
<p className={`mt-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
支付成功,窗口即将自动关闭...
|
||||
{text.successClosing}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.close()}
|
||||
className="mt-4 text-sm text-blue-600 underline hover:text-blue-700"
|
||||
>
|
||||
手动关闭窗口
|
||||
{text.closeWindowManually}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -261,10 +268,10 @@ function StripePopupContent() {
|
||||
{stripeSubmitting ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
处理中...
|
||||
{text.processing}
|
||||
</span>
|
||||
) : (
|
||||
`支付 ¥${amount.toFixed(2)}`
|
||||
text.payAmount
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
@@ -274,15 +281,20 @@ function StripePopupContent() {
|
||||
);
|
||||
}
|
||||
|
||||
function StripePopupFallback() {
|
||||
const searchParams = useSearchParams();
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">{pickLocaleText(locale, '加载中...', 'Loading...')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StripePopupPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<StripePopupFallback />}>
|
||||
<StripePopupContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -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<OrderStatusFilter>('ALL');
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -59,7 +62,7 @@ export default function MobileOrderList({
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={['text-base font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
||||
我的订单
|
||||
{locale === 'en' ? 'My Orders' : '我的订单'}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
@@ -71,11 +74,11 @@ export default function MobileOrderList({
|
||||
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
].join(' ')}
|
||||
>
|
||||
刷新
|
||||
{locale === 'en' ? 'Refresh' : '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<OrderFilterBar isDark={isDark} activeFilter={activeFilter} onChange={setActiveFilter} />
|
||||
<OrderFilterBar isDark={isDark} locale={locale} activeFilter={activeFilter} onChange={setActiveFilter} />
|
||||
|
||||
{!hasToken ? (
|
||||
<div
|
||||
@@ -84,7 +87,9 @@ export default function MobileOrderList({
|
||||
isDark ? 'border-amber-500/40 text-amber-200' : 'border-amber-300 text-amber-700',
|
||||
].join(' ')}
|
||||
>
|
||||
当前链接未携带登录 token,无法查询"我的订单"。
|
||||
{locale === 'en'
|
||||
? 'The current link does not include a login token, so "My Orders" is unavailable.'
|
||||
: '当前链接未携带登录 token,无法查询"我的订单"。'}
|
||||
</div>
|
||||
) : filteredOrders.length === 0 ? (
|
||||
<div
|
||||
@@ -93,7 +98,7 @@ export default function MobileOrderList({
|
||||
isDark ? 'border-slate-600 text-slate-400' : 'border-slate-300 text-slate-500',
|
||||
].join(' ')}
|
||||
>
|
||||
暂无符合条件的订单记录
|
||||
{locale === 'en' ? 'No matching orders found' : '暂无符合条件的订单记录'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -110,26 +115,27 @@ export default function MobileOrderList({
|
||||
<span
|
||||
className={['rounded-full px-2 py-0.5 text-xs', getStatusBadgeClass(order.status, isDark)].join(' ')}
|
||||
>
|
||||
{formatStatus(order.status)}
|
||||
{formatStatus(order.status, locale)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={['mt-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
{getPaymentDisplayInfo(order.paymentType).channel}
|
||||
{getPaymentDisplayInfo(order.paymentType, locale).channel}
|
||||
</div>
|
||||
<div className={['mt-0.5 text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{formatCreatedAt(order.createdAt)}
|
||||
{formatCreatedAt(order.createdAt, locale)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 无限滚动哨兵 */}
|
||||
{hasMore && (
|
||||
<div ref={sentinelRef} className="py-3 text-center">
|
||||
{loadingMore ? (
|
||||
<span className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>加载中...</span>
|
||||
<span className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{locale === 'en' ? 'Loading...' : '加载中...'}
|
||||
</span>
|
||||
) : (
|
||||
<span className={['text-xs', isDark ? 'text-slate-600' : 'text-slate-300'].join(' ')}>
|
||||
上滑加载更多
|
||||
{locale === 'en' ? 'Scroll up to load more' : '上滑加载更多'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -137,7 +143,7 @@ export default function MobileOrderList({
|
||||
|
||||
{!hasMore && orders.length > 0 && (
|
||||
<div className={['py-2 text-center text-xs', isDark ? 'text-slate-600' : 'text-slate-400'].join(' ')}>
|
||||
已显示全部订单
|
||||
{locale === 'en' ? 'All orders loaded' : '已显示全部订单'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FILTER_OPTIONS.map((item) => (
|
||||
{getFilterOptions(locale).map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
|
||||
@@ -1,56 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface OrderStatusProps {
|
||||
status: string;
|
||||
onBack: () => void;
|
||||
dark?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: string; message: string }> = {
|
||||
COMPLETED: {
|
||||
label: '充值成功',
|
||||
color: 'text-green-600',
|
||||
icon: '✓',
|
||||
message: '余额已到账,感谢您的充值!',
|
||||
const STATUS_CONFIG: Record<Locale, Record<string, { label: string; color: string; icon: string; message: string }>> = {
|
||||
zh: {
|
||||
COMPLETED: {
|
||||
label: '充值成功',
|
||||
color: 'text-green-600',
|
||||
icon: '✓',
|
||||
message: '余额已到账,感谢您的充值!',
|
||||
},
|
||||
PAID: {
|
||||
label: '充值中',
|
||||
color: 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: '支付成功,正在充值余额中...',
|
||||
},
|
||||
RECHARGING: {
|
||||
label: '充值中',
|
||||
color: 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: '正在充值余额中,请稍候...',
|
||||
},
|
||||
FAILED: {
|
||||
label: '充值失败',
|
||||
color: 'text-red-600',
|
||||
icon: '✗',
|
||||
message: '充值失败,请联系管理员处理。',
|
||||
},
|
||||
EXPIRED: {
|
||||
label: '订单超时',
|
||||
color: 'text-gray-500',
|
||||
icon: '⏰',
|
||||
message: '订单已超时,请重新创建订单。',
|
||||
},
|
||||
CANCELLED: {
|
||||
label: '已取消',
|
||||
color: 'text-gray-500',
|
||||
icon: '✗',
|
||||
message: '订单已取消。',
|
||||
},
|
||||
},
|
||||
PAID: {
|
||||
label: '充值中',
|
||||
color: 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: '支付成功,正在充值余额中...',
|
||||
},
|
||||
RECHARGING: {
|
||||
label: '充值中',
|
||||
color: 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: '正在充值余额中,请稍候...',
|
||||
},
|
||||
FAILED: {
|
||||
label: '充值失败',
|
||||
color: 'text-red-600',
|
||||
icon: '✗',
|
||||
message: '充值失败,请联系管理员处理。',
|
||||
},
|
||||
EXPIRED: {
|
||||
label: '订单超时',
|
||||
color: 'text-gray-500',
|
||||
icon: '⏰',
|
||||
message: '订单已超时,请重新创建订单。',
|
||||
},
|
||||
CANCELLED: {
|
||||
label: '已取消',
|
||||
color: 'text-gray-500',
|
||||
icon: '✗',
|
||||
message: '订单已取消。',
|
||||
en: {
|
||||
COMPLETED: {
|
||||
label: 'Recharge Successful',
|
||||
color: 'text-green-600',
|
||||
icon: '✓',
|
||||
message: 'Your balance has been credited. Thank you for your payment.',
|
||||
},
|
||||
PAID: {
|
||||
label: 'Recharging',
|
||||
color: 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: 'Payment received. Recharging your balance...',
|
||||
},
|
||||
RECHARGING: {
|
||||
label: 'Recharging',
|
||||
color: 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: 'Recharging your balance. Please wait...',
|
||||
},
|
||||
FAILED: {
|
||||
label: 'Recharge Failed',
|
||||
color: 'text-red-600',
|
||||
icon: '✗',
|
||||
message: 'Recharge failed. Please contact the administrator.',
|
||||
},
|
||||
EXPIRED: {
|
||||
label: 'Order Expired',
|
||||
color: 'text-gray-500',
|
||||
icon: '⏰',
|
||||
message: 'This order has expired. Please create a new order.',
|
||||
},
|
||||
CANCELLED: {
|
||||
label: 'Cancelled',
|
||||
color: 'text-gray-500',
|
||||
icon: '✗',
|
||||
message: 'The order has been cancelled.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function OrderStatus({ status, onBack, dark = false }: OrderStatusProps) {
|
||||
const config = STATUS_CONFIG[status] || {
|
||||
export default function OrderStatus({ status, onBack, dark = false, locale = 'zh' }: OrderStatusProps) {
|
||||
const config = STATUS_CONFIG[locale][status] || {
|
||||
label: status,
|
||||
color: 'text-gray-600',
|
||||
icon: '?',
|
||||
message: '未知状态',
|
||||
message: locale === 'en' ? 'Unknown status' : '未知状态',
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -65,7 +108,7 @@ export default function OrderStatus({ status, onBack, dark = false }: OrderStatu
|
||||
dark ? 'bg-blue-600 hover:bg-blue-500' : 'bg-blue-600 hover:bg-blue-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{status === 'COMPLETED' ? '完成' : '返回充值'}
|
||||
{status === 'COMPLETED' ? (locale === 'en' ? 'Done' : '完成') : locale === 'en' ? 'Back to Recharge' : '返回充值'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className={cardClass}>
|
||||
<div className={labelClass}>总订单</div>
|
||||
<div className={labelClass}>{labels.total}</div>
|
||||
<div className="mt-1 text-xl font-semibold">{summary.total}</div>
|
||||
</div>
|
||||
<div className={cardClass}>
|
||||
<div className={labelClass}>待支付</div>
|
||||
<div className={labelClass}>{labels.pending}</div>
|
||||
<div className="mt-1 text-xl font-semibold">{summary.pending}</div>
|
||||
</div>
|
||||
<div className={cardClass}>
|
||||
<div className={labelClass}>已完成</div>
|
||||
<div className={labelClass}>{labels.completed}</div>
|
||||
<div className="mt-1 text-xl font-semibold">{summary.completed}</div>
|
||||
</div>
|
||||
<div className={cardClass}>
|
||||
<div className={labelClass}>异常/关闭</div>
|
||||
<div className={labelClass}>{labels.failed}</div>
|
||||
<div className="mt-1 text-xl font-semibold">{summary.failed}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={[
|
||||
@@ -40,7 +61,7 @@ export default function OrderTable({ isDark, loading, error, orders }: OrderTabl
|
||||
isDark ? 'border-slate-600 text-slate-400' : 'border-slate-300 text-slate-500',
|
||||
].join(' ')}
|
||||
>
|
||||
暂无符合条件的订单记录
|
||||
{text.empty}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -50,11 +71,11 @@ export default function OrderTable({ isDark, loading, error, orders }: OrderTabl
|
||||
isDark ? 'text-slate-300' : 'text-slate-600',
|
||||
].join(' ')}
|
||||
>
|
||||
<span>订单号</span>
|
||||
<span>金额</span>
|
||||
<span>支付方式</span>
|
||||
<span>状态</span>
|
||||
<span>创建时间</span>
|
||||
<span>{text.orderId}</span>
|
||||
<span>{text.amount}</span>
|
||||
<span>{text.payment}</span>
|
||||
<span>{text.status}</span>
|
||||
<span>{text.createdAt}</span>
|
||||
</div>
|
||||
<div className="space-y-2 md:space-y-0">
|
||||
{orders.map((order) => (
|
||||
@@ -67,19 +88,17 @@ export default function OrderTable({ isDark, loading, error, orders }: OrderTabl
|
||||
>
|
||||
<div className="font-medium">#{order.id.slice(0, 12)}</div>
|
||||
<div className="font-semibold">¥{order.amount.toFixed(2)}</div>
|
||||
<div>
|
||||
{getPaymentDisplayInfo(order.paymentType).channel}
|
||||
</div>
|
||||
<div>{getPaymentDisplayInfo(order.paymentType, locale).channel}</div>
|
||||
<div>
|
||||
<span
|
||||
className={['rounded-full px-2 py-0.5 text-xs', getStatusBadgeClass(order.status, isDark)].join(
|
||||
' ',
|
||||
)}
|
||||
>
|
||||
{formatStatus(order.status)}
|
||||
{formatStatus(order.status, locale)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>{formatCreatedAt(order.createdAt)}</div>
|
||||
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>{formatCreatedAt(order.createdAt, locale)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-xs">
|
||||
{/* 左侧:统计 + 每页大小 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>
|
||||
共 {total} 条{totalPages > 1 && `,第 ${page} / ${totalPages} 页`}
|
||||
</span>
|
||||
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>{text.total}</span>
|
||||
|
||||
{onPageSizeChange && (
|
||||
<>
|
||||
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>每页</span>
|
||||
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{text.perPage}</span>
|
||||
{pageSizeOptions.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
@@ -68,7 +84,6 @@ export default function PaginationBar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:分页导航 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
@@ -85,7 +100,7 @@ export default function PaginationBar({
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
className={navBtnClass(page <= 1)}
|
||||
>
|
||||
上一页
|
||||
{text.previous}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -93,7 +108,7 @@ export default function PaginationBar({
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
className={navBtnClass(page >= totalPages)}
|
||||
>
|
||||
下一页
|
||||
{text.next}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface PayPageLayoutProps {
|
||||
isDark: boolean;
|
||||
@@ -8,6 +9,7 @@ interface PayPageLayoutProps {
|
||||
subtitle: string;
|
||||
actions?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
export default function PayPageLayout({
|
||||
@@ -18,6 +20,7 @@ export default function PayPageLayout({
|
||||
subtitle,
|
||||
actions,
|
||||
children,
|
||||
locale = 'zh',
|
||||
}: PayPageLayoutProps) {
|
||||
const maxWidthClass = maxWidth === 'sm' ? 'max-w-lg' : maxWidth === 'lg' ? 'max-w-6xl' : '';
|
||||
|
||||
@@ -64,7 +67,7 @@ export default function PayPageLayout({
|
||||
isDark ? 'bg-indigo-500/20 text-indigo-200' : 'bg-indigo-50 text-indigo-700',
|
||||
].join(' ')}
|
||||
>
|
||||
Sub2API Secure Pay
|
||||
{locale === 'en' ? 'Sub2API Secure Pay' : 'Sub2API 安全支付'}
|
||||
</div>
|
||||
<h1
|
||||
className={['text-2xl font-semibold tracking-tight', isDark ? 'text-slate-100' : 'text-slate-900'].join(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { PAYMENT_TYPE_META, getPaymentIconType, getPaymentMeta } from '@/lib/pay-utils';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import { PAYMENT_TYPE_META, getPaymentIconType, getPaymentMeta, getPaymentDisplayInfo } from '@/lib/pay-utils';
|
||||
|
||||
export interface MethodLimitInfo {
|
||||
available: boolean;
|
||||
@@ -25,6 +26,7 @@ interface PaymentFormProps {
|
||||
dark?: boolean;
|
||||
pendingBlocked?: boolean;
|
||||
pendingCount?: number;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
const QUICK_AMOUNTS = [10, 20, 50, 100, 200, 500, 1000, 2000];
|
||||
@@ -47,12 +49,12 @@ export default function PaymentForm({
|
||||
dark = false,
|
||||
pendingBlocked = false,
|
||||
pendingCount = 0,
|
||||
locale = 'zh',
|
||||
}: PaymentFormProps) {
|
||||
const [amount, setAmount] = useState<number | ''>('');
|
||||
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 (
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-[#00AEEF] text-xl font-bold leading-none text-white">
|
||||
支
|
||||
{locale === 'en' ? 'A' : '支'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -144,7 +146,6 @@ export default function PaymentForm({
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* User Info */}
|
||||
<div
|
||||
className={[
|
||||
'rounded-xl border p-4',
|
||||
@@ -152,22 +153,22 @@ export default function PaymentForm({
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['text-xs uppercase tracking-wide', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
充值账户
|
||||
{locale === 'en' ? 'Recharge Account' : '充值账户'}
|
||||
</div>
|
||||
<div className={['mt-1 text-base font-medium', dark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
||||
{userName || `用户 #${userId}`}
|
||||
{userName || (locale === 'en' ? `User #${userId}` : `用户 #${userId}`)}
|
||||
</div>
|
||||
{userBalance !== undefined && (
|
||||
<div className={['mt-1 text-sm', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
当前余额: <span className="font-medium text-green-600">{userBalance.toFixed(2)}</span>
|
||||
{locale === 'en' ? 'Current Balance:' : '当前余额:'}{' '}
|
||||
<span className="font-medium text-green-600">{userBalance.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Amount Selection */}
|
||||
<div>
|
||||
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
|
||||
充值金额
|
||||
{locale === 'en' ? 'Recharge Amount' : '充值金额'}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{QUICK_AMOUNTS.filter((val) => val >= minAmount && val <= effectiveMax).map((val) => (
|
||||
@@ -189,10 +190,9 @@ export default function PaymentForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Amount */}
|
||||
<div>
|
||||
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
|
||||
自定义金额
|
||||
{locale === 'en' ? 'Custom Amount' : '自定义金额'}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span
|
||||
@@ -223,23 +223,25 @@ export default function PaymentForm({
|
||||
!isValid &&
|
||||
(() => {
|
||||
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 <div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>{msg}</div>;
|
||||
})()}
|
||||
|
||||
{/* Payment Type — only show when multiple types available */}
|
||||
{enabledPaymentTypes.length > 1 && (
|
||||
<div>
|
||||
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-gray-700'].join(' ')}>
|
||||
支付方式
|
||||
{locale === 'en' ? 'Payment Method' : '支付方式'}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3 sm:flex">
|
||||
{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({
|
||||
<span className="flex items-center gap-2">
|
||||
{renderPaymentIcon(type)}
|
||||
<span className="flex flex-col items-start leading-none">
|
||||
<span className="text-xl font-semibold tracking-tight">{meta?.label || type}</span>
|
||||
<span className="text-xl font-semibold tracking-tight">{displayInfo.channel || type}</span>
|
||||
{isUnavailable ? (
|
||||
<span className="text-[10px] tracking-wide text-red-400">今日额度已满</span>
|
||||
) : meta?.sublabel ? (
|
||||
<span className="text-[10px] tracking-wide text-red-400">{locale === 'en' ? 'Daily limit reached' : '今日额度已满'}</span>
|
||||
) : displayInfo.sublabel ? (
|
||||
<span
|
||||
className={`text-[10px] tracking-wide ${dark ? (isSelected ? 'text-slate-300' : 'text-slate-400') : 'text-slate-600'}`}
|
||||
>
|
||||
{meta.sublabel}
|
||||
{displayInfo.sublabel}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
@@ -284,20 +286,20 @@ export default function PaymentForm({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 当前选中渠道额度不足时的提示 */}
|
||||
{(() => {
|
||||
const limitInfo = methodLimits?.[effectivePaymentType];
|
||||
if (!limitInfo || limitInfo.available) return null;
|
||||
return (
|
||||
<p className={['mt-2 text-xs', dark ? 'text-amber-300' : 'text-amber-600'].join(' ')}>
|
||||
所选支付方式今日额度已满,请切换到其他支付方式
|
||||
{locale === 'en'
|
||||
? 'The selected payment method has reached today\'s limit. Please switch to another method.'
|
||||
: '所选支付方式今日额度已满,请切换到其他支付方式'}
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fee Detail */}
|
||||
{feeRate > 0 && selectedAmount > 0 && (
|
||||
<div
|
||||
className={[
|
||||
@@ -306,26 +308,25 @@ export default function PaymentForm({
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>充值金额</span>
|
||||
<span>{locale === 'en' ? 'Recharge Amount' : '充值金额'}</span>
|
||||
<span>¥{selectedAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span>手续费({feeRate}%)</span>
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<span>{locale === 'en' ? `Fee (${feeRate}%)` : `手续费(${feeRate}%)`}</span>
|
||||
<span>¥{feeAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div
|
||||
className={[
|
||||
'flex items-center justify-between mt-1.5 pt-1.5 border-t font-medium',
|
||||
'mt-1.5 flex items-center justify-between border-t pt-1.5 font-medium',
|
||||
dark ? 'border-slate-700 text-slate-100' : 'border-slate-200 text-slate-900',
|
||||
].join(' ')}
|
||||
>
|
||||
<span>实付金额</span>
|
||||
<span>{locale === 'en' ? 'Amount to Pay' : '实付金额'}</span>
|
||||
<span>¥{payAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending order limit warning */}
|
||||
{pendingBlocked && (
|
||||
<div
|
||||
className={[
|
||||
@@ -335,11 +336,12 @@ export default function PaymentForm({
|
||||
: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
].join(' ')}
|
||||
>
|
||||
您有 {pendingCount} 个待支付订单,请先完成或取消后再充值
|
||||
{locale === 'en'
|
||||
? `You have ${pendingCount} pending orders. Please complete or cancel them before recharging.`
|
||||
: `您有 ${pendingCount} 个待支付订单,请先完成或取消后再充值`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid || loading || pendingBlocked}
|
||||
@@ -352,10 +354,16 @@ export default function PaymentForm({
|
||||
}`}
|
||||
>
|
||||
{loading
|
||||
? '处理中...'
|
||||
? locale === 'en'
|
||||
? 'Processing...'
|
||||
: '处理中...'
|
||||
: pendingBlocked
|
||||
? '待支付订单过多'
|
||||
: `立即充值 ¥${(feeRate > 0 && selectedAmount > 0 ? payAmount : selectedAmount || 0).toFixed(2)}`}
|
||||
? locale === 'en'
|
||||
? 'Too many pending orders'
|
||||
: '待支付订单过多'
|
||||
: locale === 'en'
|
||||
? `Recharge Now ¥${(feeRate > 0 && selectedAmount > 0 ? payAmount : selectedAmount || 0).toFixed(2)}`
|
||||
: `立即充值 ¥${(feeRate > 0 && selectedAmount > 0 ? payAmount : selectedAmount || 0).toFixed(2)}`}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col items-center space-y-4 py-8">
|
||||
<div className="text-6xl text-green-600">{'✓'}</div>
|
||||
<h2 className="text-xl font-bold text-green-600">{'订单已支付'}</h2>
|
||||
<h2 className="text-xl font-bold text-green-600">{t.paid}</h2>
|
||||
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{'该订单已支付完成,无法取消。充值将自动到账。'}
|
||||
{t.paidCancelBlocked}
|
||||
</p>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="mt-4 w-full rounded-lg bg-blue-600 py-3 font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
{'返回充值'}
|
||||
{t.backToRecharge}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -367,11 +386,12 @@ export default function PaymentQRCode({
|
||||
</div>
|
||||
{hasFeeDiff && (
|
||||
<div className={['mt-1 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
到账 ¥{amount.toFixed(2)}
|
||||
{t.credited}
|
||||
{amount.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
<div className={`mt-1 text-sm ${expired ? 'text-red-500' : !expired && timeLeftSeconds <= 60 ? 'text-red-500 animate-pulse' : dark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{expired ? TEXT_EXPIRED : `${TEXT_REMAINING}: ${timeLeft}`}
|
||||
{expired ? t.expired : `${t.remaining}: ${timeLeft}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -387,14 +407,14 @@ export default function PaymentQRCode({
|
||||
].join(' ')}
|
||||
>
|
||||
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
支付初始化失败,请返回重试
|
||||
{t.initFailed}
|
||||
</p>
|
||||
</div>
|
||||
) : !stripeLoaded ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
|
||||
<span className={['ml-3 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
正在加载支付表单...
|
||||
{t.loadingForm}
|
||||
</span>
|
||||
</div>
|
||||
) : stripeError && !stripeLib ? (
|
||||
@@ -420,7 +440,7 @@ export default function PaymentQRCode({
|
||||
<div className="text-center">
|
||||
<div className="text-4xl text-green-600">{'✓'}</div>
|
||||
<p className={['mt-2 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
支付成功,正在处理订单...
|
||||
{t.successProcessing}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -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 ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
处理中...
|
||||
{t.processing}
|
||||
</span>
|
||||
) : (
|
||||
`支付 ¥${amount.toFixed(2)}`
|
||||
`${t.payNow} ¥${amount.toFixed(2)}`
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
@@ -454,7 +474,7 @@ export default function PaymentQRCode({
|
||||
: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
].join(' ')}
|
||||
>
|
||||
弹出窗口被浏览器拦截,请允许本站弹出窗口后重试
|
||||
{t.popupBlocked}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -465,7 +485,7 @@ export default function PaymentQRCode({
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<div className={`h-8 w-8 animate-spin rounded-full border-2 border-t-transparent`} style={{ borderColor: meta.color, borderTopColor: 'transparent' }} />
|
||||
<span className={['ml-3 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
正在跳转到{channelLabel}...
|
||||
{`${t.redirectingPrefix}${channelLabel}${t.redirectingSuffix}`}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
@@ -475,10 +495,10 @@ export default function PaymentQRCode({
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-lg py-3 font-medium text-white shadow-md ${meta.buttonClass}`}
|
||||
>
|
||||
{iconSrc && <img src={iconSrc} alt={channelLabel} className="h-5 w-5 brightness-0 invert" />}
|
||||
{redirected ? `未跳转?点击前往${channelLabel}` : `前往${channelLabel}支付`}
|
||||
{redirected ? `${t.notRedirectedPrefix}${channelLabel}` : `${t.gotoPrefix}${channelLabel}${t.gotoSuffix}`}
|
||||
</a>
|
||||
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{TEXT_H5_HINT}
|
||||
{t.h5Hint}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
@@ -512,13 +532,13 @@ export default function PaymentQRCode({
|
||||
dark ? 'border-slate-700' : 'border-gray-300',
|
||||
].join(' ')}
|
||||
>
|
||||
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{TEXT_SCAN_PAY}</p>
|
||||
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{t.scanPay}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{`请打开${channelLabel}扫一扫完成支付`}
|
||||
{`${t.openScanPrefix}${channelLabel}${t.openScanSuffix}`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
@@ -535,7 +555,7 @@ export default function PaymentQRCode({
|
||||
: 'border-gray-300 text-gray-600 hover:bg-gray-50',
|
||||
].join(' ')}
|
||||
>
|
||||
{TEXT_BACK}
|
||||
{t.back}
|
||||
</button>
|
||||
{!expired && token && (
|
||||
<button
|
||||
@@ -547,7 +567,7 @@ export default function PaymentQRCode({
|
||||
: 'border-red-300 text-red-600 hover:bg-red-50',
|
||||
].join(' ')}
|
||||
>
|
||||
{TEXT_CANCEL_ORDER}
|
||||
{t.cancelOrder}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
<p className={['mb-1 text-xs', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>{label}</p>
|
||||
{payload.map((p) => (
|
||||
<p key={p.dataKey}>
|
||||
{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}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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(' ')}
|
||||
>
|
||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
每日充值趋势
|
||||
{chartTitle}
|
||||
</h3>
|
||||
<p className={['text-center text-sm py-16', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>暂无数据</p>
|
||||
<p className={['text-center text-sm py-16', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>{emptyText}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -89,7 +101,7 @@ export default function DailyChart({ data, dark }: DailyChartProps) {
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
每日充值趋势
|
||||
{chartTitle}
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<LineChart data={data} margin={{ top: 5, right: 20, bottom: 5, left: 10 }}>
|
||||
@@ -109,7 +121,7 @@ export default function DailyChart({ data, dark }: DailyChartProps) {
|
||||
tickLine={false}
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip dark={dark} />} />
|
||||
<Tooltip content={<CustomTooltip dark={dark} currency={currency} amountLabel={amountLabel} countLabel={countLabel} />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="amount"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface Summary {
|
||||
today: { amount: number; orderCount: number; paidCount: number };
|
||||
total: { amount: number; orderCount: number; paidCount: number };
|
||||
@@ -10,16 +12,18 @@ interface Summary {
|
||||
interface DashboardStatsProps {
|
||||
summary: Summary;
|
||||
dark?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
export default function DashboardStats({ summary, dark }: DashboardStatsProps) {
|
||||
export default function DashboardStats({ summary, dark, locale = 'zh' }: DashboardStatsProps) {
|
||||
const currency = locale === 'en' ? '$' : '¥';
|
||||
const cards = [
|
||||
{ label: '今日充值', value: `¥${summary.today.amount.toLocaleString()}`, accent: true },
|
||||
{ label: '今日订单', value: `${summary.today.paidCount}/${summary.today.orderCount}` },
|
||||
{ label: '累计充值', value: `¥${summary.total.amount.toLocaleString()}`, accent: true },
|
||||
{ label: '累计订单', value: String(summary.total.paidCount) },
|
||||
{ label: '成功率', value: `${summary.successRate}%` },
|
||||
{ label: '平均充值', value: `¥${summary.avgAmount.toFixed(2)}` },
|
||||
{ label: locale === 'en' ? 'Today Recharge' : '今日充值', value: `${currency}${summary.today.amount.toLocaleString()}`, accent: true },
|
||||
{ label: locale === 'en' ? 'Today Orders' : '今日订单', value: `${summary.today.paidCount}/${summary.today.orderCount}` },
|
||||
{ label: locale === 'en' ? 'Total Recharge' : '累计充值', value: `${currency}${summary.total.amount.toLocaleString()}`, accent: true },
|
||||
{ label: locale === 'en' ? 'Paid Orders' : '累计订单', value: String(summary.total.paidCount) },
|
||||
{ label: locale === 'en' ? 'Success Rate' : '成功率', value: `${summary.successRate}%` },
|
||||
{ label: locale === 'en' ? 'Average Amount' : '平均充值', value: `${currency}${summary.avgAmount.toFixed(2)}` },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface LeaderboardEntry {
|
||||
userId: number;
|
||||
userName: string | null;
|
||||
@@ -11,6 +13,7 @@ interface LeaderboardEntry {
|
||||
interface LeaderboardProps {
|
||||
data: LeaderboardEntry[];
|
||||
dark?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
const RANK_STYLES: Record<number, { light: string; dark: string }> = {
|
||||
@@ -19,7 +22,13 @@ const RANK_STYLES: Record<number, { light: string; dark: string }> = {
|
||||
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(' ')}
|
||||
>
|
||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
充值排行榜 (Top 10)
|
||||
{title}
|
||||
</h3>
|
||||
<p className={['text-center text-sm py-8', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>暂无数据</p>
|
||||
<p className={['text-center text-sm py-8', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>{emptyText}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,16 +57,16 @@ export default function Leaderboard({ data, dark }: LeaderboardProps) {
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['px-6 pt-5 pb-2 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
充值排行榜 (Top 10)
|
||||
{title}
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className={`min-w-full divide-y ${dark ? 'divide-slate-700' : 'divide-gray-200'}`}>
|
||||
<thead className={dark ? 'bg-slate-800/50' : 'bg-gray-50'}>
|
||||
<tr>
|
||||
<th className={thCls}>#</th>
|
||||
<th className={thCls}>用户</th>
|
||||
<th className={thCls}>累计金额</th>
|
||||
<th className={thCls}>订单数</th>
|
||||
<th className={thCls}>{userLabel}</th>
|
||||
<th className={thCls}>{amountLabel}</th>
|
||||
<th className={thCls}>{orderCountLabel}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={`divide-y ${dark ? 'divide-slate-700/60' : 'divide-gray-200'}`}>
|
||||
@@ -88,7 +97,7 @@ export default function Leaderboard({ data, dark }: LeaderboardProps) {
|
||||
<td
|
||||
className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : 'text-slate-900'}`}
|
||||
>
|
||||
¥{entry.totalAmount.toLocaleString()}
|
||||
{currency}{entry.totalAmount.toLocaleString()}
|
||||
</td>
|
||||
<td className={tdMuted}>{entry.orderCount}</td>
|
||||
</tr>
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold">订单详情</h3>
|
||||
<h3 className="text-lg font-bold">{text.title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={dark ? 'text-slate-400 hover:text-slate-200' : 'text-gray-400 hover:text-gray-600'}
|
||||
@@ -115,7 +192,7 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps)
|
||||
|
||||
{/* Audit Logs */}
|
||||
<div className="mt-6">
|
||||
<h4 className={`mb-3 font-medium ${dark ? 'text-slate-100' : 'text-gray-900'}`}>审计日志</h4>
|
||||
<h4 className={`mb-3 font-medium ${dark ? 'text-slate-100' : 'text-gray-900'}`}>{text.auditLogs}</h4>
|
||||
<div className="space-y-2">
|
||||
{order.auditLogs.map((log) => (
|
||||
<div
|
||||
@@ -125,7 +202,7 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps)
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{log.action}</span>
|
||||
<span className={`text-xs ${dark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
{new Date(log.createdAt).toLocaleString('zh-CN')}
|
||||
{formatCreatedAt(log.createdAt, locale)}
|
||||
</span>
|
||||
</div>
|
||||
{log.detail && (
|
||||
@@ -135,13 +212,13 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps)
|
||||
)}
|
||||
{log.operator && (
|
||||
<div className={`mt-1 text-xs ${dark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
操作者: {log.operator}
|
||||
{text.operator}: {log.operator}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{order.auditLogs.length === 0 && (
|
||||
<div className={`text-center text-sm ${dark ? 'text-slate-500' : 'text-gray-400'}`}>暂无日志</div>
|
||||
<div className={`text-center text-sm ${dark ? 'text-slate-500' : 'text-gray-400'}`}>{text.emptyLogs}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<string, { label: string; light: string; dark: string }> = {
|
||||
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
|
||||
<table className={`min-w-full divide-y ${dark ? 'divide-slate-700' : 'divide-gray-200'}`}>
|
||||
<thead className={dark ? 'bg-slate-800/50' : 'bg-gray-50'}>
|
||||
<tr>
|
||||
<th className={thCls}>订单号</th>
|
||||
<th className={thCls}>用户名</th>
|
||||
<th className={thCls}>邮箱</th>
|
||||
<th className={thCls}>备注</th>
|
||||
<th className={thCls}>金额</th>
|
||||
<th className={thCls}>状态</th>
|
||||
<th className={thCls}>支付方式</th>
|
||||
<th className={thCls}>来源</th>
|
||||
<th className={thCls}>创建时间</th>
|
||||
<th className={thCls}>操作</th>
|
||||
<th className={thCls}>{text.orderId}</th>
|
||||
<th className={thCls}>{text.userName}</th>
|
||||
<th className={thCls}>{text.email}</th>
|
||||
<th className={thCls}>{text.notes}</th>
|
||||
<th className={thCls}>{text.amount}</th>
|
||||
<th className={thCls}>{text.status}</th>
|
||||
<th className={thCls}>{text.paymentMethod}</th>
|
||||
<th className={thCls}>{text.source}</th>
|
||||
<th className={thCls}>{text.createdAt}</th>
|
||||
<th className={thCls}>{text.actions}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={`divide-y ${dark ? 'divide-slate-700/60' : 'divide-gray-200 bg-white'}`}>
|
||||
{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 (
|
||||
<tr key={order.id} className={dark ? 'hover:bg-slate-700/40' : 'hover:bg-gray-50'}>
|
||||
@@ -85,7 +133,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
<td className={tdMuted}>{order.userEmail || '-'}</td>
|
||||
<td className={tdMuted}>{order.userNotes || '-'}</td>
|
||||
<td className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : ''}`}>
|
||||
¥{order.amount.toFixed(2)}
|
||||
{currency}{order.amount.toFixed(2)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<span
|
||||
@@ -96,7 +144,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
</td>
|
||||
<td className={tdMuted}>
|
||||
{(() => {
|
||||
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
|
||||
})()}
|
||||
</td>
|
||||
<td className={tdMuted}>{order.srcHost || '-'}</td>
|
||||
<td className={tdMuted}>{new Date(order.createdAt).toLocaleString('zh-CN')}</td>
|
||||
<td className={tdMuted}>{formatCreatedAt(order.createdAt, locale)}</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<div className="flex gap-1">
|
||||
{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}
|
||||
</button>
|
||||
)}
|
||||
{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}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -137,7 +185,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
</tbody>
|
||||
</table>
|
||||
{orders.length === 0 && (
|
||||
<div className={`py-12 text-center ${dark ? 'text-slate-500' : 'text-gray-500'}`}>暂无订单</div>
|
||||
<div className={`py-12 text-center ${dark ? 'text-slate-500' : 'text-gray-500'}`}>{text.empty}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -24,9 +30,9 @@ export default function PaymentMethodChart({ data, dark }: PaymentMethodChartPro
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
支付方式分布
|
||||
{title}
|
||||
</h3>
|
||||
<p className={['text-center text-sm py-8', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>暂无数据</p>
|
||||
<p className={['text-center text-sm py-8', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>{emptyText}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,18 +45,18 @@ export default function PaymentMethodChart({ data, dark }: PaymentMethodChartPro
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
支付方式分布
|
||||
{title}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{data.map((method) => {
|
||||
const meta = getPaymentMeta(method.paymentType);
|
||||
const label = getPaymentTypeLabel(method.paymentType);
|
||||
const label = getPaymentTypeLabel(method.paymentType, locale);
|
||||
return (
|
||||
<div key={method.paymentType}>
|
||||
<div className="mb-1.5 flex items-center justify-between text-sm">
|
||||
<span className={dark ? 'text-slate-300' : 'text-slate-700'}>{label}</span>
|
||||
<span className={dark ? 'text-slate-400' : 'text-slate-500'}>
|
||||
¥{method.amount.toLocaleString()} · {method.percentage}%
|
||||
{currency}{method.amount.toLocaleString()} · {method.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface RefundDialogProps {
|
||||
orderId: string;
|
||||
@@ -10,6 +11,7 @@ interface RefundDialogProps {
|
||||
warning?: string;
|
||||
requireForce?: boolean;
|
||||
dark?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
export default function RefundDialog({
|
||||
@@ -20,11 +22,38 @@ export default function RefundDialog({
|
||||
warning,
|
||||
requireForce,
|
||||
dark = false,
|
||||
locale = 'zh',
|
||||
}: RefundDialogProps) {
|
||||
const [reason, setReason] = useState('');
|
||||
const [force, setForce] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const currency = locale === 'en' ? '$' : '¥';
|
||||
const text =
|
||||
locale === 'en'
|
||||
? {
|
||||
title: 'Confirm Refund',
|
||||
orderId: 'Order ID',
|
||||
amount: 'Refund Amount',
|
||||
reason: 'Refund Reason',
|
||||
reasonPlaceholder: 'Enter refund reason (optional)',
|
||||
forceRefund: 'Force refund (balance may become negative)',
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Confirm Refund',
|
||||
processing: 'Processing...',
|
||||
}
|
||||
: {
|
||||
title: '确认退款',
|
||||
orderId: '订单号',
|
||||
amount: '退款金额',
|
||||
reason: '退款原因',
|
||||
reasonPlaceholder: '请输入退款原因(可选)',
|
||||
forceRefund: '强制退款(余额可能扣为负数)',
|
||||
cancel: '取消',
|
||||
confirm: '确认退款',
|
||||
processing: '处理中...',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
@@ -51,17 +80,17 @@ export default function RefundDialog({
|
||||
].join(' ')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className={['text-lg font-bold', dark ? 'text-slate-100' : 'text-gray-900'].join(' ')}>确认退款</h3>
|
||||
<h3 className={['text-lg font-bold', dark ? 'text-slate-100' : 'text-gray-900'].join(' ')}>{text.title}</h3>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className={['rounded-lg p-3', dark ? 'bg-slate-800' : 'bg-gray-50'].join(' ')}>
|
||||
<div className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>订单号</div>
|
||||
<div className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{text.orderId}</div>
|
||||
<div className="text-sm font-mono">{orderId}</div>
|
||||
</div>
|
||||
|
||||
<div className={['rounded-lg p-3', dark ? 'bg-slate-800' : 'bg-gray-50'].join(' ')}>
|
||||
<div className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>退款金额</div>
|
||||
<div className="text-lg font-bold text-red-600">¥{amount.toFixed(2)}</div>
|
||||
<div className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{text.amount}</div>
|
||||
<div className="text-lg font-bold text-red-600">{currency}{amount.toFixed(2)}</div>
|
||||
</div>
|
||||
|
||||
{warning && (
|
||||
@@ -77,13 +106,13 @@ export default function RefundDialog({
|
||||
|
||||
<div>
|
||||
<label className={['mb-1 block text-sm font-medium', dark ? 'text-slate-300' : 'text-gray-700'].join(' ')}>
|
||||
退款原因
|
||||
{text.reason}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={(e) => 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(' ')}
|
||||
/>
|
||||
<span className="text-red-600">强制退款(余额可能扣为负数)</span>
|
||||
<span className="text-red-600">{text.forceRefund}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
@@ -114,14 +143,14 @@ export default function RefundDialog({
|
||||
: 'border-gray-300 text-gray-600 hover:bg-gray-50',
|
||||
].join(' ')}
|
||||
>
|
||||
取消
|
||||
{text.cancel}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={loading || (requireForce && !force)}
|
||||
className="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-gray-300"
|
||||
>
|
||||
{loading ? '处理中...' : '确认退款'}
|
||||
{loading ? text.processing : text.confirm}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<boolean> {
|
||||
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 });
|
||||
}
|
||||
|
||||
20
src/lib/locale.ts
Normal file
20
src/lib/locale.ts
Normal file
@@ -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<T>(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;
|
||||
}
|
||||
@@ -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<CreateOrderResult> {
|
||||
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<CreateOrderR
|
||||
const alreadyPaid = Number(dailyAgg._sum.amount ?? 0);
|
||||
if (alreadyPaid + input.amount > 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<CreateOrderR
|
||||
throw new OrderError(
|
||||
'METHOD_DAILY_LIMIT_EXCEEDED',
|
||||
remaining > 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<CreateOrderR
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Payment gateway error (${input.paymentType}):`, error);
|
||||
if (msg.includes('environment variables') || msg.includes('not configured') || msg.includes('not found')) {
|
||||
throw new OrderError('PAYMENT_GATEWAY_ERROR', `支付渠道(${input.paymentType})暂未配置,请联系管理员`, 503);
|
||||
throw new OrderError(
|
||||
'PAYMENT_GATEWAY_ERROR',
|
||||
message(locale, `支付渠道(${input.paymentType})暂未配置,请联系管理员`, `Payment method (${input.paymentType}) is not configured. Please contact the administrator`),
|
||||
503,
|
||||
);
|
||||
}
|
||||
throw new OrderError('PAYMENT_GATEWAY_ERROR', '支付渠道暂时不可用,请稍后重试或更换支付方式', 502);
|
||||
throw new OrderError(
|
||||
'PAYMENT_GATEWAY_ERROR',
|
||||
message(locale, '支付渠道暂时不可用,请稍后重试或更换支付方式', 'Payment method is temporarily unavailable. Please try again later or use another payment method'),
|
||||
502,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,15 +303,16 @@ export async function cancelOrderCore(options: {
|
||||
return 'cancelled';
|
||||
}
|
||||
|
||||
export async function cancelOrder(orderId: string, userId: number): Promise<CancelOutcome> {
|
||||
export async function cancelOrder(orderId: string, userId: number, locale: Locale = 'zh'): Promise<CancelOutcome> {
|
||||
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<Canc
|
||||
paymentType: order.paymentType,
|
||||
finalStatus: ORDER_STATUS.CANCELLED,
|
||||
operator: `user:${userId}`,
|
||||
auditDetail: 'User cancelled order',
|
||||
auditDetail: message(locale, '用户取消订单', 'User cancelled order'),
|
||||
});
|
||||
}
|
||||
|
||||
export async function adminCancelOrder(orderId: string): Promise<CancelOutcome> {
|
||||
export async function adminCancelOrder(orderId: string, locale: Locale = 'zh'): Promise<CancelOutcome> {
|
||||
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<CancelOutcome>
|
||||
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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
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<void> {
|
||||
export async function retryRecharge(orderId: string, locale: Locale = 'zh'): Promise<void> {
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
select: {
|
||||
@@ -567,10 +604,10 @@ export async function retryRecharge(orderId: string): Promise<void> {
|
||||
});
|
||||
|
||||
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<void> {
|
||||
});
|
||||
|
||||
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<RefundResult> {
|
||||
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<RefundResult> {
|
||||
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<RefundResult> {
|
||||
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 {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
[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<Locale, Record<string, string>> = {
|
||||
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<Locale, { key: OrderStatusFilter; label: string }[]> = {
|
||||
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<string, PaymentTypeMeta> = {
|
||||
},
|
||||
};
|
||||
|
||||
/** 获取支付方式的显示名称(如 '支付宝(易支付)'),用于管理后台等需要区分的场景 */
|
||||
export function getPaymentTypeLabel(type: string): string {
|
||||
const PAYMENT_TEXT_MAP: Record<Locale, Record<string, { label: string; provider: string; sublabel?: string }>> = {
|
||||
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<string, string>): void {
|
||||
for (const [type, sublabel] of Object.entries(overrides)) {
|
||||
if (PAYMENT_TYPE_META[type]) {
|
||||
|
||||
@@ -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 为普通对象 */
|
||||
|
||||
Reference in New Issue
Block a user