- Updated the `UpdateClientAsync` method in `IXuiPanelGateway` to accept nullable parameters for `name` and `expiresAt`, allowing for more flexible client management without unintended modifications. - Adjusted the `BlockUserCommandHandler`, `UnblockUserCommandHandler`, and other related command handlers to utilize the new nullable parameters, ensuring that client names remain unchanged during block/unblock operations and that expiration dates are managed correctly. - Enhanced the billing and configuration handling to reflect the new logic for managing client states based on expiration rather than enabling/disabling, improving reliability in client status management. - Updated tests to cover the new behavior and ensure proper functionality across the application.
1107 lines
53 KiB
TypeScript
1107 lines
53 KiB
TypeScript
import i18n from 'i18next'
|
||
import { initReactI18next } from 'react-i18next'
|
||
|
||
const resources = {
|
||
ru: {
|
||
translation: {
|
||
appName: 'PnvPanel',
|
||
tagline: 'Self-service портал для VPN-конфигураций',
|
||
theme: 'Тема',
|
||
language: 'Язык',
|
||
light: 'Светлая',
|
||
dark: 'Тёмная',
|
||
system: 'Системная',
|
||
unlimited: 'Без лимита',
|
||
|
||
auth: {
|
||
loginTitle: 'Вход',
|
||
registerTitle: 'Регистрация',
|
||
userName: 'Имя пользователя',
|
||
password: 'Пароль',
|
||
submitLogin: 'Войти',
|
||
submitRegister: 'Зарегистрироваться',
|
||
noAccount: 'Нет аккаунта?',
|
||
haveAccount: 'Уже есть аккаунт?',
|
||
goRegister: 'Зарегистрироваться',
|
||
goLogin: 'Войти',
|
||
loginViaTelegram: 'Войти через Telegram',
|
||
invalidCredentials: 'Неверное имя пользователя или пароль.',
|
||
duplicateUserName: 'Пользователь с таким именем уже существует.',
|
||
genericError: 'Что-то пошло не так. Попробуйте ещё раз.',
|
||
userNameHint: 'Латиница, цифры, «_», «.», «-», от 3 до 32 символов.',
|
||
passwordHint: 'Не менее 8 символов, минимум одна заглавная буква и одна цифра.',
|
||
or: 'или',
|
||
telegramBotNotConfigured: 'Telegram-бот не настроен администратором.',
|
||
telegramQrHint:
|
||
'Перейдите по ссылке или отсканируйте QR-код — откроется бот в Telegram. Если открываете его впервые, нажмите «Старт».',
|
||
waitingForConfirmation: 'Ожидание подтверждения в Telegram…',
|
||
telegramLoginRejected: 'Вход отклонён в Telegram.',
|
||
telegramLoginExpired: 'Время ожидания истекло, попробуйте снова.',
|
||
},
|
||
|
||
telegramLinkWarning: {
|
||
message: 'Вы не привязали Telegram — без этого вы не будете получать уведомления и не сможете самостоятельно восстановить пароль.',
|
||
action: 'Привязать Telegram',
|
||
dismiss: 'Скрыть',
|
||
},
|
||
|
||
nav: {
|
||
dashboard: 'Мои конфиги',
|
||
instructions: 'Инструкции',
|
||
news: 'Новости',
|
||
support: 'Поддержка',
|
||
billing: 'Оплата',
|
||
settings: 'Настройки',
|
||
admin: 'Админка',
|
||
logout: 'Выйти',
|
||
toggleMenu: 'Меню',
|
||
},
|
||
|
||
activation: {
|
||
title: 'Аккаунт не активирован',
|
||
description:
|
||
'Чтобы создавать конфиги, дождитесь активации администратором. Можно оставить комментарий к заявке.',
|
||
commentLabel: 'Комментарий (необязательно)',
|
||
submit: 'Запросить активацию',
|
||
pending: 'Заявка на активацию отправлена, ожидайте решения администратора.',
|
||
alreadyPending: 'У вас уже есть необработанная заявка на активацию.',
|
||
retry: 'Повторить',
|
||
},
|
||
|
||
configs: {
|
||
title: 'Мои конфиги',
|
||
quota: 'Использовано {{used}} из {{max}}',
|
||
quotaUnlimited: 'Использовано {{used}}, без лимита',
|
||
empty: 'У вас пока нет конфигов. Создайте первый.',
|
||
create: 'Создать конфиг',
|
||
selectLocation: 'Выберите локацию',
|
||
location: 'Локация',
|
||
label: 'Метка (необязательно)',
|
||
noInboundsNotice: 'Пока нет доступных локаций для создания конфига. Обратитесь к администратору — необходимо, чтобы он добавил сервер.',
|
||
billingRequiredNotice: 'Оплата подписки истекла — создание новых конфигов недоступно, пока не продлите доступ.',
|
||
goToBilling: 'Перейти к оплате',
|
||
created: 'Конфиг создан.',
|
||
quotaExceeded: 'Достигнут лимит конфигов для вашей роли.',
|
||
showLink: 'Ссылка / QR',
|
||
rotate: 'Перевыпустить',
|
||
rotated: 'Конфиг перевыпущен.',
|
||
revoked: 'Конфиг отозван.',
|
||
confirmRevoke: 'Отозвать этот конфиг? Действие необратимо.',
|
||
copied: 'Скопировано.',
|
||
copyLink: 'Скопировать ссылку',
|
||
loadingLink: 'Загрузка ссылки…',
|
||
subscriptionLink: 'Ссылка-подписка (для клиента):',
|
||
panelEmail: 'Email в панели: {{email}}',
|
||
aggregatedSubscription: 'Общая подписка',
|
||
aggregatedSubscriptionHint: 'Одна ссылка/QR со всеми активными конфигами — удобно добавить один раз в клиент.',
|
||
status: {
|
||
Active: 'Активен',
|
||
Disabled: 'Отключён',
|
||
Expired: 'Истёк',
|
||
LimitReached: 'Лимит исчерпан',
|
||
Revoked: 'Отозван',
|
||
},
|
||
},
|
||
|
||
billing: {
|
||
title: 'Оплата подписки',
|
||
notApplicable: 'Биллинг не применяется к вашей роли.',
|
||
status: 'Статус оплаты',
|
||
suspended: 'Приостановлено',
|
||
paidUntil: 'Оплачено до {{date}}',
|
||
paidUntilLabel: 'Оплата:',
|
||
neverPaid: 'Оплата ещё не производилась.',
|
||
neverPaidShort: 'Не оплачено',
|
||
expiredShort: 'Истекло',
|
||
daysRemaining: '{{count}} дн.',
|
||
hoursMinutesRemaining: '{{hours}} ч {{minutes}} мин',
|
||
minutesRemaining: '{{count}} мин',
|
||
pendingReviewShort: 'На проверке',
|
||
pendingReviewHint: 'Оплата отправлена на проверку администратору — доступ не будет приостановлен, пока заявка не решена.',
|
||
paidUntilWithRemaining: 'до {{date}} ({{remaining}})',
|
||
newRequest: 'Оформить оплату',
|
||
period: 'Период',
|
||
periods: {
|
||
Quarter: '3 месяца',
|
||
HalfYear: 'Полгода',
|
||
Year: 'Год',
|
||
},
|
||
createRequest: 'Создать заявку',
|
||
requestCreated: 'Заявка создана.',
|
||
activeRequest: 'Заявка на оплату',
|
||
requestStatus: {
|
||
AwaitingPayment: 'Ожидает оплаты',
|
||
AwaitingConfirmation: 'На проверке у администратора',
|
||
},
|
||
requisites: 'Реквизиты для оплаты',
|
||
requisitesMissing: 'Реквизиты ещё не настроены администратором.',
|
||
sendToTelegram: 'Отправить в Telegram',
|
||
requisitesSent: 'Реквизиты отправлены в Telegram.',
|
||
telegramNotLinked: 'Сначала привяжите Telegram в настройках.',
|
||
iPaid: 'Я оплатил',
|
||
markedPaid: 'Отмечено — администратор проверит оплату.',
|
||
cancelRequest: 'Отменить',
|
||
confirmCancel: 'Отменить заявку на оплату?',
|
||
requestCancelled: 'Заявка отменена.',
|
||
awaitingAdminHint: 'Администратор уведомлён и проверит оплату. Конфиги не отключатся, пока заявка не решена.',
|
||
roleChangeTopUp: 'Доплата за смену роли',
|
||
roleChangeTopUpHint: 'Новая роль дороже прежней — эта сумма покрывает разницу в цене за оставшуюся часть уже оплаченного периода, срок подписки при этом не меняется.',
|
||
},
|
||
|
||
instructions: {
|
||
title: 'Инструкции по подключению',
|
||
appsTitle: 'Приложения',
|
||
noApps: 'Каталог приложений пока пуст.',
|
||
recommended: 'Рекомендуем',
|
||
os: {
|
||
IOS: 'iOS',
|
||
Android: 'Android',
|
||
Windows: 'Windows',
|
||
MacOS: 'macOS',
|
||
Linux: 'Linux',
|
||
},
|
||
},
|
||
|
||
news: {
|
||
title: 'Новости',
|
||
empty: 'Пока нет новостей.',
|
||
},
|
||
|
||
support: {
|
||
title: 'Поддержка',
|
||
empty: 'У вас пока нет обращений.',
|
||
reportBug: 'Сообщить об ошибке',
|
||
requestRole: 'Запросить роль',
|
||
requestExtension: 'Попросить о продлении',
|
||
requestedDaysLabel: 'Сколько дней нужно',
|
||
requestedExtension: 'Запрошено продление на {{days}} дн.',
|
||
extensionRequestPending: 'У вас уже есть необработанная заявка на продление.',
|
||
submit: 'Отправить',
|
||
messageLabel: 'Опишите проблему или предложение',
|
||
attachmentsLabel: 'Скриншоты (необязательно, до 5)',
|
||
filesSelected: '{{count}} файл(ов) выбрано',
|
||
existingRole: 'Существующая роль',
|
||
newRole: 'Новая роль',
|
||
selectRole: 'Выберите роль',
|
||
roleOption: '{{name}} — конфигов: {{configs}}, IP: {{ip}}',
|
||
newRoleName: 'Название роли',
|
||
newRoleMaxConfigs: 'Количество конфигов (-1 — без лимита)',
|
||
newRoleMaxIpLimit: 'Количество IP (-1 — без лимита)',
|
||
pricingTitle: 'Ориентировочная стоимость',
|
||
pricingQuarter: '3 месяца: {{price}}',
|
||
pricingHalfYear: 'Полгода: {{price}}',
|
||
pricingYear: 'Год: {{price}}',
|
||
pricingDisclaimer: 'Цены на данный момент ознакомительные.',
|
||
pricingDiscounted: '{{price}} ₽ (вместо {{original}} ₽, скидка {{percent}}%)',
|
||
justification: 'Обоснование',
|
||
ticketCreated: 'Обращение отправлено.',
|
||
roleRequestPending: 'У вас уже есть необработанная заявка на роль.',
|
||
reply: 'Ответить',
|
||
replyPlaceholder: 'Написать комментарий…',
|
||
reopen: 'Переоткрыть',
|
||
reopened: 'Тикет переоткрыт.',
|
||
lastActivity: 'Последняя активность: {{date}}',
|
||
requestedExistingRole: 'Запрошена роль: {{role}}',
|
||
requestedNewRole: 'Запрошена новая роль «{{name}}» (конфигов: {{configs}}, IP: {{ip}})',
|
||
type: {
|
||
BugReport: 'Ошибка/предложение',
|
||
RoleRequest: 'Заявка на роль',
|
||
ExtensionRequest: 'Заявка на продление',
|
||
},
|
||
status: {
|
||
Open: 'Открыт',
|
||
Resolved: 'Решён',
|
||
Closed: 'Закрыт',
|
||
},
|
||
},
|
||
|
||
settings: {
|
||
changePassword: 'Сменить пароль',
|
||
currentPassword: 'Текущий пароль',
|
||
currentPasswordRequired: 'Введите текущий пароль.',
|
||
currentPasswordInvalid: 'Неверный текущий пароль.',
|
||
newPassword: 'Новый пароль',
|
||
passwordChanged: 'Пароль изменён.',
|
||
changeUserName: 'Сменить логин',
|
||
changeUserNameHint: 'Текущий логин: {{current}}.',
|
||
newUserName: 'Новый логин',
|
||
userNameChanged: 'Логин изменён.',
|
||
telegramHint: 'Привязка Telegram нужна для входа без пароля и восстановления доступа.',
|
||
telegramLinkedStatus: 'Привязан',
|
||
link: 'Привязать Telegram',
|
||
unlink: 'Отвязать',
|
||
confirmUnlink: 'Отвязать Telegram от аккаунта?',
|
||
telegramLinked: 'Telegram привязан.',
|
||
telegramUnlinked: 'Telegram отвязан.',
|
||
waitingForLink: 'Ожидание подтверждения в Telegram…',
|
||
deleteAccount: 'Удалить аккаунт',
|
||
deleteAccountHint: 'Отзовёт все конфиги и безвозвратно удалит аккаунт.',
|
||
confirmDelete: 'Вы уверены? Это действие необратимо.',
|
||
confirmDeleteYes: 'Да, удалить',
|
||
cancel: 'Отмена',
|
||
},
|
||
|
||
admin: {
|
||
prev: 'Назад',
|
||
next: 'Вперёд',
|
||
tabs: {
|
||
overview: 'Обзор',
|
||
activation: 'Запросы на активацию',
|
||
users: 'Пользователи',
|
||
configs: 'Все конфиги',
|
||
roles: 'Роли',
|
||
pricing: 'Цены',
|
||
billing: 'Оплата',
|
||
nodes: 'Ноды',
|
||
apps: 'Приложения',
|
||
instructions: 'Инструкции',
|
||
news: 'Новости',
|
||
support: 'Тикеты',
|
||
audit: 'Аудит',
|
||
maintenance: 'Обслуживание',
|
||
},
|
||
users: {
|
||
searchPlaceholder: 'Поиск по имени пользователя',
|
||
userName: 'Имя пользователя',
|
||
role: 'Роль',
|
||
statusLabel: 'Статус',
|
||
billingLabel: 'Оплачено до',
|
||
manage: 'Управление',
|
||
empty: 'Пользователи не найдены.',
|
||
total: 'Всего: {{count}}',
|
||
status: {
|
||
blocked: 'Заблокирован',
|
||
active: 'Активен',
|
||
pending: 'Не активирован',
|
||
},
|
||
block: 'Заблокировать',
|
||
unblock: 'Разблокировать',
|
||
confirmBlock: 'Заблокировать пользователя? Все его конфиги будут отключены.',
|
||
blocked: 'Пользователь заблокирован.',
|
||
unblocked: 'Пользователь разблокирован.',
|
||
roleChanged: 'Роль изменена.',
|
||
resetPassword: 'Сбросить пароль',
|
||
reset: 'Сбросить',
|
||
passwordReset: 'Пароль сброшен.',
|
||
configs: 'Конфиги',
|
||
delete: 'Удалить пользователя',
|
||
confirmDelete: 'Удалить пользователя? Все его конфиги будут отозваны в 3x-ui, действие необратимо.',
|
||
deleted: 'Пользователь удалён.',
|
||
giftDaysPlaceholder: 'Дней',
|
||
giftGrant: 'Подарить',
|
||
giftGranted: 'Дни подписки подарены пользователю.',
|
||
},
|
||
configs: {
|
||
searchPlaceholder: 'Поиск по email в панели или метке',
|
||
owner: 'Владелец',
|
||
node: 'Нода',
|
||
location: 'Локация',
|
||
protocol: 'Протокол',
|
||
traffic: 'Трафик',
|
||
statusLabel: 'Статус',
|
||
allStatuses: 'Все статусы',
|
||
created: 'Создан',
|
||
empty: 'Конфиги не найдены.',
|
||
total: 'Всего: {{count}}',
|
||
},
|
||
activation: {
|
||
empty: 'Нет ожидающих запросов на активацию.',
|
||
approved: 'Пользователь активирован.',
|
||
rejected: 'Запрос отклонён.',
|
||
approve: 'Активировать',
|
||
reject: 'Отклонить',
|
||
},
|
||
roles: {
|
||
create: 'Создать роль',
|
||
name: 'Название',
|
||
maxConfigs: 'Квота конфигов',
|
||
maxConfigsHint: '−1 = без лимита.',
|
||
maxIpLimit: 'Лимит IP на конфиг',
|
||
maxIpLimitHint: '−1 = без лимита. Применяется только к новым конфигам.',
|
||
totalPerQuarter: 'Итого / 3 мес',
|
||
totalHalfYear: 'Итого / полгода',
|
||
totalPerYear: 'Итого / год',
|
||
noPrice: '—',
|
||
system: 'системная',
|
||
edit: 'Изменить',
|
||
save: 'Сохранить',
|
||
delete: 'Удалить',
|
||
confirmDelete: 'Удалить роль? Это действие необратимо.',
|
||
created: 'Роль создана.',
|
||
updated: 'Квота обновлена.',
|
||
deleted: 'Роль удалена.',
|
||
billingEnabledLabel: 'Включить биллинг для этой роли',
|
||
billingBadge: 'биллинг',
|
||
},
|
||
pricing: {
|
||
title: 'Справочная цена конфига',
|
||
pricePerConfigPerQuarter: 'Цена за конфиг в месяц / оплата раз в 3 мес',
|
||
pricePerConfigPerQuarterHint: 'Минимальный период оплаты. Видно только админу. Итог за период = ставка × число месяцев × квота роли.',
|
||
pricePerConfigPerHalfYear: 'Цена за конфиг в месяц / оплата раз в полгода',
|
||
pricePerConfigPerHalfYearHint: 'Может быть ниже квартальной (скидка за оплату на полгода вперёд), но итог за полгода не должен быть дешевле итога за квартал.',
|
||
halfYearCheaperThanQuarter: 'Цена за полгода (в пересчёте на 6 месяцев) не может быть меньше цены за 3 месяца.',
|
||
pricePerConfigPerYear: 'Цена за конфиг в месяц / оплата раз в год',
|
||
pricePerConfigPerYearHint: 'Может быть ниже полугодовой (скидка за годовую оплату), но итог за год не должен быть дешевле итога за полгода.',
|
||
yearCheaperThanHalfYear: 'Цена за год (в пересчёте на 12 месяцев) не может быть меньше цены за полгода.',
|
||
yearCheaperThanQuarter: 'Цена за год (в пересчёте на 12 месяцев) не может быть меньше цены за 3 месяца.',
|
||
updated: 'Цена обновлена.',
|
||
discountTiersTitle: 'Скидка за объём',
|
||
discountTiersHint: 'Роль с квотой конфигов не меньше порога получает указанную скидку от итоговой цены за период. Действует наивысший подходящий порог, скидки не суммируются.',
|
||
discountTierMinConfigs: 'От скольки конфигов',
|
||
discountTierPercent: 'Скидка, %',
|
||
addDiscountTier: 'Добавить ступень',
|
||
removeDiscountTier: 'Удалить',
|
||
discountTierInvalidMinConfigs: 'Порог — целое число конфигов, не меньше 1.',
|
||
discountTierInvalidPercent: 'Скидка — целое число от 1 до 99.',
|
||
discountTiersDuplicate: 'Пороги скидочной лесенки не должны повторяться.',
|
||
discountTiersNotProgressive: 'Скидка на более высоком пороге не может быть меньше скидки на более низком.',
|
||
},
|
||
billing: {
|
||
settingsTitle: 'Настройки биллинга',
|
||
requisitesText: 'Реквизиты для оплаты',
|
||
requisitesTextHint: 'Показываются пользователю вместе с заявкой на оплату.',
|
||
graceDays: 'Грейс-период (дней)',
|
||
graceDaysHint: 'Сколько дней доступ активен после назначения billing-роли, пока не поступит первая оплата.',
|
||
defaultBillingEnabledForNewRolesLabel: 'Новые роли по умолчанию с включённым биллингом',
|
||
settingsUpdated: 'Настройки биллинга обновлены.',
|
||
requestsTitle: 'Заявки на оплату',
|
||
allStatuses: 'Все статусы',
|
||
user: 'Пользователь',
|
||
period: 'Период',
|
||
amount: 'Сумма',
|
||
statusLabel: 'Статус',
|
||
created: 'Создана',
|
||
empty: 'Заявок не найдено.',
|
||
confirm: 'Подтвердить',
|
||
reject: 'Отклонить',
|
||
confirmed: 'Оплата подтверждена.',
|
||
rejected: 'Заявка отклонена.',
|
||
rejectReasonPrompt: 'Причина отклонения (необязательно):',
|
||
status: {
|
||
AwaitingPayment: 'Ожидает оплаты',
|
||
AwaitingConfirmation: 'На проверке',
|
||
Confirmed: 'Подтверждена',
|
||
Rejected: 'Отклонена',
|
||
Cancelled: 'Отменена',
|
||
},
|
||
},
|
||
nodes: {
|
||
create: 'Добавить ноду',
|
||
name: 'Название',
|
||
baseAddress: 'Адрес панели',
|
||
username: 'Логин',
|
||
password: 'Пароль',
|
||
location: 'Локация',
|
||
empty: 'Ноды не добавлены.',
|
||
created: 'Нода добавлена.',
|
||
updated: 'Нода обновлена.',
|
||
deleted: 'Нода удалена.',
|
||
confirmDelete: 'Удалить ноду? Существующие конфиги на ней перестанут синхронизироваться.',
|
||
edit: 'Изменить',
|
||
delete: 'Удалить',
|
||
probe: 'Проверить',
|
||
sync: 'Синхронизировать',
|
||
probeSuccess: 'Нода доступна.',
|
||
probeFailure: 'Нода недоступна: {{message}}',
|
||
syncSuccess: 'Синхронизировано инбаундов: {{count}}',
|
||
status: {
|
||
Unknown: 'Неизвестно',
|
||
Online: 'Онлайн',
|
||
Offline: 'Офлайн',
|
||
},
|
||
enabled: 'Включена',
|
||
disabled: 'Отключена',
|
||
inbounds: 'Инбаунды',
|
||
noInbounds: 'Инбаунды не найдены — нажмите «Синхронизировать».',
|
||
publish: 'Публикация',
|
||
published: 'Опубликован',
|
||
unpublished: 'Не опубликован',
|
||
unavailable: 'Недоступен на панели',
|
||
publishSaved: 'Настройки публикации сохранены.',
|
||
displayName: 'Отображаемое имя',
|
||
allowedRoles: 'Доступно ролям',
|
||
isPublishedLabel: 'Опубликовать инбаунд',
|
||
optional: 'необязательно',
|
||
},
|
||
apps: {
|
||
create: 'Добавить приложение',
|
||
name: 'Название',
|
||
downloadUrl: 'Ссылка на скачивание',
|
||
os: 'ОС',
|
||
description: 'Описание',
|
||
iconUrl: 'Ссылка на иконку',
|
||
sortOrder: 'Порядок',
|
||
enabled: 'Включено',
|
||
disabled: 'отключено',
|
||
recommended: 'Рекомендовано',
|
||
empty: 'Каталог приложений пуст.',
|
||
created: 'Приложение добавлено.',
|
||
updated: 'Приложение обновлено.',
|
||
deleted: 'Приложение удалено.',
|
||
confirmDelete: 'Удалить приложение из каталога?',
|
||
},
|
||
instructions: {
|
||
introTitle: 'Основная инструкция',
|
||
introHint: 'Показывается над вкладками на странице «Инструкции» (Markdown).',
|
||
introSaved: 'Инструкция сохранена.',
|
||
tabsTitle: 'Дополнительные вкладки',
|
||
tabsEmpty: 'Дополнительных вкладок пока нет.',
|
||
createTab: 'Добавить вкладку',
|
||
tabTitle: 'Название вкладки',
|
||
tabBody: 'Текст (Markdown)',
|
||
tabCreated: 'Вкладка добавлена.',
|
||
tabUpdated: 'Вкладка обновлена.',
|
||
tabDeleted: 'Вкладка удалена.',
|
||
confirmDeleteTab: 'Удалить вкладку инструкций?',
|
||
},
|
||
news: {
|
||
create: 'Добавить новость',
|
||
title: 'Заголовок',
|
||
body: 'Текст (Markdown)',
|
||
preview: 'Предпросмотр',
|
||
empty: 'Новостей пока нет.',
|
||
created: 'Новость опубликована.',
|
||
updated: 'Новость обновлена.',
|
||
deleted: 'Новость удалена.',
|
||
confirmDelete: 'Удалить новость?',
|
||
},
|
||
support: {
|
||
empty: 'Обращений пока нет.',
|
||
resolve: 'Решено',
|
||
resolved: 'Тикет отмечен как решённый.',
|
||
close: 'Закрыть',
|
||
closed: 'Тикет закрыт.',
|
||
approve: 'Одобрить',
|
||
approved: 'Заявка одобрена, роль выдана.',
|
||
reject: 'Отклонить',
|
||
rejected: 'Заявка отклонена.',
|
||
},
|
||
audit: {
|
||
time: 'Время',
|
||
action: 'Действие',
|
||
target: 'Объект',
|
||
source: 'Источник',
|
||
empty: 'Журнал аудита пуст.',
|
||
},
|
||
maintenance: {
|
||
closedTickets: {
|
||
title: 'Закрытые обращения',
|
||
description: 'Удалить все закрытые обращения вместе с перепиской и вложениями (файлами). Действие необратимо.',
|
||
action: 'Удалить закрытые обращения',
|
||
confirm: 'Удалить все закрытые обращения и файлы в них? Действие необратимо.',
|
||
deleted: 'Удалено обращений: {{count}}.',
|
||
},
|
||
audit: {
|
||
title: 'Журнал аудита',
|
||
description: 'Удалить записи журнала аудита старше указанного числа дней. Действие необратимо.',
|
||
olderThan: 'Старше',
|
||
days: 'дней',
|
||
action: 'Удалить старые записи',
|
||
confirm: 'Удалить записи аудита старше {{days}} дней? Действие необратимо.',
|
||
deleted: 'Удалено записей аудита: {{count}}.',
|
||
},
|
||
apps: {
|
||
title: 'Отключённые приложения',
|
||
description: 'Удалить все отключённые приложения из каталога. Действие необратимо.',
|
||
action: 'Удалить отключённые приложения',
|
||
confirm: 'Удалить все отключённые приложения? Действие необратимо.',
|
||
deleted: 'Удалено приложений: {{count}}.',
|
||
},
|
||
factoryReset: {
|
||
dangerZone: 'Опасная зона',
|
||
title: 'Полный сброс панели',
|
||
description: 'Вернуть панель в состояние только что развёрнутой — удалить всех пользователей, конфиги, ноды и все данные. Ваш текущий аккаунт сохранится.',
|
||
action: 'Сбросить панель',
|
||
dialogTitle: 'Полный сброс панели',
|
||
warningIntro: 'Будет удалено безвозвратно:',
|
||
warningItems: [
|
||
'Все пользователи, кроме вашего текущего аккаунта',
|
||
'Все VPN-конфиги (сначала будут отозваны на нодах 3x-ui)',
|
||
'Все ноды и инбаунды — регистрация серверов 3x-ui и сохранённые креды',
|
||
'Все обращения в поддержку вместе с перепиской и вложениями',
|
||
'Все новости',
|
||
'Весь журнал аудита',
|
||
'Все роли, кроме системных admin/user',
|
||
'Каталог приложений (будет пересеян из стартового набора)',
|
||
],
|
||
irreversible: 'Действие необратимо и не может быть отменено.',
|
||
confirmPhrase: 'СБРОС',
|
||
confirmInputLabel: 'Введите «{{phrase}}» для подтверждения',
|
||
confirmButton: 'Сбросить панель безвозвратно',
|
||
done: 'Панель сброшена до начального состояния.',
|
||
},
|
||
},
|
||
stats: {
|
||
totalUsers: 'Всего пользователей',
|
||
activatedUsers: 'Активировано',
|
||
pendingActivationRequests: 'Ожидают активации',
|
||
totalNodes: 'Всего нод',
|
||
onlineNodes: 'Нод онлайн',
|
||
totalConfigs: 'Всего конфигов',
|
||
activeConfigs: 'Активных конфигов',
|
||
totalTraffic: 'Суммарный трафик',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
en: {
|
||
translation: {
|
||
appName: 'PnvPanel',
|
||
tagline: 'Self-service portal for VPN configurations',
|
||
theme: 'Theme',
|
||
language: 'Language',
|
||
light: 'Light',
|
||
dark: 'Dark',
|
||
system: 'System',
|
||
unlimited: 'Unlimited',
|
||
|
||
auth: {
|
||
loginTitle: 'Log in',
|
||
registerTitle: 'Register',
|
||
userName: 'Username',
|
||
password: 'Password',
|
||
submitLogin: 'Log in',
|
||
submitRegister: 'Register',
|
||
noAccount: "Don't have an account?",
|
||
haveAccount: 'Already have an account?',
|
||
goRegister: 'Register',
|
||
goLogin: 'Log in',
|
||
loginViaTelegram: 'Log in via Telegram',
|
||
invalidCredentials: 'Invalid username or password.',
|
||
duplicateUserName: 'A user with this name already exists.',
|
||
genericError: 'Something went wrong. Please try again.',
|
||
userNameHint: 'Latin letters, digits, "_", ".", "-", 3 to 32 characters.',
|
||
passwordHint: 'At least 8 characters, with one uppercase letter and one digit.',
|
||
or: 'or',
|
||
telegramBotNotConfigured: 'The Telegram bot has not been configured by the administrator.',
|
||
telegramQrHint:
|
||
'Follow the link or scan the QR code — it opens the bot in Telegram. If this is your first time, tap "Start".',
|
||
waitingForConfirmation: 'Waiting for confirmation in Telegram…',
|
||
telegramLoginRejected: 'Login was rejected in Telegram.',
|
||
telegramLoginExpired: 'The request expired, please try again.',
|
||
},
|
||
|
||
telegramLinkWarning: {
|
||
message: "You haven't linked Telegram — without it you won't receive notifications and won't be able to recover your password yourself.",
|
||
action: 'Link Telegram',
|
||
dismiss: 'Dismiss',
|
||
},
|
||
|
||
nav: {
|
||
dashboard: 'My configs',
|
||
instructions: 'Instructions',
|
||
news: 'News',
|
||
support: 'Support',
|
||
billing: 'Billing',
|
||
settings: 'Settings',
|
||
admin: 'Admin',
|
||
logout: 'Log out',
|
||
toggleMenu: 'Menu',
|
||
},
|
||
|
||
activation: {
|
||
title: 'Account not activated',
|
||
description:
|
||
'Wait for an administrator to activate your account before creating configs. You can leave a comment with your request.',
|
||
commentLabel: 'Comment (optional)',
|
||
submit: 'Request activation',
|
||
pending: 'Activation request sent, waiting for administrator review.',
|
||
alreadyPending: 'You already have a pending activation request.',
|
||
retry: 'Retry',
|
||
},
|
||
|
||
configs: {
|
||
title: 'My configs',
|
||
quota: 'Used {{used}} of {{max}}',
|
||
quotaUnlimited: 'Used {{used}}, unlimited',
|
||
empty: "You don't have any configs yet. Create your first one.",
|
||
create: 'Create config',
|
||
selectLocation: 'Select location',
|
||
location: 'Location',
|
||
label: 'Label (optional)',
|
||
noInboundsNotice: 'No locations are available for creating a config yet. Please contact the administrator — a server needs to be added.',
|
||
billingRequiredNotice: 'Your subscription has expired — creating new configs is unavailable until you renew.',
|
||
goToBilling: 'Go to payment',
|
||
created: 'Config created.',
|
||
quotaExceeded: 'Config quota reached for your role.',
|
||
showLink: 'Link / QR',
|
||
rotate: 'Rotate',
|
||
rotated: 'Config rotated.',
|
||
revoked: 'Config revoked.',
|
||
confirmRevoke: 'Revoke this config? This cannot be undone.',
|
||
copied: 'Copied.',
|
||
copyLink: 'Copy link',
|
||
loadingLink: 'Loading link…',
|
||
subscriptionLink: 'Subscription link (for the client app):',
|
||
panelEmail: 'Panel email: {{email}}',
|
||
aggregatedSubscription: 'Aggregated subscription',
|
||
aggregatedSubscriptionHint: 'One link/QR with all active configs — add it once to your client.',
|
||
status: {
|
||
Active: 'Active',
|
||
Disabled: 'Disabled',
|
||
Expired: 'Expired',
|
||
LimitReached: 'Limit reached',
|
||
Revoked: 'Revoked',
|
||
},
|
||
},
|
||
|
||
billing: {
|
||
title: 'Subscription billing',
|
||
notApplicable: 'Billing does not apply to your role.',
|
||
status: 'Payment status',
|
||
suspended: 'Suspended',
|
||
paidUntil: 'Paid until {{date}}',
|
||
paidUntilLabel: 'Payment:',
|
||
neverPaid: 'No payment has been made yet.',
|
||
neverPaidShort: 'Not paid',
|
||
expiredShort: 'Expired',
|
||
daysRemaining: '{{count}}d',
|
||
hoursMinutesRemaining: '{{hours}}h {{minutes}}m',
|
||
minutesRemaining: '{{count}}m',
|
||
pendingReviewShort: 'Under review',
|
||
pendingReviewHint: 'Payment sent for admin review — access will not be suspended until the request is decided.',
|
||
paidUntilWithRemaining: 'until {{date}} ({{remaining}})',
|
||
newRequest: 'Set up payment',
|
||
period: 'Period',
|
||
periods: {
|
||
Quarter: '3 months',
|
||
HalfYear: 'Half a year',
|
||
Year: 'A year',
|
||
},
|
||
createRequest: 'Create request',
|
||
requestCreated: 'Request created.',
|
||
activeRequest: 'Payment request',
|
||
requestStatus: {
|
||
AwaitingPayment: 'Awaiting payment',
|
||
AwaitingConfirmation: 'Under admin review',
|
||
},
|
||
requisites: 'Payment details',
|
||
requisitesMissing: 'The administrator has not set up payment details yet.',
|
||
sendToTelegram: 'Send to Telegram',
|
||
requisitesSent: 'Payment details sent to Telegram.',
|
||
telegramNotLinked: 'Link Telegram in settings first.',
|
||
iPaid: 'I paid',
|
||
markedPaid: 'Marked — the administrator will verify the payment.',
|
||
cancelRequest: 'Cancel',
|
||
confirmCancel: 'Cancel this payment request?',
|
||
requestCancelled: 'Request cancelled.',
|
||
awaitingAdminHint: 'The administrator has been notified and will verify the payment. Configs stay active until the request is decided.',
|
||
roleChangeTopUp: 'Role change top-up',
|
||
roleChangeTopUpHint: "Your new role costs more than the old one — this amount covers the price difference for the remaining part of your already-paid period; your subscription end date doesn't change.",
|
||
},
|
||
|
||
instructions: {
|
||
title: 'Connection instructions',
|
||
appsTitle: 'Apps',
|
||
noApps: 'The app catalog is empty right now.',
|
||
recommended: 'Recommended',
|
||
os: {
|
||
IOS: 'iOS',
|
||
Android: 'Android',
|
||
Windows: 'Windows',
|
||
MacOS: 'macOS',
|
||
Linux: 'Linux',
|
||
},
|
||
},
|
||
|
||
news: {
|
||
title: 'News',
|
||
empty: 'No news yet.',
|
||
},
|
||
|
||
support: {
|
||
title: 'Support',
|
||
empty: 'You have no tickets yet.',
|
||
reportBug: 'Report a bug',
|
||
requestRole: 'Request a role',
|
||
requestExtension: 'Request an extension',
|
||
requestedDaysLabel: 'How many days you need',
|
||
requestedExtension: 'Requested a {{days}}-day extension',
|
||
extensionRequestPending: 'You already have a pending extension request.',
|
||
submit: 'Submit',
|
||
messageLabel: 'Describe the issue or suggestion',
|
||
attachmentsLabel: 'Screenshots (optional, up to 5)',
|
||
filesSelected: '{{count}} file(s) selected',
|
||
existingRole: 'Existing role',
|
||
newRole: 'New role',
|
||
selectRole: 'Select a role',
|
||
roleOption: '{{name}} — configs: {{configs}}, IP: {{ip}}',
|
||
newRoleName: 'Role name',
|
||
newRoleMaxConfigs: 'Max configs (-1 = unlimited)',
|
||
newRoleMaxIpLimit: 'Max IPs (-1 = unlimited)',
|
||
pricingTitle: 'Estimated cost',
|
||
pricingQuarter: '3 months: {{price}}',
|
||
pricingHalfYear: '6 months: {{price}}',
|
||
pricingYear: 'Year: {{price}}',
|
||
pricingDisclaimer: 'Prices are indicative only at this time.',
|
||
pricingDiscounted: '{{price}} ₽ (instead of {{original}} ₽, {{percent}}% off)',
|
||
justification: 'Justification',
|
||
ticketCreated: 'Ticket submitted.',
|
||
roleRequestPending: 'You already have a pending role request.',
|
||
reply: 'Reply',
|
||
replyPlaceholder: 'Write a comment…',
|
||
reopen: 'Reopen',
|
||
reopened: 'Ticket reopened.',
|
||
lastActivity: 'Last activity: {{date}}',
|
||
requestedExistingRole: 'Requested role: {{role}}',
|
||
requestedNewRole: 'Requested new role "{{name}}" (configs: {{configs}}, IPs: {{ip}})',
|
||
type: {
|
||
BugReport: 'Bug/suggestion',
|
||
RoleRequest: 'Role request',
|
||
ExtensionRequest: 'Extension request',
|
||
},
|
||
status: {
|
||
Open: 'Open',
|
||
Resolved: 'Resolved',
|
||
Closed: 'Closed',
|
||
},
|
||
},
|
||
|
||
settings: {
|
||
changePassword: 'Change password',
|
||
currentPassword: 'Current password',
|
||
currentPasswordRequired: 'Enter your current password.',
|
||
currentPasswordInvalid: 'Current password is incorrect.',
|
||
newPassword: 'New password',
|
||
passwordChanged: 'Password changed.',
|
||
changeUserName: 'Change username',
|
||
changeUserNameHint: 'Current username: {{current}}.',
|
||
newUserName: 'New username',
|
||
userNameChanged: 'Username changed.',
|
||
telegramHint: 'Linking Telegram enables passwordless login and account recovery.',
|
||
telegramLinkedStatus: 'Linked',
|
||
link: 'Link Telegram',
|
||
unlink: 'Unlink',
|
||
confirmUnlink: 'Unlink Telegram from your account?',
|
||
telegramLinked: 'Telegram linked.',
|
||
telegramUnlinked: 'Telegram unlinked.',
|
||
waitingForLink: 'Waiting for confirmation in Telegram…',
|
||
deleteAccount: 'Delete account',
|
||
deleteAccountHint: 'Revokes all configs and permanently deletes your account.',
|
||
confirmDelete: 'Are you sure? This cannot be undone.',
|
||
confirmDeleteYes: 'Yes, delete',
|
||
cancel: 'Cancel',
|
||
},
|
||
|
||
admin: {
|
||
prev: 'Previous',
|
||
next: 'Next',
|
||
tabs: {
|
||
overview: 'Overview',
|
||
activation: 'Activation requests',
|
||
users: 'Users',
|
||
configs: 'All configs',
|
||
roles: 'Roles',
|
||
pricing: 'Pricing',
|
||
billing: 'Billing',
|
||
nodes: 'Nodes',
|
||
apps: 'Apps',
|
||
instructions: 'Instructions',
|
||
news: 'News',
|
||
support: 'Tickets',
|
||
audit: 'Audit',
|
||
maintenance: 'Maintenance',
|
||
},
|
||
users: {
|
||
searchPlaceholder: 'Search by username',
|
||
userName: 'Username',
|
||
role: 'Role',
|
||
statusLabel: 'Status',
|
||
billingLabel: 'Paid until',
|
||
manage: 'Manage',
|
||
empty: 'No users found.',
|
||
total: 'Total: {{count}}',
|
||
status: {
|
||
blocked: 'Blocked',
|
||
active: 'Active',
|
||
pending: 'Not activated',
|
||
},
|
||
block: 'Block',
|
||
unblock: 'Unblock',
|
||
confirmBlock: 'Block this user? All their configs will be disabled.',
|
||
blocked: 'User blocked.',
|
||
unblocked: 'User unblocked.',
|
||
roleChanged: 'Role changed.',
|
||
resetPassword: 'Reset password',
|
||
reset: 'Reset',
|
||
passwordReset: 'Password reset.',
|
||
configs: 'Configs',
|
||
delete: 'Delete user',
|
||
confirmDelete: 'Delete this user? All their configs will be revoked in 3x-ui — this cannot be undone.',
|
||
deleted: 'User deleted.',
|
||
giftDaysPlaceholder: 'Days',
|
||
giftGrant: 'Grant',
|
||
giftGranted: 'Subscription days granted to the user.',
|
||
},
|
||
configs: {
|
||
searchPlaceholder: 'Search by panel email or label',
|
||
owner: 'Owner',
|
||
node: 'Node',
|
||
location: 'Location',
|
||
protocol: 'Protocol',
|
||
traffic: 'Traffic',
|
||
statusLabel: 'Status',
|
||
allStatuses: 'All statuses',
|
||
created: 'Created',
|
||
empty: 'No configs found.',
|
||
total: 'Total: {{count}}',
|
||
},
|
||
activation: {
|
||
empty: 'No pending activation requests.',
|
||
approved: 'User activated.',
|
||
rejected: 'Request rejected.',
|
||
approve: 'Approve',
|
||
reject: 'Reject',
|
||
},
|
||
roles: {
|
||
create: 'Create role',
|
||
name: 'Name',
|
||
maxConfigs: 'Config quota',
|
||
maxConfigsHint: '−1 = unlimited.',
|
||
maxIpLimit: 'IP limit per config',
|
||
maxIpLimitHint: '−1 = unlimited. Applies to new configs only.',
|
||
totalPerQuarter: 'Total / 3 months',
|
||
totalHalfYear: 'Total / 6 months',
|
||
totalPerYear: 'Total / year',
|
||
noPrice: '—',
|
||
system: 'system',
|
||
edit: 'Edit',
|
||
save: 'Save',
|
||
delete: 'Delete',
|
||
confirmDelete: 'Delete this role? This cannot be undone.',
|
||
created: 'Role created.',
|
||
updated: 'Quota updated.',
|
||
deleted: 'Role deleted.',
|
||
billingEnabledLabel: 'Enable billing for this role',
|
||
billingBadge: 'billing',
|
||
},
|
||
pricing: {
|
||
title: 'Reference config price',
|
||
pricePerConfigPerQuarter: 'Price per config per month / billed every 3 months',
|
||
pricePerConfigPerQuarterHint: 'Minimum billing period. Visible to admin only. Total for a period = rate × months × role quota.',
|
||
pricePerConfigPerHalfYear: 'Price per config per month / billed every 6 months',
|
||
pricePerConfigPerHalfYearHint: 'Can be lower than the quarterly rate (discount for a 6-month prepayment), but the 6-month total must not be cheaper than the 3-month total.',
|
||
halfYearCheaperThanQuarter: 'The 6-month price (over 6 months) cannot be lower than the 3-month price.',
|
||
pricePerConfigPerYear: 'Price per config per month / billed yearly',
|
||
pricePerConfigPerYearHint: 'Can be lower than the 6-month rate (annual discount), but the yearly total must not be cheaper than the 6-month total.',
|
||
yearCheaperThanHalfYear: 'The annual price (over 12 months) cannot be lower than the 6-month price.',
|
||
yearCheaperThanQuarter: 'The annual price (over 12 months) cannot be lower than the 3-month price.',
|
||
updated: 'Pricing updated.',
|
||
discountTiersTitle: 'Volume discount',
|
||
discountTiersHint: 'A role with a config quota at or above the threshold gets the listed discount off the period total. The highest applicable threshold wins — discounts do not stack.',
|
||
discountTierMinConfigs: 'From how many configs',
|
||
discountTierPercent: 'Discount, %',
|
||
addDiscountTier: 'Add tier',
|
||
removeDiscountTier: 'Remove',
|
||
discountTierInvalidMinConfigs: 'Threshold must be a whole number of configs, at least 1.',
|
||
discountTierInvalidPercent: 'Discount must be a whole number from 1 to 99.',
|
||
discountTiersDuplicate: 'Discount tier thresholds cannot repeat.',
|
||
discountTiersNotProgressive: 'A higher threshold cannot have a smaller discount than a lower one.',
|
||
},
|
||
billing: {
|
||
settingsTitle: 'Billing settings',
|
||
requisitesText: 'Payment details',
|
||
requisitesTextHint: 'Shown to the user alongside their payment request.',
|
||
graceDays: 'Grace period (days)',
|
||
graceDaysHint: 'How many days access stays active after a billing role is assigned, before the first payment is due.',
|
||
defaultBillingEnabledForNewRolesLabel: 'New roles default to billing enabled',
|
||
settingsUpdated: 'Billing settings updated.',
|
||
requestsTitle: 'Payment requests',
|
||
allStatuses: 'All statuses',
|
||
user: 'User',
|
||
period: 'Period',
|
||
amount: 'Amount',
|
||
statusLabel: 'Status',
|
||
created: 'Created',
|
||
empty: 'No requests found.',
|
||
confirm: 'Confirm',
|
||
reject: 'Reject',
|
||
confirmed: 'Payment confirmed.',
|
||
rejected: 'Request rejected.',
|
||
rejectReasonPrompt: 'Rejection reason (optional):',
|
||
status: {
|
||
AwaitingPayment: 'Awaiting payment',
|
||
AwaitingConfirmation: 'Under review',
|
||
Confirmed: 'Confirmed',
|
||
Rejected: 'Rejected',
|
||
Cancelled: 'Cancelled',
|
||
},
|
||
},
|
||
nodes: {
|
||
create: 'Add node',
|
||
name: 'Name',
|
||
baseAddress: 'Panel address',
|
||
username: 'Username',
|
||
password: 'Password',
|
||
location: 'Location',
|
||
empty: 'No nodes added yet.',
|
||
created: 'Node added.',
|
||
updated: 'Node updated.',
|
||
deleted: 'Node deleted.',
|
||
confirmDelete: 'Delete this node? Existing configs on it will stop syncing.',
|
||
edit: 'Edit',
|
||
delete: 'Delete',
|
||
probe: 'Probe',
|
||
sync: 'Sync',
|
||
probeSuccess: 'Node is reachable.',
|
||
probeFailure: 'Node unreachable: {{message}}',
|
||
syncSuccess: 'Synced inbounds: {{count}}',
|
||
status: {
|
||
Unknown: 'Unknown',
|
||
Online: 'Online',
|
||
Offline: 'Offline',
|
||
},
|
||
enabled: 'Enabled',
|
||
disabled: 'Disabled',
|
||
inbounds: 'Inbounds',
|
||
noInbounds: 'No inbounds found — click "Sync".',
|
||
publish: 'Publishing',
|
||
published: 'Published',
|
||
unpublished: 'Not published',
|
||
unavailable: 'Unavailable on panel',
|
||
publishSaved: 'Publishing settings saved.',
|
||
displayName: 'Display name',
|
||
allowedRoles: 'Allowed for roles',
|
||
isPublishedLabel: 'Publish inbound',
|
||
optional: 'optional',
|
||
},
|
||
apps: {
|
||
create: 'Add app',
|
||
name: 'Name',
|
||
downloadUrl: 'Download link',
|
||
os: 'OS',
|
||
description: 'Description',
|
||
iconUrl: 'Icon URL',
|
||
sortOrder: 'Sort order',
|
||
enabled: 'Enabled',
|
||
disabled: 'disabled',
|
||
recommended: 'Recommended',
|
||
empty: 'The app catalog is empty.',
|
||
created: 'App added.',
|
||
updated: 'App updated.',
|
||
deleted: 'App deleted.',
|
||
confirmDelete: 'Remove this app from the catalog?',
|
||
},
|
||
instructions: {
|
||
introTitle: 'Main instruction',
|
||
introHint: 'Shown above the tabs on the Instructions page (Markdown).',
|
||
introSaved: 'Instruction saved.',
|
||
tabsTitle: 'Additional tabs',
|
||
tabsEmpty: 'No additional tabs yet.',
|
||
createTab: 'Add tab',
|
||
tabTitle: 'Tab title',
|
||
tabBody: 'Body (Markdown)',
|
||
tabCreated: 'Tab added.',
|
||
tabUpdated: 'Tab updated.',
|
||
tabDeleted: 'Tab deleted.',
|
||
confirmDeleteTab: 'Delete this instruction tab?',
|
||
},
|
||
news: {
|
||
create: 'Add post',
|
||
title: 'Title',
|
||
body: 'Body (Markdown)',
|
||
preview: 'Preview',
|
||
empty: 'No news yet.',
|
||
created: 'Post published.',
|
||
updated: 'Post updated.',
|
||
deleted: 'Post deleted.',
|
||
confirmDelete: 'Delete this post?',
|
||
},
|
||
support: {
|
||
empty: 'No tickets yet.',
|
||
resolve: 'Resolve',
|
||
resolved: 'Ticket marked as resolved.',
|
||
close: 'Close',
|
||
closed: 'Ticket closed.',
|
||
approve: 'Approve',
|
||
approved: 'Request approved, role granted.',
|
||
reject: 'Reject',
|
||
rejected: 'Request rejected.',
|
||
},
|
||
audit: {
|
||
time: 'Time',
|
||
action: 'Action',
|
||
target: 'Target',
|
||
source: 'Source',
|
||
empty: 'The audit log is empty.',
|
||
},
|
||
maintenance: {
|
||
closedTickets: {
|
||
title: 'Closed tickets',
|
||
description: 'Delete all closed tickets along with their comments and attachments (files). This cannot be undone.',
|
||
action: 'Delete closed tickets',
|
||
confirm: 'Delete all closed tickets and their files? This cannot be undone.',
|
||
deleted: 'Deleted tickets: {{count}}.',
|
||
},
|
||
audit: {
|
||
title: 'Audit log',
|
||
description: 'Delete audit log entries older than the given number of days. This cannot be undone.',
|
||
olderThan: 'Older than',
|
||
days: 'days',
|
||
action: 'Delete old entries',
|
||
confirm: 'Delete audit entries older than {{days}} days? This cannot be undone.',
|
||
deleted: 'Deleted audit entries: {{count}}.',
|
||
},
|
||
apps: {
|
||
title: 'Disabled apps',
|
||
description: 'Delete all disabled apps from the catalog. This cannot be undone.',
|
||
action: 'Delete disabled apps',
|
||
confirm: 'Delete all disabled apps? This cannot be undone.',
|
||
deleted: 'Deleted apps: {{count}}.',
|
||
},
|
||
factoryReset: {
|
||
dangerZone: 'Danger zone',
|
||
title: 'Full panel reset',
|
||
description: 'Return the panel to a freshly-deployed state — deletes all users, configs, nodes and data. Your current account is kept.',
|
||
action: 'Reset panel',
|
||
dialogTitle: 'Full panel reset',
|
||
warningIntro: 'This will permanently delete:',
|
||
warningItems: [
|
||
'All users except your current account',
|
||
'All VPN configs (revoked on the 3x-ui nodes first)',
|
||
'All nodes and inbounds — 3x-ui server registrations and stored credentials',
|
||
'All support tickets with their comments and attachments',
|
||
'All news posts',
|
||
'The entire audit log',
|
||
'All roles except the system admin/user roles',
|
||
'The app catalog (will be re-seeded from the default set)',
|
||
],
|
||
irreversible: 'This cannot be undone.',
|
||
confirmPhrase: 'RESET',
|
||
confirmInputLabel: 'Type "{{phrase}}" to confirm',
|
||
confirmButton: 'Reset the panel permanently',
|
||
done: 'The panel has been reset to its initial state.',
|
||
},
|
||
},
|
||
stats: {
|
||
totalUsers: 'Total users',
|
||
activatedUsers: 'Activated',
|
||
pendingActivationRequests: 'Pending activation',
|
||
totalNodes: 'Total nodes',
|
||
onlineNodes: 'Nodes online',
|
||
totalConfigs: 'Total configs',
|
||
activeConfigs: 'Active configs',
|
||
totalTraffic: 'Total traffic',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
const STORAGE_KEY = 'pnv-lang'
|
||
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null
|
||
|
||
void i18n.use(initReactI18next).init({
|
||
resources,
|
||
lng: stored ?? 'ru',
|
||
fallbackLng: 'ru',
|
||
interpolation: { escapeValue: false },
|
||
})
|
||
|
||
export function setLanguage(lng: string) {
|
||
localStorage.setItem(STORAGE_KEY, lng)
|
||
void i18n.changeLanguage(lng)
|
||
}
|
||
|
||
export default i18n
|