feat(usercenter/i18n): 6 语(加 日/韩/俄/西)+ 默认英文

- lib/i18n.ts: Lang/Entry 扩 6 语;STRINGS 130 条补 ja/ko/ru/es;makeT 回退英文
- lib/api/errors.ts: ERROR_TEXT 14 条补 4 语;bilingual 改为 code 本地字典优先
  → 英文字典 → 后端 message → 通用兜底
- lib/theme.tsx: 默认语种 zh→en;localStorage 白名单扩 6 语;setAttribute('lang') 直用 lang
- components/shared.tsx: LangSeg 段控 → 原生 select 6 语下拉
- app/layout.tsx: 默认壳 html lang zh→en;title/desc 改英文
- next build 通过(5 页静态);tsc 干净;无红线词

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
wangjia
2026-07-07 13:31:38 +08:00
parent f0d574bc44
commit 1b49e9a94b
5 changed files with 161 additions and 160 deletions
+3 -3
View File
@@ -3,8 +3,8 @@ import './globals.css';
import { UIProvider } from '../lib/theme';
export const metadata: Metadata = {
title: '穿山甲 · 用户中心',
description: '管理订阅、兑换激活码、查看用量、邀请返利与账户安全设置。',
title: 'Pangolin · Account',
description: 'Manage your subscription, redeem codes, view usage, referrals and account security.',
};
export const viewport: Viewport = {
@@ -14,7 +14,7 @@ export const viewport: Viewport = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh" data-theme="light" suppressHydrationWarning>
<html lang="en" data-theme="light" suppressHydrationWarning>
<head>
{/* 设计令牌单一真相源,原样链入(SRI 由构建期注入) */}
{/* eslint-disable-next-line @next/next/no-css-tags */}
+24 -21
View File
@@ -23,28 +23,31 @@ export const input: React.CSSProperties = {
};
export function LangSeg({ lang, setLang }: { lang: Lang; setLang: (l: Lang) => void }) {
// 6 语言用原生下拉(段控横排会挤)。默认 en。
const langs: [Lang, string][] = [
['en', 'English'], ['zh', '中文'], ['ja', '日本語'],
['ko', '한국어'], ['ru', 'Русский'], ['es', 'Español'],
];
return (
<div style={{ display: 'flex', background: 'var(--bg-subtle)', borderRadius: 999, padding: 3, gap: 2 }}>
{([['zh', '中文'], ['en', 'EN']] as [Lang, string][]).map(([v, l]) => (
<button
key={v}
onClick={() => setLang(v)}
aria-pressed={lang === v}
style={{
border: 'none',
cursor: 'pointer',
borderRadius: 999,
padding: '5px 12px',
fontFamily: 'var(--font-sans)',
fontSize: 12.5,
fontWeight: 700,
background: lang === v ? 'var(--accent)' : 'transparent',
color: lang === v ? 'var(--fg-on-accent)' : 'var(--fg3)',
}}
>
{l}
</button>
<select
value={lang}
onChange={(e) => setLang(e.target.value as Lang)}
aria-label="Language"
style={{
border: '1px solid var(--border)',
cursor: 'pointer',
borderRadius: 999,
padding: '5px 9px',
fontFamily: 'var(--font-sans)',
fontSize: 12.5,
fontWeight: 700,
background: 'var(--bg-subtle)',
color: 'var(--fg2)',
}}
>
{langs.map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</div>
</select>
);
}
+20 -22
View File
@@ -1,35 +1,33 @@
// errors.ts — 错误码 → 语文案映射表(阶段 B 落地)
// errors.ts — 错误码 → 语文案映射表(阶段 B 落地)
import { ApiError } from './types';
import type { Lang } from '../i18n';
/**
* 后端统一返回 {code, message_zh, message_en};前端优先用后端文案,
* 兜底用本表(后端文案缺失/网络层错误时)。codes 与 #1 openapi 对齐。
* 后端统一返回 {code, message_zh, message_en};前端优先用本表(按 code 命中当前语言),
* 再回退后端文案(en 优先),最后 unknown。codes 与 #1 openapi 对齐。
*/
export const ERROR_TEXT: Record<string, { zh: string; en: string }> = {
invalid_credentials: { zh: '邮箱或密码错误', en: 'Wrong email or password' },
rate_limited: { zh: '操作过于频繁,请稍后再试', en: 'Too many requests, slow down' },
account_locked: { zh: '失败次数过多,账户已临时锁定', en: 'Too many attempts — account temporarily locked' },
totp_required: { zh: '需要双重认证动态码', en: 'Two-factor code required' },
totp_invalid: { zh: '动态码不正确,请重试', en: 'Invalid code, try again' },
code_invalid: { zh: '激活码无效', en: 'Invalid activation code' },
code_used: { zh: '激活码已被使用', en: 'Activation code already used' },
code_expired: { zh: '激活码已过期', en: 'Activation code expired' },
device_limit: { zh: '设备数量已达上限', en: 'Device limit reached' },
device_not_found: { zh: '设备不存在', en: 'Device not found' },
unauthorized: { zh: '登录已失效,请重新登录', en: 'Session expired, please log in again' },
'auth.ticket_invalid': { zh: '登录票据无效或已过期,请重新从 App 打开', en: 'Login ticket is invalid or expired, please reopen from the app' },
network: { zh: '网络异常,请稍后重试', en: 'Network error, please retry' },
unknown: { zh: '操作失败,请稍后重试', en: 'Something went wrong, please retry' },
export const ERROR_TEXT: Record<string, { zh: string; en: string; ja: string; ko: string; ru: string; es: string }> = {
invalid_credentials: { zh: '邮箱或密码错误', en: 'Wrong email or password', ja: 'メールアドレスまたはパスワードが違います', ko: '이메일 또는 비밀번호가 올바르지 않습니다', ru: 'Неверная почта или пароль', es: 'Correo o contraseña incorrectos' },
rate_limited: { zh: '操作过于频繁,请稍后再试', en: 'Too many requests, slow down', ja: 'リクエストが多すぎます。少し時間をおいてください', ko: '요청이 너무 많습니다. 잠시 후 다시 시도하세요', ru: 'Слишком много запросов, помедленнее', es: 'Demasiadas solicitudes, ve más despacio' },
account_locked: { zh: '失败次数过多,账户已临时锁定', en: 'Too many attempts — account temporarily locked', ja: '試行回数が多すぎます — アカウントを一時的にロックしました', ko: '시도 횟수가 너무 많습니다 — 계정이 일시적으로 잠겼습니다', ru: 'Слишком много попыток — аккаунт временно заблокирован', es: 'Demasiados intentos: la cuenta está bloqueada temporalmente' },
totp_required: { zh: '需要双重认证动态码', en: 'Two-factor code required', ja: '二要素認証コードが必要です', ko: '2단계 인증 코드가 필요합니다', ru: 'Требуется код двухфакторной аутентификации', es: 'Se requiere el código de verificación en dos pasos' },
totp_invalid: { zh: '动态码不正确,请重试', en: 'Invalid code, try again', ja: 'コードが正しくありません。もう一度お試しください', ko: '코드가 올바르지 않습니다. 다시 시도하세요', ru: 'Неверный код, попробуйте снова', es: 'Código no válido, inténtalo de nuevo' },
code_invalid: { zh: '激活码无效', en: 'Invalid activation code', ja: 'アクティベーションコードが無効です', ko: '인증 코드가 유효하지 않습니다', ru: 'Недействительный код активации', es: 'Código de activación no válido' },
code_used: { zh: '激活码已被使用', en: 'Activation code already used', ja: 'アクティベーションコードは使用済みです', ko: '이미 사용된 인증 코드입니다', ru: 'Код активации уже использован', es: 'El código de activación ya se ha usado' },
code_expired: { zh: '激活码已过期', en: 'Activation code expired', ja: 'アクティベーションコードの有効期限が切れています', ko: '인증 코드가 만료되었습니다', ru: 'Срок действия кода активации истёк', es: 'El código de activación ha caducado' },
device_limit: { zh: '设备数量已达上限', en: 'Device limit reached', ja: 'デバイス数が上限に達しました', ko: '기기 수가 한도에 도달했습니다', ru: 'Достигнут лимит устройств', es: 'Se alcanzó el límite de dispositivos' },
device_not_found: { zh: '设备不存在', en: 'Device not found', ja: 'デバイスが見つかりません', ko: '기기를 찾을 수 없습니다', ru: 'Устройство не найдено', es: 'Dispositivo no encontrado' },
unauthorized: { zh: '登录已失效,请重新登录', en: 'Session expired, please log in again', ja: 'セッションの有効期限が切れました。再度ログインしてください', ko: '세션이 만료되었습니다. 다시 로그인하세요', ru: 'Сеанс истёк, войдите снова', es: 'La sesión ha caducado, inicia sesión de nuevo' },
'auth.ticket_invalid': { zh: '登录票据无效或已过期,请重新从 App 打开', en: 'Login ticket is invalid or expired, please reopen from the app', ja: 'ログインチケットが無効か期限切れです。アプリから開き直してください', ko: '로그인 티켓이 유효하지 않거나 만료되었습니다. 앱에서 다시 여세요', ru: 'Токен входа недействителен или истёк, откройте снова из приложения', es: 'El ticket de inicio de sesión no es válido o ha caducado, vuelve a abrirlo desde la app' },
network: { zh: '网络异常,请稍后重试', en: 'Network error, please retry', ja: 'ネットワークエラーです。後でもう一度お試しください', ko: '네트워크 오류입니다. 잠시 후 다시 시도하세요', ru: 'Ошибка сети, повторите попытку', es: 'Error de red, vuelve a intentarlo' },
unknown: { zh: '操作失败,请稍后重试', en: 'Something went wrong, please retry', ja: '問題が発生しました。後でもう一度お試しください', ko: '문제가 발생했습니다. 잠시 후 다시 시도하세요', ru: 'Что-то пошло не так, повторите попытку', es: 'Algo salió mal, vuelve a intentarlo' },
};
/** 把任意错误转成当前语言文案(后端文案优先,再查表,最后 unknown) */
/** 把任意错误转成当前语言文案(按 code 查表→后端文案 en 优先→通用兜底) */
export function bilingual(err: unknown, lang: Lang): string {
if (err instanceof ApiError) {
const fromServer = lang === 'zh' ? err.message_zh : err.message_en;
if (fromServer) return fromServer;
const m = ERROR_TEXT[err.code];
if (m) return m[lang];
return m?.[lang] ?? m?.en ?? err.message_en ?? err.message_zh ?? ERROR_TEXT.unknown[lang];
}
return ERROR_TEXT.unknown[lang];
}
+111 -111
View File
@@ -1,140 +1,140 @@
// i18n.ts — 穿山甲 Web 用户中心 · 语字串(单显,绝不并排)
// i18n.ts — 穿山甲 Web 用户中心 · 语字串(单显,绝不并排)
// 直接承袭 design/ui_kits/usercenter/ucparts.jsx 的 UCSTRINGS,并补充设置/设备/TOTP 键。
export type Lang = 'zh' | 'en';
export type Lang = 'zh' | 'en' | 'ja' | 'ko' | 'ru' | 'es';
type Entry = { zh: string; en: string };
type Entry = { zh: string; en: string; ja: string; ko: string; ru: string; es: string };
export const STRINGS: Record<string, Entry> = {
navOverview: { zh: '概览', en: 'Overview' },
navSub: { zh: '订阅', en: 'Subscription' },
navRedeem: { zh: '兑换 & 购买', en: 'Redeem & buy' },
navInvite: { zh: '邀请返利', en: 'Referral' },
navSettings: { zh: '设置', en: 'Settings' },
signOut: { zh: '退出', en: 'Sign out' },
navOverview: { zh: '概览', en: 'Overview', ja: '概要', ko: '개요', ru: 'Обзор', es: 'Resumen' },
navSub: { zh: '订阅', en: 'Subscription', ja: 'サブスクリプション', ko: '구독', ru: 'Подписка', es: 'Suscripción' },
navRedeem: { zh: '兑换 & 购买', en: 'Redeem & buy', ja: '引き換え & 購入', ko: '코드 등록 & 구매', ru: 'Активация и покупка', es: 'Canjear y comprar' },
navInvite: { zh: '邀请返利', en: 'Referral', ja: '紹介', ko: '추천', ru: 'Рефералы', es: 'Referidos' },
navSettings: { zh: '设置', en: 'Settings', ja: '設定', ko: '설정', ru: 'Настройки', es: 'Ajustes' },
signOut: { zh: '退出', en: 'Sign out', ja: 'ログアウト', ko: '로그아웃', ru: 'Выйти', es: 'Cerrar sesión' },
/* login */
loginTitle: { zh: '登录用户中心', en: 'Log in to your account' },
loginSub: { zh: '管理订阅、兑换激活码、查看用量', en: 'Manage subscription, redeem codes, track usage' },
emailLabel: { zh: '邮箱', en: 'Email' },
emailPh: { zh: '你的邮箱地址', en: 'your@email.com' },
pwLabel: { zh: '密码', en: 'Password' },
pwPh: { zh: '输入密码', en: 'Enter password' },
doLogin: { zh: '登录', en: 'Log in' },
forgotPw: { zh: '忘记密码?', en: 'Forgot password?' },
noAccount: { zh: '没有账户?在 App 内注册', en: 'No account? Sign up in the app' },
loginFailed: { zh: '邮箱或密码错误', en: 'Wrong email or password' },
loginLocked: { zh: '失败次数过多,账户已临时锁定', en: 'Too many attempts — account temporarily locked' },
lockedCountdown: { zh: '请于 {s} 秒后重试', en: 'Try again in {s}s' },
loginTitle: { zh: '登录用户中心', en: 'Log in to your account', ja: 'アカウントにログイン', ko: '계정에 로그인', ru: 'Вход в аккаунт', es: 'Inicia sesión en tu cuenta' },
loginSub: { zh: '管理订阅、兑换激活码、查看用量', en: 'Manage subscription, redeem codes, track usage', ja: 'サブスクの管理、コードの引き換え、使用状況の確認', ko: '구독 관리, 코드 등록, 사용량 확인', ru: 'Управление подпиской, активация кодов, статистика использования', es: 'Gestiona tu suscripción, canjea códigos y consulta el uso' },
emailLabel: { zh: '邮箱', en: 'Email', ja: 'メールアドレス', ko: '이메일', ru: 'Эл. почта', es: 'Correo electrónico' },
emailPh: { zh: '你的邮箱地址', en: 'your@email.com', ja: 'your@email.com', ko: 'your@email.com', ru: 'your@email.com', es: 'your@email.com' },
pwLabel: { zh: '密码', en: 'Password', ja: 'パスワード', ko: '비밀번호', ru: 'Пароль', es: 'Contraseña' },
pwPh: { zh: '输入密码', en: 'Enter password', ja: 'パスワードを入力', ko: '비밀번호 입력', ru: 'Введите пароль', es: 'Introduce la contraseña' },
doLogin: { zh: '登录', en: 'Log in', ja: 'ログイン', ko: '로그인', ru: 'Войти', es: 'Iniciar sesión' },
forgotPw: { zh: '忘记密码?', en: 'Forgot password?', ja: 'パスワードをお忘れですか?', ko: '비밀번호를 잊으셨나요?', ru: 'Забыли пароль?', es: '¿Olvidaste tu contraseña?' },
noAccount: { zh: '没有账户?在 App 内注册', en: 'No account? Sign up in the app', ja: 'アカウントがない場合はアプリで登録', ko: '계정이 없나요? 앱에서 가입하세요', ru: 'Нет аккаунта? Зарегистрируйтесь в приложении', es: '¿No tienes cuenta? Regístrate en la app' },
loginFailed: { zh: '邮箱或密码错误', en: 'Wrong email or password', ja: 'メールアドレスまたはパスワードが違います', ko: '이메일 또는 비밀번호가 올바르지 않습니다', ru: 'Неверная почта или пароль', es: 'Correo o contraseña incorrectos' },
loginLocked: { zh: '失败次数过多,账户已临时锁定', en: 'Too many attempts — account temporarily locked', ja: '試行回数が多すぎます — アカウントを一時的にロックしました', ko: '시도 횟수가 너무 많습니다 — 계정이 일시적으로 잠겼습니다', ru: 'Слишком много попыток — аккаунт временно заблокирован', es: 'Demasiados intentos: la cuenta está bloqueada temporalmente' },
lockedCountdown: { zh: '请于 {s} 秒后重试', en: 'Try again in {s}s', ja: '{s} 秒後に再試行してください', ko: '{s}초 후에 다시 시도하세요', ru: 'Повторите через {s} с', es: 'Vuelve a intentarlo en {s}s' },
/* sso(App→网页免登录落地页) */
ssoTitle: { zh: '正在登录…', en: 'Signing in…' },
ssoMsg: { zh: '正在验证来自 App 的登录票据', en: 'Verifying the sign-in ticket from the app' },
ssoFailTitle: { zh: '登录跳转失败', en: 'Sign-in redirect failed' },
ssoFailHint: { zh: '即将跳转到登录页,请手动登录', en: 'Redirecting to the login page, please sign in manually' },
ssoTitle: { zh: '正在登录…', en: 'Signing in…', ja: 'ログイン中…', ko: '로그인 중…', ru: 'Вход…', es: 'Iniciando sesión…' },
ssoMsg: { zh: '正在验证来自 App 的登录票据', en: 'Verifying the sign-in ticket from the app', ja: 'アプリからのログインチケットを確認しています', ko: '앱에서 받은 로그인 티켓을 확인하는 중', ru: 'Проверяем токен входа из приложения', es: 'Verificando el ticket de inicio de sesión de la app' },
ssoFailTitle: { zh: '登录跳转失败', en: 'Sign-in redirect failed', ja: 'ログインのリダイレクトに失敗しました', ko: '로그인 리디렉션에 실패했습니다', ru: 'Не удалось выполнить переход для входа', es: 'Error en la redirección de inicio de sesión' },
ssoFailHint: { zh: '即将跳转到登录页,请手动登录', en: 'Redirecting to the login page, please sign in manually', ja: 'ログインページに移動します。手動でログインしてください', ko: '로그인 페이지로 이동합니다. 직접 로그인해 주세요', ru: 'Перенаправляем на страницу входа, войдите вручную', es: 'Redirigiendo a la página de inicio de sesión, inicia sesión manualmente' },
/* overview */
greeting: { zh: '欢迎回来', en: 'Welcome back' },
curPlan: { zh: '当前套餐', en: 'Current plan' },
freePlan: { zh: '免费版', en: 'Free' },
proMember: { zh: 'PRO 会员', en: 'PRO member' },
expires: { zh: '有效期至', en: 'Expires' },
renew: { zh: '续费 / 升级', en: 'Renew / Upgrade' },
quotaToday: { zh: '今日剩余时长', en: 'Time left today' },
dataToday: { zh: '今日已用流量', en: 'Data used today' },
devices: { zh: '在线设备', en: 'Devices online' },
quotaFree: { zh: '免费版 · 每日 10 分钟', en: 'Free · 10 min/day' },
usageTitle: { zh: '近 7 日流量 (GB)', en: 'Last 7 days (GB)' },
quickSub: { zh: '快速操作', en: 'Quick actions' },
qaSub: { zh: '获取订阅链接', en: 'Get subscription' },
qaRedeem: { zh: '兑换激活码', en: 'Redeem a code' },
qaApp: { zh: '下载 App', en: 'Download apps' },
greeting: { zh: '欢迎回来', en: 'Welcome back', ja: 'おかえりなさい', ko: '다시 오신 것을 환영합니다', ru: 'С возвращением', es: 'Bienvenido de nuevo' },
curPlan: { zh: '当前套餐', en: 'Current plan', ja: '現在のプラン', ko: '현재 요금제', ru: 'Текущий тариф', es: 'Plan actual' },
freePlan: { zh: '免费版', en: 'Free', ja: '無料版', ko: '무료', ru: 'Бесплатный', es: 'Gratis' },
proMember: { zh: 'PRO 会员', en: 'PRO member', ja: 'PRO会員', ko: 'PRO 회원', ru: 'PRO-участник', es: 'Miembro PRO' },
expires: { zh: '有效期至', en: 'Expires', ja: '有効期限', ko: '만료일', ru: 'Действует до', es: 'Caduca' },
renew: { zh: '续费 / 升级', en: 'Renew / Upgrade', ja: '更新 / アップグレード', ko: '갱신 / 업그레이드', ru: 'Продлить / Улучшить', es: 'Renovar / Mejorar' },
quotaToday: { zh: '今日剩余时长', en: 'Time left today', ja: '本日の残り時間', ko: '오늘 남은 시간', ru: 'Осталось времени сегодня', es: 'Tiempo restante hoy' },
dataToday: { zh: '今日已用流量', en: 'Data used today', ja: '本日の使用データ量', ko: '오늘 사용한 데이터', ru: 'Трафик за сегодня', es: 'Datos usados hoy' },
devices: { zh: '在线设备', en: 'Devices online', ja: 'オンラインのデバイス', ko: '온라인 기기', ru: 'Устройств онлайн', es: 'Dispositivos en línea' },
quotaFree: { zh: '免费版 · 每日 10 分钟', en: 'Free · 10 min/day', ja: '無料版 · 1日10分', ko: '무료 · 하루 10분', ru: 'Бесплатно · 10 мин/день', es: 'Gratis · 10 min/día' },
usageTitle: { zh: '近 7 日流量 (GB)', en: 'Last 7 days (GB)', ja: '過去7日間 (GB)', ko: '최근 7일 (GB)', ru: 'За 7 дней (ГБ)', es: 'Últimos 7 días (GB)' },
quickSub: { zh: '快速操作', en: 'Quick actions', ja: 'クイック操作', ko: '빠른 작업', ru: 'Быстрые действия', es: 'Acciones rápidas' },
qaSub: { zh: '获取订阅链接', en: 'Get subscription', ja: 'サブスクリプションを取得', ko: '구독 가져오기', ru: 'Получить подписку', es: 'Obtener suscripción' },
qaRedeem: { zh: '兑换激活码', en: 'Redeem a code', ja: 'コードを引き換える', ko: '코드 등록', ru: 'Активировать код', es: 'Canjear un código' },
qaApp: { zh: '下载 App', en: 'Download apps', ja: 'アプリをダウンロード', ko: '앱 다운로드', ru: 'Скачать приложения', es: 'Descargar apps' },
/* subscription */
subTitle: { zh: '我的订阅', en: 'My subscription' },
subDesc: { zh: '订阅链接是你的专属凭证,泄露后他人可使用你的额度。请勿分享。', en: 'Your subscription link is a private credential. Never share it.' },
subCopy: { zh: '复制链接', en: 'Copy link' },
subCopied: { zh: '已复制', en: 'Copied' },
subReset: { zh: '重置链接', en: 'Reset link' },
subResetSub: { zh: '旧链接立即失效,所有设备需重新导入', en: 'Old link stops working; re-import on all devices' },
subResetOk: { zh: '已重置,请重新导入', en: 'Reset — re-import on your devices' },
scanTitle: { zh: '扫码导入', en: 'Scan to import' },
scanSub: { zh: '用穿山甲 App 或三方客户端扫码', en: 'Scan with the Pangolin app or a 3rd-party client' },
importTitle: { zh: '一键导入三方客户端', en: 'One-tap import' },
importSub: { zh: '已安装对应客户端时,点击即自动导入订阅。', en: 'If the client is installed, tapping imports the subscription automatically.' },
fmtNote: { zh: '同一链接同时兼容 sing-box / Clash.Meta / v2ray 格式。', en: 'One link serves sing-box / Clash.Meta / v2ray formats.' },
subTitle: { zh: '我的订阅', en: 'My subscription', ja: 'マイサブスクリプション', ko: '내 구독', ru: 'Моя подписка', es: 'Mi suscripción' },
subDesc: { zh: '订阅链接是你的专属凭证,泄露后他人可使用你的额度。请勿分享。', en: 'Your subscription link is a private credential. Never share it.', ja: 'サブスクリプションリンクはあなた専用の認証情報です。他人と共有しないでください。', ko: '구독 링크는 개인 인증 정보입니다. 절대 공유하지 마세요.', ru: 'Ссылка на подписку — ваш личный ключ доступа. Никому не передавайте её.', es: 'Tu enlace de suscripción es una credencial privada. Nunca lo compartas.' },
subCopy: { zh: '复制链接', en: 'Copy link', ja: 'リンクをコピー', ko: '링크 복사', ru: 'Копировать ссылку', es: 'Copiar enlace' },
subCopied: { zh: '已复制', en: 'Copied', ja: 'コピーしました', ko: '복사됨', ru: 'Скопировано', es: 'Copiado' },
subReset: { zh: '重置链接', en: 'Reset link', ja: 'リンクをリセット', ko: '링크 재설정', ru: 'Сбросить ссылку', es: 'Restablecer enlace' },
subResetSub: { zh: '旧链接立即失效,所有设备需重新导入', en: 'Old link stops working; re-import on all devices', ja: '古いリンクは即座に無効になり、すべてのデバイスで再インポートが必要です', ko: '기존 링크는 즉시 만료되며 모든 기기에서 다시 가져와야 합니다', ru: 'Старая ссылка перестанет работать; переимпортируйте на всех устройствах', es: 'El enlace anterior deja de funcionar; vuelve a importarlo en todos los dispositivos' },
subResetOk: { zh: '已重置,请重新导入', en: 'Reset — re-import on your devices', ja: 'リセットしました。デバイスで再インポートしてください', ko: '재설정됨 — 기기에서 다시 가져오세요', ru: 'Сброшено — переимпортируйте на устройствах', es: 'Restablecido: vuelve a importarlo en tus dispositivos' },
scanTitle: { zh: '扫码导入', en: 'Scan to import', ja: 'スキャンしてインポート', ko: '스캔하여 가져오기', ru: 'Сканировать для импорта', es: 'Escanear para importar' },
scanSub: { zh: '用穿山甲 App 或三方客户端扫码', en: 'Scan with the Pangolin app or a 3rd-party client', ja: 'Pangolinアプリまたはサードパーティクライアントでスキャン', ko: 'Pangolin 앱 또는 서드파티 클라이언트로 스캔하세요', ru: 'Сканируйте в приложении Pangolin или стороннем клиенте', es: 'Escanea con la app Pangolin o un cliente de terceros' },
importTitle: { zh: '一键导入三方客户端', en: 'One-tap import', ja: 'ワンタップインポート', ko: '원탭 가져오기', ru: 'Импорт в одно касание', es: 'Importar con un toque' },
importSub: { zh: '已安装对应客户端时,点击即自动导入订阅。', en: 'If the client is installed, tapping imports the subscription automatically.', ja: '対応クライアントがインストール済みなら、タップするだけでサブスクリプションが自動インポートされます。', ko: '해당 클라이언트가 설치되어 있으면 탭 한 번으로 구독이 자동 가져오기 됩니다.', ru: 'Если клиент установлен, нажатие автоматически импортирует подписку.', es: 'Si el cliente está instalado, al tocar se importa la suscripción automáticamente.' },
fmtNote: { zh: '同一链接同时兼容 sing-box / Clash.Meta / v2ray 格式。', en: 'One link serves sing-box / Clash.Meta / v2ray formats.', ja: '1つのリンクで sing-box / Clash.Meta / v2ray 形式に対応します。', ko: '하나의 링크로 sing-box / Clash.Meta / v2ray 형식을 모두 지원합니다.', ru: 'Одна ссылка работает с форматами sing-box / Clash.Meta / v2ray.', es: 'Un mismo enlace sirve para los formatos sing-box / Clash.Meta / v2ray.' },
/* redeem */
redeemTitle: { zh: '兑换激活码', en: 'Redeem a code' },
redeemPh: { zh: '输入激活码', en: 'Enter activation code' },
redeemBtn: { zh: '激活', en: 'Redeem' },
redeemOk: { zh: '激活成功 · 套餐已到账', en: 'Activated — plan applied' },
buyTitle: { zh: '购买渠道', en: 'Where to buy' },
buySub: { zh: '本站不直接收款。通过以下渠道购买激活码,回到本页兑换即可:', en: 'We never take payment on this site. Buy a code via a channel below, then redeem here:' },
chStore: { zh: '自助发卡商店', en: 'Self-serve store' },
chStoreSub: { zh: '支付宝 / 微信 · 自动发码', en: 'Alipay / WeChat · instant code' },
chUsdtSub: { zh: '链上转账 · 最隐私 · 自动发码', en: 'On-chain · most private · instant code' },
chEmail: { zh: '邮箱客服', en: 'Email support' },
redeemTitle: { zh: '兑换激活码', en: 'Redeem a code', ja: 'コードを引き換える', ko: '코드 등록', ru: 'Активировать код', es: 'Canjear un código' },
redeemPh: { zh: '输入激活码', en: 'Enter activation code', ja: 'アクティベーションコードを入力', ko: '인증 코드 입력', ru: 'Введите код активации', es: 'Introduce el código de activación' },
redeemBtn: { zh: '激活', en: 'Redeem', ja: '引き換える', ko: '등록', ru: 'Активировать', es: 'Canjear' },
redeemOk: { zh: '激活成功 · 套餐已到账', en: 'Activated — plan applied', ja: 'アクティベート完了 — プランが適用されました', ko: '활성화 완료 — 요금제가 적용되었습니다', ru: 'Активировано — тариф применён', es: 'Activado: plan aplicado' },
buyTitle: { zh: '购买渠道', en: 'Where to buy', ja: '購入方法', ko: '구매처', ru: 'Где купить', es: 'Dónde comprar' },
buySub: { zh: '本站不直接收款。通过以下渠道购买激活码,回到本页兑换即可:', en: 'We never take payment on this site. Buy a code via a channel below, then redeem here:', ja: '当サイトでは直接お支払いを受け付けていません。以下のチャネルでコードを購入し、このページで引き換えてください:', ko: '본 사이트에서는 직접 결제를 받지 않습니다. 아래 채널에서 코드를 구매한 뒤 이 페이지에서 등록하세요:', ru: 'Мы не принимаем оплату на этом сайте. Купите код через один из каналов ниже и активируйте его здесь:', es: 'No aceptamos pagos en este sitio. Compra un código por uno de los canales de abajo y canjéalo aquí:' },
chStore: { zh: '自助发卡商店', en: 'Self-serve store', ja: 'セルフサービスストア', ko: '셀프 서비스 스토어', ru: 'Магазин самообслуживания', es: 'Tienda de autoservicio' },
chStoreSub: { zh: '支付宝 / 微信 · 自动发码', en: 'Alipay / WeChat · instant code', ja: 'Alipay / WeChat · コード即時発行', ko: 'Alipay / WeChat · 즉시 발급', ru: 'Alipay / WeChat · мгновенная выдача кода', es: 'Alipay / WeChat · código al instante' },
chUsdtSub: { zh: '链上转账 · 最隐私 · 自动发码', en: 'On-chain · most private · instant code', ja: 'オンチェーン送金 · 最もプライベート · コード即時発行', ko: '온체인 송금 · 가장 프라이빗 · 즉시 발급', ru: 'Ончейн-перевод · максимальная приватность · мгновенная выдача кода', es: 'En cadena · máxima privacidad · código al instante' },
chEmail: { zh: '邮箱客服', en: 'Email support', ja: 'メールサポート', ko: '이메일 고객지원', ru: 'Поддержка по эл. почте', es: 'Soporte por correo' },
/* invite */
inviteTitle: { zh: '邀请返利', en: 'Referral' },
inviteSub: { zh: '好友通过你的链接注册并付费,你获得其首单 20% 余额返利,可抵扣续费。', en: 'When a friend signs up and pays via your link, you earn 20% of their first order as credit.' },
inviteLink: { zh: '我的邀请链接', en: 'My invite link' },
invited: { zh: '已邀请', en: 'Invited' },
paidUsers: { zh: '已付费', en: 'Converted' },
earned: { zh: '累计返利', en: 'Credit earned' },
inviteRecord: { zh: '邀请记录', en: 'History' },
recEmpty: { zh: '暂无邀请记录', en: 'No referrals yet' },
recRegistered: { zh: '已注册', en: 'Registered' },
recPaid: { zh: '已付费', en: 'Paid' },
inviteTitle: { zh: '邀请返利', en: 'Referral', ja: '紹介', ko: '추천', ru: 'Рефералы', es: 'Referidos' },
inviteSub: { zh: '好友通过你的链接注册并付费,你获得其首单 20% 余额返利,可抵扣续费。', en: 'When a friend signs up and pays via your link, you earn 20% of their first order as credit.', ja: '友達があなたのリンクから登録して支払うと、初回注文の20%がクレジットとして還元されます。', ko: '친구가 내 링크로 가입하고 결제하면 첫 주문 금액의 20%를 크레딧으로 받습니다.', ru: 'Когда друг регистрируется и платит по вашей ссылке, вы получаете 20% его первого заказа в виде бонуса.', es: 'Cuando un amigo se registra y paga con tu enlace, ganas el 20% de su primer pedido como saldo.' },
inviteLink: { zh: '我的邀请链接', en: 'My invite link', ja: 'マイ紹介リンク', ko: '내 초대 링크', ru: 'Моя реферальная ссылка', es: 'Mi enlace de invitación' },
invited: { zh: '已邀请', en: 'Invited', ja: '招待済み', ko: '초대함', ru: 'Приглашено', es: 'Invitados' },
paidUsers: { zh: '已付费', en: 'Converted', ja: '成約', ko: '전환됨', ru: 'Оплатили', es: 'Convertidos' },
earned: { zh: '累计返利', en: 'Credit earned', ja: '獲得クレジット', ko: '적립 크레딧', ru: 'Начислено бонусов', es: 'Saldo ganado' },
inviteRecord: { zh: '邀请记录', en: 'History', ja: '履歴', ko: '내역', ru: 'История', es: 'Historial' },
recEmpty: { zh: '暂无邀请记录', en: 'No referrals yet', ja: 'まだ紹介はありません', ko: '아직 추천 내역이 없습니다', ru: 'Пока нет рефералов', es: 'Aún no hay referidos' },
recRegistered: { zh: '已注册', en: 'Registered', ja: '登録済み', ko: '가입함', ru: 'Зарегистрирован', es: 'Registrado' },
recPaid: { zh: '已付费', en: 'Paid', ja: '支払い済み', ko: '결제함', ru: 'Оплачено', es: 'Pagado' },
/* 2FA login step */
twoFATitle: { zh: '双重认证', en: 'Two-factor auth' },
twoFAHint: { zh: '你的账户已开启双重认证。请输入身份验证器 App 中的 6 位动态码。', en: 'Two-factor auth is on for this account. Enter the 6-digit code from your authenticator app.' },
twoFAConfirm: { zh: '验证并登录', en: 'Verify & log in' },
twoFABack: { zh: '返回上一步', en: 'Back' },
twoFAWrong: { zh: '动态码不正确,请重试', en: 'Invalid code, try again' },
twoFATitle: { zh: '双重认证', en: 'Two-factor auth', ja: '二要素認証', ko: '2단계 인증', ru: 'Двухфакторная аутентификация', es: 'Verificación en dos pasos' },
twoFAHint: { zh: '你的账户已开启双重认证。请输入身份验证器 App 中的 6 位动态码。', en: 'Two-factor auth is on for this account. Enter the 6-digit code from your authenticator app.', ja: 'このアカウントでは二要素認証が有効です。認証アプリの6桁コードを入力してください。', ko: '이 계정은 2단계 인증이 켜져 있습니다. 인증 앱의 6자리 코드를 입력하세요.', ru: 'Для этого аккаунта включена двухфакторная аутентификация. Введите 6-значный код из приложения-аутентификатора.', es: 'La verificación en dos pasos está activada en esta cuenta. Introduce el código de 6 dígitos de tu app de autenticación.' },
twoFAConfirm: { zh: '验证并登录', en: 'Verify & log in', ja: '確認してログイン', ko: '인증 후 로그인', ru: 'Подтвердить и войти', es: 'Verificar e iniciar sesión' },
twoFABack: { zh: '返回上一步', en: 'Back', ja: '戻る', ko: '뒤로', ru: 'Назад', es: 'Atrás' },
twoFAWrong: { zh: '动态码不正确,请重试', en: 'Invalid code, try again', ja: 'コードが正しくありません。もう一度お試しください', ko: '코드가 올바르지 않습니다. 다시 시도하세요', ru: 'Неверный код, попробуйте снова', es: 'Código no válido, inténtalo de nuevo' },
/* settings */
settingsTitle: { zh: '设置', en: 'Settings' },
prefTitle: { zh: '偏好', en: 'Preferences' },
prefLang: { zh: '显示语言', en: 'Language' },
prefTheme: { zh: '外观主题', en: 'Theme' },
themeLight: { zh: '浅色', en: 'Light' },
themeDark: { zh: '深色', en: 'Dark' },
settingsTitle: { zh: '设置', en: 'Settings', ja: '設定', ko: '설정', ru: 'Настройки', es: 'Ajustes' },
prefTitle: { zh: '偏好', en: 'Preferences', ja: '環境設定', ko: '환경설정', ru: 'Параметры', es: 'Preferencias' },
prefLang: { zh: '显示语言', en: 'Language', ja: '表示言語', ko: '언어', ru: 'Язык', es: 'Idioma' },
prefTheme: { zh: '外观主题', en: 'Theme', ja: 'テーマ', ko: '테마', ru: 'Тема', es: 'Tema' },
themeLight: { zh: '浅色', en: 'Light', ja: 'ライト', ko: '라이트', ru: 'Светлая', es: 'Claro' },
themeDark: { zh: '深色', en: 'Dark', ja: 'ダーク', ko: '다크', ru: 'Тёмная', es: 'Oscuro' },
/* devices */
devTitle: { zh: '设备管理', en: 'Devices' },
devSub: { zh: '管理已登录本账户的设备,移除后该设备需重新登录。', en: 'Devices signed in to your account. Removing one signs it out.' },
devCurrent: { zh: '当前设备', en: 'This device' },
devLastActive: { zh: '最近活跃', en: 'Last active' },
devRemove: { zh: '移除', en: 'Remove' },
devRemoveConfirmTitle: { zh: '移除此设备?', en: 'Remove this device?' },
devRemoveConfirmSub: { zh: '该设备将被登出,需重新输入凭证才能再次连接。', en: 'It will be signed out and must re-authenticate to connect again.' },
devEmpty: { zh: '暂无其他设备', en: 'No devices yet' },
cancel: { zh: '取消', en: 'Cancel' },
confirm: { zh: '确认移除', en: 'Remove' },
devTitle: { zh: '设备管理', en: 'Devices', ja: 'デバイス', ko: '기기', ru: 'Устройства', es: 'Dispositivos' },
devSub: { zh: '管理已登录本账户的设备,移除后该设备需重新登录。', en: 'Devices signed in to your account. Removing one signs it out.', ja: 'このアカウントにログイン中のデバイスです。削除するとそのデバイスはログアウトされます。', ko: '이 계정에 로그인된 기기입니다. 제거하면 해당 기기는 로그아웃됩니다.', ru: 'Устройства, вошедшие в ваш аккаунт. Удаление приведёт к выходу из аккаунта на устройстве.', es: 'Dispositivos con sesión iniciada en tu cuenta. Al quitar uno, se cierra su sesión.' },
devCurrent: { zh: '当前设备', en: 'This device', ja: 'このデバイス', ko: '현재 기기', ru: 'Это устройство', es: 'Este dispositivo' },
devLastActive: { zh: '最近活跃', en: 'Last active', ja: '最終アクティブ', ko: '마지막 활동', ru: 'Последняя активность', es: 'Última actividad' },
devRemove: { zh: '移除', en: 'Remove', ja: '削除', ko: '제거', ru: 'Удалить', es: 'Quitar' },
devRemoveConfirmTitle: { zh: '移除此设备?', en: 'Remove this device?', ja: 'このデバイスを削除しますか?', ko: '이 기기를 제거할까요?', ru: 'Удалить это устройство?', es: '¿Quitar este dispositivo?' },
devRemoveConfirmSub: { zh: '该设备将被登出,需重新输入凭证才能再次连接。', en: 'It will be signed out and must re-authenticate to connect again.', ja: 'このデバイスはログアウトされ、再接続には再認証が必要になります。', ko: '해당 기기는 로그아웃되며 다시 연결하려면 재인증이 필요합니다.', ru: 'Устройство выйдет из аккаунта и должно будет пройти повторную аутентификацию для подключения.', es: 'Se cerrará su sesión y deberá volver a autenticarse para conectarse de nuevo.' },
devEmpty: { zh: '暂无其他设备', en: 'No devices yet', ja: '他のデバイスはありません', ko: '다른 기기가 없습니다', ru: 'Пока нет устройств', es: 'Aún no hay dispositivos' },
cancel: { zh: '取消', en: 'Cancel', ja: 'キャンセル', ko: '취소', ru: 'Отмена', es: 'Cancelar' },
confirm: { zh: '确认移除', en: 'Remove', ja: '削除する', ko: '제거', ru: 'Удалить', es: 'Quitar' },
/* TOTP management */
totpTitle: { zh: '双重认证 (TOTP)', en: 'Two-factor auth (TOTP)' },
totpOff: { zh: '未开启', en: 'Off' },
totpOn: { zh: '已开启', en: 'On' },
totpDesc: { zh: '用身份验证器 App 生成的一次性动态码保护登录,显著提升账户安全。', en: 'Protect sign-in with one-time codes from an authenticator app.' },
totpEnable: { zh: '开启双重认证', en: 'Enable 2FA' },
totpDisableBtn: { zh: '关闭双重认证', en: 'Disable 2FA' },
totpStep1: { zh: '1. 用身份验证器扫码,或手动输入密钥', en: '1. Scan with an authenticator, or enter the key manually' },
totpSecretLabel: { zh: '手动密钥', en: 'Manual key' },
totpStep2: { zh: '2. 输入 App 显示的 6 位动态码以确认', en: '2. Enter the 6-digit code shown in the app to confirm' },
totpVerifyBtn: { zh: '确认开启', en: 'Confirm & enable' },
totpEnabledOk: { zh: '双重认证已开启', en: 'Two-factor auth enabled' },
totpDisabledOk: { zh: '双重认证已关闭', en: 'Two-factor auth disabled' },
totpDisableHint: { zh: '输入当前动态码以关闭双重认证。', en: 'Enter your current code to disable 2FA.' },
copyKey: { zh: '复制密钥', en: 'Copy key' },
totpTitle: { zh: '双重认证 (TOTP)', en: 'Two-factor auth (TOTP)', ja: '二要素認証 (TOTP)', ko: '2단계 인증 (TOTP)', ru: 'Двухфакторная аутентификация (TOTP)', es: 'Verificación en dos pasos (TOTP)' },
totpOff: { zh: '未开启', en: 'Off', ja: 'オフ', ko: '꺼짐', ru: 'Выкл.', es: 'Desactivado' },
totpOn: { zh: '已开启', en: 'On', ja: 'オン', ko: '켜짐', ru: 'Вкл.', es: 'Activado' },
totpDesc: { zh: '用身份验证器 App 生成的一次性动态码保护登录,显著提升账户安全。', en: 'Protect sign-in with one-time codes from an authenticator app.', ja: '認証アプリのワンタイムコードでログインを保護します。', ko: '인증 앱의 일회용 코드로 로그인을 보호하세요.', ru: 'Защитите вход одноразовыми кодами из приложения-аутентификатора.', es: 'Protege el inicio de sesión con códigos de un solo uso de una app de autenticación.' },
totpEnable: { zh: '开启双重认证', en: 'Enable 2FA', ja: '二要素認証を有効にする', ko: '2단계 인증 켜기', ru: 'Включить 2FA', es: 'Activar 2FA' },
totpDisableBtn: { zh: '关闭双重认证', en: 'Disable 2FA', ja: '二要素認証を無効にする', ko: '2단계 인증 끄기', ru: 'Отключить 2FA', es: 'Desactivar 2FA' },
totpStep1: { zh: '1. 用身份验证器扫码,或手动输入密钥', en: '1. Scan with an authenticator, or enter the key manually', ja: '1. 認証アプリでスキャンするか、キーを手動で入力してください', ko: '1. 인증 앱으로 스캔하거나 키를 직접 입력하세요', ru: '1. Отсканируйте в приложении-аутентификаторе или введите ключ вручную', es: '1. Escanea con una app de autenticación o introduce la clave manualmente' },
totpSecretLabel: { zh: '手动密钥', en: 'Manual key', ja: '手動入力キー', ko: '수동 키', ru: 'Ключ для ручного ввода', es: 'Clave manual' },
totpStep2: { zh: '2. 输入 App 显示的 6 位动态码以确认', en: '2. Enter the 6-digit code shown in the app to confirm', ja: '2. アプリに表示された6桁コードを入力して確認してください', ko: '2. 앱에 표시된 6자리 코드를 입력해 확인하세요', ru: '2. Введите 6-значный код из приложения для подтверждения', es: '2. Introduce el código de 6 dígitos que muestra la app para confirmar' },
totpVerifyBtn: { zh: '确认开启', en: 'Confirm & enable', ja: '確認して有効にする', ko: '확인 후 켜기', ru: 'Подтвердить и включить', es: 'Confirmar y activar' },
totpEnabledOk: { zh: '双重认证已开启', en: 'Two-factor auth enabled', ja: '二要素認証を有効にしました', ko: '2단계 인증이 켜졌습니다', ru: 'Двухфакторная аутентификация включена', es: 'Verificación en dos pasos activada' },
totpDisabledOk: { zh: '双重认证已关闭', en: 'Two-factor auth disabled', ja: '二要素認証を無効にしました', ko: '2단계 인증이 꺼졌습니다', ru: 'Двухфакторная аутентификация отключена', es: 'Verificación en dos pasos desactivada' },
totpDisableHint: { zh: '输入当前动态码以关闭双重认证。', en: 'Enter your current code to disable 2FA.', ja: '二要素認証を無効にするには現在のコードを入力してください。', ko: '2단계 인증을 끄려면 현재 코드를 입력하세요.', ru: 'Введите текущий код, чтобы отключить 2FA.', es: 'Introduce tu código actual para desactivar la 2FA.' },
copyKey: { zh: '复制密钥', en: 'Copy key', ja: 'キーをコピー', ko: '키 복사', ru: 'Копировать ключ', es: 'Copiar clave' },
/* generic errors */
errNetwork: { zh: '网络异常,请稍后重试', en: 'Network error, please retry' },
errUnknown: { zh: '操作失败,请稍后重试', en: 'Something went wrong, please retry' },
loading: { zh: '加载中…', en: 'Loading…' },
errNetwork: { zh: '网络异常,请稍后重试', en: 'Network error, please retry', ja: 'ネットワークエラーです。後でもう一度お試しください', ko: '네트워크 오류입니다. 잠시 후 다시 시도하세요', ru: 'Ошибка сети, повторите попытку', es: 'Error de red, vuelve a intentarlo' },
errUnknown: { zh: '操作失败,请稍后重试', en: 'Something went wrong, please retry', ja: '問題が発生しました。後でもう一度お試しください', ko: '문제가 발생했습니다. 잠시 후 다시 시도하세요', ru: 'Что-то пошло не так, повторите попытку', es: 'Algo salió mal, vuelve a intentarlo' },
loading: { zh: '加载中…', en: 'Loading…', ja: '読み込み中…', ko: '불러오는 중…', ru: 'Загрузка…', es: 'Cargando…' },
};
export type TFn = (key: string, vars?: Record<string, string | number>) => string;
@@ -142,7 +142,7 @@ export type TFn = (key: string, vars?: Record<string, string | number>) => strin
export function makeT(lang: Lang): TFn {
return (key, vars) => {
const e = STRINGS[key];
let s = e ? e[lang] || key : key;
let s = e ? e[lang] || e.en || key : key;
if (vars) for (const k of Object.keys(vars)) s = s.replace(`{${k}}`, String(vars[k]));
return s;
};
+3 -3
View File
@@ -18,7 +18,7 @@ const LANG_KEY = 'pg_uc_lang';
const THEME_KEY = 'pg_uc_theme';
export function UIProvider({ children }: { children: React.ReactNode }) {
const [lang, setLangState] = useState<Lang>('zh');
const [lang, setLangState] = useState<Lang>('en'); // 默认英文(国际化默认语种)
const [theme, setThemeState] = useState<Theme>('light');
// 挂载后读取持久化(避免 hydration 不一致)
@@ -26,7 +26,7 @@ export function UIProvider({ children }: { children: React.ReactNode }) {
try {
const l = window.localStorage.getItem(LANG_KEY) as Lang | null;
const t = window.localStorage.getItem(THEME_KEY) as Theme | null;
if (l === 'zh' || l === 'en') setLangState(l);
if (l && (['zh', 'en', 'ja', 'ko', 'ru', 'es'] as Lang[]).includes(l)) setLangState(l);
const initial: Theme = t === 'dark' || t === 'light' ? t : window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
setThemeState(initial);
} catch {
@@ -36,7 +36,7 @@ export function UIProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.setAttribute('lang', lang === 'zh' ? 'zh' : 'en');
document.documentElement.setAttribute('lang', lang);
}, [theme, lang]);
const setLang = useCallback((l: Lang) => {