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:
erio
2026-03-09 18:33:57 +08:00
parent 5cebe85079
commit 2492031e13
35 changed files with 1997 additions and 579 deletions

View File

@@ -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>