feat(i18n): add Russian locale for web UI and server messages

Register ru in i18next and the language switcher, with MessageSource
bundle messages_ru.properties. Keep CLI/commands and brand names as-is.

Signed-off-by: Mikhail Neradkov <michael.neradkov@gmail.com>
This commit is contained in:
Mikhail Neradkov 2026-07-20 15:03:24 +03:00
parent 2e4a69d590
commit 62c27696d2
8 changed files with 1824 additions and 9 deletions

View file

@ -0,0 +1,180 @@
response.success=Успешно
response.success.read=Успешно получено
response.success.created=Успешно создано
response.success.updated=Успешно обновлено
response.success.deleted=Успешно удалено
response.success.published=Успешно опубликовано
response.success.revoked=Успешно отозвано
response.success.health=Сервис работает
validation.namespace.slug.notBlank=Slug не может быть пустым
validation.namespace.slug.size=Slug должен содержать от 2 до 64 символов
validation.namespace.displayName.notBlank=Отображаемое имя не может быть пустым
validation.namespace.displayName.size=Отображаемое имя не должно превышать 128 символов
validation.namespace.description.size=Описание не должно превышать 512 символов
validation.member.userId.notNull=Требуется ID пользователя
validation.member.role.notNull=Требуется роль
validation.auth.local.username.notBlank=Имя пользователя не может быть пустым
validation.auth.local.password.notBlank=Пароль не может быть пустым
validation.auth.local.email.notBlank=Email не может быть пустым
validation.auth.local.currentPassword.notBlank=Текущий пароль не может быть пустым
validation.auth.local.newPassword.notBlank=Новый пароль не может быть пустым
validation.auth.local.email.invalid=Некорректный формат email
validation.token.name.notBlank=Имя токена не может быть пустым
validation.token.name.size=Имя токена должно содержать не более 64 символов
validation.token.expiresAt.invalid=Некорректный формат времени истечения
validation.token.expiresAt.future=Время истечения должно быть в будущем
error.token.name.duplicate=У вас уже есть токен с таким именем
error.token.notFound=Токен не найден: {0}
error.auth.required=Требуется аутентификация
error.auth.local.username.exists=Имя пользователя уже занято
error.auth.local.email.exists=Email уже занят
error.auth.local.password.tooShort=Пароль должен содержать не менее 8 символов
error.auth.local.password.tooLong=Пароль не должен превышать 128 символов
error.auth.local.password.tooWeak=Пароль должен включать не менее 3 типов символов
error.auth.local.username.invalid=Имя пользователя: 3–64 символа, только буквы, цифры или подчёркивания
error.auth.local.invalidCredentials=Неверное имя пользователя или пароль
error.auth.local.notEnabled=Вход по локальной учётной записи для этого пользователя не включён
error.auth.local.accountDisabled=Эта учётная запись отключена
error.auth.local.accountPending=Эта учётная запись ожидает активации
error.auth.local.accountMerged=Эта учётная запись объединена и больше не может использоваться для входа
error.auth.local.locked=Слишком много неудачных попыток. Повторите через {0} мин.
error.auth.login.throttled=Слишком много попыток входа. Повторите через {0} мин.
error.auth.direct.disabled=Совместимость прямой аутентификации отключена
error.auth.direct.providerUnsupported=Неподдерживаемый провайдер прямой аутентификации: {0}
error.auth.sessionBootstrap.disabled=Инициализация сессии отключена
error.auth.sessionBootstrap.providerUnsupported=Неподдерживаемый провайдер инициализации сессии: {0}
error.auth.sessionBootstrap.notAuthenticated=Внешняя аутентифицированная сессия не найдена
error.badRequest=Некорректный запрос
error.forbidden=Доступ запрещён
error.request.timeout=Время ожидания запроса истекло
error.rateLimit.exceeded=Превышен лимит запросов
error.storage.unavailable=Объектное хранилище временно недоступно. Повторите попытку позже.
error.internal=Произошла непредвиденная ошибка
error.slug.blank=Slug не может быть пустым
error.slug.length=Длина slug должна быть от {0} до {1} символов
error.slug.pattern=Slug может содержать только строчные буквы, цифры и дефисы и должен начинаться и заканчиваться буквой или цифрой
error.slug.doubleHyphen=Slug не может содержать два дефиса подряд
error.slug.reserved=Slug ''{0}'' зарезервирован и не может быть использован
label.definition.too_many=Достигнут лимит определений меток ({0})
label.sort_order.empty=Полезная нагрузка обновления порядка сортировки не может быть пустой
label.translation.empty=Требуется хотя бы один перевод метки
label.translation.locale.blank=Локаль перевода метки не может быть пустой
label.translation.display_name.blank=Отображаемое имя перевода метки не может быть пустым
label.translation.locale.duplicate=Дублирующаяся локаль перевода метки: {0}
label.translation.locale.conflict=Дублирующаяся локаль перевода метки
error.namespace.slug.exists=Slug пространства имён ''{0}'' уже существует
error.namespace.id.notFound=Пространство имён не найдено: {0}
error.namespace.slug.notFound=Пространство имён не найдено: {0}
error.namespace.membership.required=Требуется членство в пространстве имён
error.namespace.global.members.platformAdmin.required=Только администраторы пользователей платформы могут просматривать участников глобального пространства имён
error.namespace.admin.required=Требуется роль владельца или администратора пространства имён
error.namespace.owner.required=Требуется роль владельца пространства имён
error.namespace.create.platformAdminRequired=Создавать пространства имён могут только SKILL_ADMIN или SUPER_ADMIN
error.namespace.delete.hasDependencies=Пространство имён нельзя удалить, пока в нём есть скиллы или записи управления
error.namespace.member.owner.assignDirect=Нельзя назначить роль OWNER напрямую
error.namespace.member.alreadyExists=Пользователь уже является участником пространства имён
error.namespace.member.notFound=Участник не найден
error.namespace.member.owner.remove=Нельзя удалить владельца пространства имён
error.namespace.member.owner.setDirect=Нельзя задать роль OWNER напрямую, используйте передачу владения
error.namespace.member.search.tooShort=Поисковый запрос должен содержать не менее 2 символов
error.namespace.owner.current.notFound=Текущий владелец не найден
error.namespace.owner.current.invalid=Текущий пользователь не является владельцем пространства имён
error.namespace.owner.new.notFound=Новый владелец не является участником пространства имён
error.skill.metadata.content.empty=Содержимое SKILL.md не может быть пустым
error.skill.metadata.frontmatter.missingStart=Отсутствует начальный маркер frontmatter ''---''
error.skill.metadata.frontmatter.missingContent=Отсутствует содержимое frontmatter после начального маркера
error.skill.metadata.frontmatter.missingEnd=Отсутствует конечный маркер frontmatter ''---''
error.skill.metadata.yaml.notMap=Frontmatter должен быть YAML-объектом
error.skill.metadata.yaml.invalid=Некорректный YAML во frontmatter: {0}
error.skill.metadata.requiredField.missing=Отсутствует обязательное поле: {0}
error.skill.publish.publisher.notMember=Публикующий не является участником пространства имён: {0}
error.skill.publish.package.invalid=Проверка пакета не пройдена: {0}
error.skill.publish.skillMd.notFound=SKILL.md не найден
error.skill.publish.precheck.confirmRequired=Предупреждения перед публикацией требуют подтверждения:\n{0}
error.skill.publish.precheck.failed=Проверка перед публикацией не пройдена: {0}
error.security.scanner.required=Перед публикацией публичных или видимых в пространстве имён скиллов необходимо включить сканер безопасности
error.skill.publish.archived=Архивный скилл нужно восстановить перед публикацией: {0}
review.withdraw.not_pending=Отозвать можно только заявки на ревью со статусом pending: {0}
review.withdraw.not_submitter=Отозвать это ревью может только отправитель
review.approve.scan_in_progress=Сканирование безопасности ещё выполняется. Одобрение недоступно до завершения сканирования.
review_task.not_found_for_version=Не найдена ожидающая заявка на ревью для версии: {0}
error.skill.publish.summary.tooLong=Описание скилла не должно превышать {0} символов
error.skill.notFound=Скилл не найден: {0}
error.skill.access.denied=Нет доступа к скиллу: {0}
error.skill.status.notActive=Скилл не активен
error.skill.lifecycle.noPermission=Управлять этим скиллом может только владелец скилла или администратор пространства имён
error.skill.version.exists=Версия уже существует: {0}
error.skill.version.notFound=Версия не найдена: {0}
error.skill.version.notPublished=Версия не опубликована: {0}
error.skill.version.delete.unsupported=Удалять можно только версии в статусах DRAFT, UPLOADED, REJECTED или SCAN_FAILED: {0}
error.skill.version.delete.lastVersion=Нельзя удалить последнюю оставшуюся версию: {0}
error.skill.version.compare.same=Нельзя сравнить версию саму с собой
error.skill.report.reason.required=Укажите причину жалобы
error.skill.report.unavailable=Сейчас на этот скилл нельзя пожаловаться: {0}
error.skill.report.self=Нельзя пожаловаться на свой собственный скилл
error.skill.report.duplicate=У вас уже есть ожидающая жалоба на этот скилл
error.skill.report.notFound=Жалоба на скилл не найдена: {0}
error.skill.report.alreadyHandled=Эта жалоба на скилл уже обработана
error.skill.report.status.invalid=Неподдерживаемый статус жалобы на скилл: {0}
error.skill.version.latest.unavailable=Нет опубликованной версии для скилла: {0}
error.skill.version.latest.notFound=Последняя опубликованная версия не найдена
error.skill.file.notFound=Файл не найден: {0}
error.skill.tag.latest.reserved=Имя тега ''latest'' зарезервировано
error.skill.tag.latest.delete=Имя тега ''latest'' зарезервировано и не может быть удалено
error.skill.tag.notFound=Тег не найден: {0}
error.skill.tag.targetVersion.notPublished=Целевая версия должна быть опубликована
error.skill.tag.version.missing=Тег не указывает на версию: {0}
error.skill.tag.version.notFound=Версия, на которую указывает тег, не найдена: {0}
error.skill.bundle.notFound=Опубликованный пакет не найден в хранилище
error.skill.resolve.versionTag.conflict=Параметры version и tag нельзя использовать вместе
error.deviceAuth.userCode.invalid=Неверный или просроченный код пользователя
error.deviceAuth.deviceCode.expired=Код устройства истёк
error.deviceAuth.deviceCode.invalid=Код устройства истёк или недействителен
error.deviceAuth.deviceCode.used=Код устройства уже использован
error.admin.user.notFound=Пользователь не найден: {0}
error.admin.user.role.invalid=Неверная роль: {0}
error.admin.user.role.superAdmin.assignDenied=Изменять состояние роли SUPER_ADMIN может только SUPER_ADMIN
error.admin.user.systemAccount.immutable=Системные учётные записи нельзя изменять из управления пользователями
error.admin.user.status.invalid=Неверный статус пользователя: {0}
error.admin.user.status.unsupported=Здесь можно управлять только статусами ACTIVE или DISABLED
error.skill.publish.nameConflict=Опубликованный скилл с именем ''{0}'' уже существует в этом пространстве имён
error.skill.publish.nameConflict.private=Приватный скилл с именем ''{0}'' уже опубликован в этом пространстве имён
error.skill.approve.nameConflict=Нельзя одобрить: опубликованный скилл с именем ''{0}'' уже существует в этом пространстве имён
error.skill.version.submit.notUploaded=Версия ''{0}'' не в статусе UPLOADED и не может быть отправлена на ревью
error.skill.version.confirm.notUploaded=Версия ''{0}'' не в статусе UPLOADED и не может быть подтверждена
error.skill.confirm.notPrivate=Confirm-publish доступен только для PRIVATE скиллов
error.skill.version.notDownloadable=Версия ''{0}'' недоступна для скачивания
# Profile update
error.profile.displayName.length=Отображаемое имя должно содержать от 2 до 32 символов
error.profile.displayName.pattern=Отображаемое имя может содержать только китайские иероглифы, латинские буквы, цифры, пробелы, подчёркивания и дефисы
error.profile.noChanges=Необходимо указать хотя бы одно поле
response.profile.updated=Профиль успешно обновлён
response.profile.pendingReview=Изменения профиля отправлены на ревью
error.profileReview.notFound=Заявка на изменение профиля не найдена
error.profileReview.notPending=Эта заявка уже рассмотрена
error.profileReview.commentRequired=Требуется причина отклонения
error.profileReview.commentTooLong=Причина отклонения не должна превышать 500 символов
error.profileReview.status.invalid=Неверный статус ревью: {0}
error.profileReview.userDisabled=Нельзя применить изменения — учётная запись пользователя отключена
# Password reset
response.auth.password.reset.requested=Если учётная запись подходит, код подтверждения для сброса пароля отправлен.
response.auth.password.reset.confirmed=Пароль успешно сброшен. Войдите с новым паролем.
error.auth.password.reset.invalid.code=Код подтверждения недействителен или истёк.
error.auth.password.reset.not.eligible=Эта учётная запись не подходит для сброса пароля.
error.auth.password.reset.no.credential=У этой учётной записи нет локальных учётных данных.
error.auth.password.reset.email.failed=Не удалось отправить код подтверждения для сброса пароля. Повторите попытку позже.
validation.auth.password.reset.email.notBlank=Email не может быть пустым
validation.auth.password.reset.email.invalid=Некорректный формат email
validation.auth.password.reset.code.notBlank=Код подтверждения не может быть пустым
validation.auth.password.reset.code.invalid=Код подтверждения должен состоять из 6 цифр
validation.auth.password.reset.newPassword.notBlank=Новый пароль не может быть пустым
promotion.target_skill_conflict=Целевой глобальный скилл "{0}" уже существует
promotion.status.invalid=Неподдерживаемый статус продвижения: {0}
promotion.sort.field.invalid=Неподдерживаемое поле сортировки продвижения: {0}
promotion.sort.direction.invalid=Неподдерживаемое направление сортировки продвижения: {0}
promotion.sort.pending_unsupported=Ожидающие заявки на продвижение не поддерживают сортировку по времени ревью

