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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user