View file

@ -29,6 +29,10 @@ vi.mock('./locales/zh.json', () => ({
default: { greeting: '你好' },
}))
vi.mock('./locales/ru.json', () => ({
default: { greeting: 'Привет' },
}))
// Import triggers the side-effect initialization
await import('./config')
@ -54,11 +58,13 @@ describe('i18n config', () => {
expect(initOptions.detection.caches).toEqual(['localStorage'])
})
it('registers both english and chinese resource bundles', () => {
it('registers english, russian and chinese resource bundles', () => {
const initOptions = initMock.mock.calls[0][0]
expect(initOptions.resources).toHaveProperty('en')
expect(initOptions.resources).toHaveProperty('ru')
expect(initOptions.resources).toHaveProperty('zh')
expect(initOptions.resources.en).toHaveProperty('translation')
expect(initOptions.resources.ru).toHaveProperty('translation')
expect(initOptions.resources.zh).toHaveProperty('translation')
})
})

View file

@ -2,6 +2,7 @@ import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import en from './locales/en.json'
import ru from './locales/ru.json'
import zh from './locales/zh.json'
/**
@ -15,6 +16,7 @@ i18n
.init({
resources: {
en: { translation: en },
ru: { translation: ru },
zh: { translation: zh },
},
fallbackLng: 'en',

1552
web/src/i18n/locales/ru.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import en from './locales/en.json'
import ru from './locales/ru.json'
function leafKeys(value: unknown, prefix = ''): string[] {
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
return Object.entries(value as Record<string, unknown>).flatMap(([key, child]) =>
leafKeys(child, prefix ? `${prefix}.${key}` : key),
)
}
return [prefix]
}
function placeholders(text: string): string[] {
return [...text.matchAll(/\{\{[^}]+\}\}/g)].map((match) => match[0]).sort()
}
describe('russian locale', () => {
it('mirrors the english key tree', () => {
expect(leafKeys(ru).sort()).toEqual(leafKeys(en).sort())
})
it('preserves interpolation placeholders', () => {
const enMap = Object.fromEntries(leafKeys(en).map((key) => {
const parts = key.split('.')
let cursor: unknown = en
for (const part of parts) {
cursor = (cursor as Record<string, unknown>)[part]
}
return [key, String(cursor)]
}))
const mismatches: string[] = []
for (const key of leafKeys(ru)) {
const parts = key.split('.')
let cursor: unknown = ru
for (const part of parts) {
cursor = (cursor as Record<string, unknown>)[part]
}
if (placeholders(String(cursor)).join() !== placeholders(enMap[key] ?? '').join()) {
mismatches.push(key)
}
}
expect(mismatches).toEqual([])
})
it('translates core navigation labels', () => {
expect(ru.nav.home).not.toBe(en.nav.home)
expect(ru.nav.home.length).toBeGreaterThan(0)
expect(ru.login.title.length).toBeGreaterThan(0)
})
})

View file

@ -35,11 +35,28 @@ function formatRelativeTime(dateStr: string, lang: string): string {
const hours = Math.floor(diff / 3_600_000)
const days = Math.floor(diff / 86_400_000)
const isChinese = lang.startsWith('zh')
if (minutes < 1) return isChinese ? '刚刚' : 'just now'
if (minutes < 60) return isChinese ? `${minutes}分钟` : `${minutes}m`
if (hours < 24) return isChinese ? `${hours}小时` : `${hours}h`
if (days < 30) return isChinese ? `${days}天` : `${days}d`
return new Date(dateStr).toLocaleDateString()
const isRussian = lang.startsWith('ru')
if (minutes < 1) {
if (isChinese) return '刚刚'
if (isRussian) return 'только что'
return 'just now'
}
if (minutes < 60) {
if (isChinese) return `${minutes}分钟`
if (isRussian) return `${minutes} мин`
return `${minutes}m`
}
if (hours < 24) {
if (isChinese) return `${hours}小时`
if (isRussian) return `${hours} ч`
return `${hours}h`
}
if (days < 30) {
if (isChinese) return `${days}天`
if (isRussian) return `${days} д`
return `${days}d`
}
return new Date(dateStr).toLocaleDateString(lang.startsWith('ru') ? 'ru-RU' : undefined)
}
function CategoryBadge({ category }: { category: NotificationItem['category'] }) {

View file

@ -19,11 +19,15 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
const languages = [
{ code: 'zh', name: '中文' },
{ code: 'en', name: 'English' },
{ code: 'ru', name: 'Русский' },
]
// 获取当前语言的主要代码(去掉地区代码)
const currentLangCode = i18n.language?.split('-')[0] || 'zh'
const currentLanguage = languages.find((lang) => lang.code === currentLangCode) || languages[0]
// Primary language code only (strip region, e.g. ru-RU → ru).
const currentLangCode = i18n.language?.split('-')[0] || 'en'
const currentLanguage =
languages.find((lang) => lang.code === currentLangCode)
|| languages.find((lang) => lang.code === 'en')
|| languages[0]
const changeLanguage = (langCode: string) => {
i18n.changeLanguage(langCode)

View file

@ -29,6 +29,7 @@ function isAccountDisabledError(error: ApiError): boolean {
const accountDisabledMessages = [
i18n.t('apiError.auth.accountDisabled'),
i18n.getFixedT('en')('apiError.auth.accountDisabled'),
i18n.getFixedT('ru')('apiError.auth.accountDisabled'),
i18n.getFixedT('zh')('apiError.auth.accountDisabled'),
]
const normalizedServerMessage = (error.serverMessage ?? '').toLowerCase()
@ -42,6 +43,8 @@ function isAccountDisabledError(error: ApiError): boolean {
|| normalizedMessage.includes('disabled')
|| (error.serverMessage ?? '').includes('禁用')
|| error.message.includes('禁用')
|| (error.serverMessage ?? '').toLowerCase().includes('отключ')
|| error.message.toLowerCase().includes('отключ')
}
export function handleApiError(error: unknown): void {