diff --git a/server/skillhub-app/src/main/resources/messages_ru.properties b/server/skillhub-app/src/main/resources/messages_ru.properties new file mode 100644 index 000000000..8ea9d8231 --- /dev/null +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -0,0 +1,179 @@ +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.methodNotAllowed=HTTP-метод не поддерживается +error.unsupportedMediaType=Неподдерживаемый тип медиа +error.notAcceptable=Запрошенный тип ответа не поддерживается +error.forbidden=Доступ запрещён +error.apiToken.scope.missing=У API-токена отсутствует требуемая область доступа: {0} +error.apiToken.endpoint.unsupported=API-токен не может обращаться к эндпоинту: {0} +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.metadata.compliance.invalid=Некорректные метаданные x-astron-compliance: {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}'' недоступна для скачивания +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=Нельзя применить изменения — учётная запись пользователя отключена +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=Ожидающие заявки на продвижение не поддерживают сортировку по времени ревью diff --git a/web/src/features/notification/notification-dropdown.tsx b/web/src/features/notification/notification-dropdown.tsx index a1c645321..90c0b5680 100644 --- a/web/src/features/notification/notification-dropdown.tsx +++ b/web/src/features/notification/notification-dropdown.tsx @@ -6,26 +6,12 @@ import { resolveNotificationDisplay } from './notification-content' import { useAuth } from '@/features/auth/use-auth' import { useNotifications, useMarkAllRead, useMarkRead } from './use-notifications' import { resolveNotificationTarget } from './notification-target' +import { formatRelativeTime } from '@/shared/lib/format-relative-time' interface Props { onClose: () => void } -function formatRelativeTime(dateStr: string, lang: string): string { - const diff = Date.now() - new Date(dateStr).getTime() - const minutes = Math.floor(diff / 60_000) - 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() -} - /** * Dropdown panel showing the latest 5 notifications with mark-all-read and view-all actions. */ diff --git a/web/src/i18n/config.test.ts b/web/src/i18n/config.test.ts index 843605db7..dd76b7941 100644 --- a/web/src/i18n/config.test.ts +++ b/web/src/i18n/config.test.ts @@ -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') }) }) diff --git a/web/src/i18n/config.ts b/web/src/i18n/config.ts index 9973671f8..e1cee718c 100644 --- a/web/src/i18n/config.ts +++ b/web/src/i18n/config.ts @@ -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', diff --git a/web/src/i18n/landing-quick-start-locale.test.ts b/web/src/i18n/landing-quick-start-locale.test.ts index 0fcac2474..1aeb0e8cd 100644 --- a/web/src/i18n/landing-quick-start-locale.test.ts +++ b/web/src/i18n/landing-quick-start-locale.test.ts @@ -1,16 +1,19 @@ import { describe, expect, it } from 'vitest' import en from './locales/en.json' +import ru from './locales/ru.json' import zh from './locales/zh.json' describe('landing quick start locales', () => { - it('uses localized agent setup prompts for chinese and english', () => { + it('uses localized agent setup prompts for chinese, english, and russian', () => { expect(zh.landing.quickStart.agent.command).toBe('阅读 https://www.example.com/registry/skill.md,并按照说明完成 SkillHub Skills Registry 的配置') expect(en.landing.quickStart.agent.command).toBe('Read https://www.example.com/registry/skill.md and follow the instructions to setup SkillHub Skills Registry') + expect(ru.landing.quickStart.agent.command).toBe('Прочитайте https://www.example.com/registry/skill.md и следуйте инструкциям для настройки SkillHub Skills Registry') }) it('provides command templates with url placeholder for dynamic rendering', () => { expect(zh.landing.quickStart.agent.commandTemplate).toBe('阅读 {{url}},并按照说明完成 SkillHub Skills Registry 的配置') expect(en.landing.quickStart.agent.commandTemplate).toBe('Read {{url}} and follow the instructions to setup SkillHub Skills Registry') + expect(ru.landing.quickStart.agent.commandTemplate).toBe('Прочитайте {{url}} и следуйте инструкциям для настройки SkillHub Skills Registry') }) it('exposes CLI install command in both locales', () => { diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json new file mode 100644 index 000000000..4cf756265 --- /dev/null +++ b/web/src/i18n/locales/ru.json @@ -0,0 +1,1567 @@ +{ + "compliance": { + "title": "Заявления о соответствии", + "mappingCount": "{{count}} элементов", + "unknownStandard": "Неизвестный стандарт", + "evidence": "Доказательства" + }, + "common": { + "expand": "Развернуть подробности", + "collapse": "Свернуть подробности" + }, + "nav": { + "landing": "Главная", + "home": "Центр скиллов", + "search": "Поиск", + "skillDetail": "Карточка скилла", + "explore": "Обзор скиллов", + "dashboard": "Панель управления", + "mySkills": "Мои скиллы", + "publish": "Опубликовать", + "login": "Вход" + }, + "landing": { + "hero": { + "title": "Находите и делитесь AI-скиллами", + "subtitle": "Создавайте мощных AI-агентов на скиллах сообщества", + "searchPlaceholder": "Поиск скиллов...", + "exploreSkills": "Обзор скиллов", + "publishSkill": "Опубликовать скилл" + }, + "features": { + "secure": { + "title": "Безопасность и конфиденциальность", + "description": "Корпоративный уровень защиты для ваших AI-процессов" + }, + "community": { + "title": "Сообщество", + "description": "Делитесь скиллами и находите их у разработчиков по всему миру" + }, + "integration": { + "title": "Простая интеграция", + "description": "Легко встраивается в ваши существующие инструменты" + }, + "versionControl": { + "title": "Управление версиями", + "description": "Структурированное версионирование и релизы, чтобы пакеты скиллов оставались прослеживаемыми и надёжными." + }, + "cli": { + "title": "Инструменты CLI", + "description": "Мощные сценарии командной строки для публикации, установки и управления пакетами скиллов." + }, + "governance": { + "title": "Управление рецензированием", + "description": "Встроенные процессы рецензирования и контроль прав для высокого качества корпоративных скиллов." + } + }, + "stats": { + "skills": "Каталоги", + "downloads": "Загрузки", + "teams": "Команды" + }, + "whySkillHub": { + "title": "Почему SkillHub", + "subtitle": "Частная платформа Agent-скиллов для корпоративных команд" + }, + "badge": "Корпоративный реестр скиллов", + "tagline": "Публикуйте, находите, управляйте", + "taglineHighlight": " скиллами агентов", + "description": "Самостоятельно размещаемый частный реестр скиллов для безопасного и эффективного обмена и совместной работы в командах", + "publishSkill": "Опубликовать скилл", + "statsSkills": "Скиллы", + "statsDownloads": "Загрузки", + "statsTeams": "Команды", + "whyTitle": "Почему выбирают", + "whyDescription": "Корпоративная платформа управления скиллами с полным циклом публикации, поиска и контроля", + "featuresList": { + "privateDeploy": { + "title": "Частное развёртывание", + "description": "Полностью self-hosted с суверенитетом данных. Развёртывание в вашей инфраструктуре в один клик, безопасно за межсетевым экраном." + }, + "versionControl": { + "title": "Управление версиями", + "description": "Семантическое версионирование, пользовательские теги (beta, stable), автоматическое отслеживание последней версии." + }, + "smartSearch": { + "title": "Умный поиск", + "description": "Полнотекстовый поиск с многомерной фильтрацией по пространству имён, загрузкам, рейтингам и времени." + }, + "teamwork": { + "title": "Командная работа", + "description": "Управление пространствами имён, ролевой доступ (Owner/Admin/Member), настройка политики публикации." + }, + "governance": { + "title": "Рецензирование и контроль", + "description": "Командное рецензирование, одобрение на уровне платформы, полный аудит — соответствие требованиям комплаенса." + }, + "cliFirst": { + "title": "CLI в приоритете", + "description": "Нативный REST API, совместимость с существующими инструментами ClawHub CLI — без изменений на стороне клиента." + } + }, + "ctaTitle": "Готовы начать?", + "ctaDescription": "Запустите локальное окружение одной командой и пройдите полный цикл управления скиллами", + "ctaButton": "Попробовать", + "footerLicense": "© 2026 SkillHub. Apache License 2.0.", + "footerDocs": "Документация", + "footerGithub": "GitHub", + "footerCommunity": "Сообщество", + "quickStart": { + "title": "Быстрый старт", + "subtitle": "Быстрый старт", + "description": "Выберите способ работы, скопируйте инструкцию по настройке и продолжайте", + "tip": "💡 Совет: на странице скилла есть команды установки в один клик с переменными окружения", + "tabs": { + "agent": "Я Agent", + "human": "Я человек", + "cli": "CLI" + }, + "cli": { + "description": "Установите SkillHub CLI локально, чтобы выполнять skillhub install для скиллов.", + "command": "npm i -g @astron-team/skillhub" + }, + "agent": { + "description": "Отправьте промпт своему Agent, чтобы настроить SkillHub Registry", + "command": "Прочитайте https://www.example.com/registry/skill.md и следуйте инструкциям для настройки SkillHub Skills Registry", + "commandTemplate": "Прочитайте {{url}} и следуйте инструкциям для настройки SkillHub Skills Registry" + }, + "human": { + "description": "Используйте CLI для установки Skills", + "command": "npx clawhub search " + }, + "steps": { + "configureEnv": { + "title": "1. Настройте переменные окружения", + "description": "Настройте ClawHub CLI для подключения к SkillHub" + }, + "installSkills": { + "title": "2. Установите скиллы", + "description": "Найдите и установите нужные скиллы", + "code": "# Поиск скиллов\nclawhub search \n\n# Установить скилл\nclawhub install " + }, + "publishSkills": { + "title": "3. Опубликуйте скиллы", + "description": "Делитесь скиллами с командой", + "code": "# Опубликовать скилл\nclawhub publish\n\n# Или через веб-интерфейс\n# Нажмите «Опубликовать скилл»" + } + } + } + }, + "home": { + "subtitle": "Реестр скиллов", + "description": "Эффективная платформа управления, распространения и совместной работы со скиллами для разработчиков", + "browseSkills": "Обзор скиллов", + "publishSkill": "Опубликовать скилл", + "popularTitle": "Популярные загрузки", + "popularDescription": "Самые популярные скиллы в сообществе", + "latestTitle": "Последние релизы", + "latestDescription": "Недавно опубликованные скиллы", + "viewAll": "Смотреть все →", + "quickStart": { + "title": "Быстрый старт", + "subtitle": "Быстрый старт", + "description": "Начните работу с SkillHub за несколько простых шагов", + "tip": "💡 Совет: на странице скилла есть команды установки в один клик с переменными окружения", + "steps": { + "configureEnv": { + "title": "1. Настройте переменные окружения", + "description": "Настройте ClawHub CLI для подключения к SkillHub" + }, + "installSkills": { + "title": "2. Установите скиллы", + "description": "Найдите и установите нужные скиллы", + "code": "# Поиск скиллов\nclawhub search \n\n# Установить скилл\nclawhub install " + }, + "publishSkills": { + "title": "3. Опубликуйте скиллы", + "description": "Делитесь скиллами с командой", + "code": "# Опубликовать скилл\nclawhub publish\n\n# Или через веб-интерфейс\n# Нажмите «Опубликовать скилл»" + } + } + } + }, + "search": { + "title": "Поиск скиллов", + "placeholder": "Поиск скиллов...", + "filters": { + "label": "Фильтр:" + }, + "sort": { + "label": "Сортировка:", + "relevance": "Релевантность", + "downloads": "Загрузки", + "stars": "Звёзды", + "newest": "Новизна" + }, + "noResults": "Ничего не найдено", + "noResultsFor": "Скиллы по запросу «{{q}}» не найдены", + "filterStarred": "Только избранные", + "noStarredResults": "Избранные скиллы не найдены", + "noStarredResultsFor": "Нет избранных скиллов по запросу «{{q}}»", + "noStarredSkills": "У вас пока нет избранных скиллов", + "namespaceFilter": "@{{namespace}}", + "enterKeyword": "Введите ключевое слово для поиска", + "results": "Найдено скиллов: {{count}}", + "resultCount": "Найдено результатов: <1>{{count}}", + "loadingMore": "Обновление результатов поиска..." + }, + "searchBar": { + "placeholder": "Поиск скиллов...", + "button": "Найти", + "clear": "Очистить поиск" + }, + "login": { + "title": "Вход в SkillHub", + "subtitle": "Выберите способ продолжения", + "tabPassword": "Пароль", + "tabOAuth": "OAuth", + "username": "Имя пользователя", + "password": "Пароль", + "usernamePlaceholder": "Введите имя пользователя", + "passwordPlaceholder": "Введите пароль", + "usernameRequired": "Имя пользователя обязательно", + "passwordRequired": "Пароль обязателен", + "showPassword": "Показать пароль", + "hidePassword": "Скрыть пароль", + "submitting": "Вход...", + "submit": "Войти", + "forgotPassword": "Забыли пароль?", + "noAccount": "Нет аккаунта?", + "register": "Зарегистрироваться", + "oauthHint": "После аутентификации OAuth вы будете автоматически перенаправлены обратно на этот сайт.", + "passwordCompatHint": "В этом развёртывании включён слой совместимости паролей. Форма направит запрос в {{name}} вместо фиксированной локальной учётной записи.", + "enterpriseSsoTitle": "Корпоративный SSO", + "enterpriseSsoHint": "В этом развёртывании включён слой совместимости. Если в браузере уже есть сессия {{name}}, можно попробовать сразу установить сессию SkillHub.", + "enterpriseSsoAutoHint": "В этом развёртывании включено автоматическое зондирование {{name}}. Если оно не сработает, продолжайте стандартными способами входа.", + "enterpriseSsoAction": "Попробовать {{name}}", + "enterpriseSsoSubmitting": "Пробуем {{name}}...", + "agreementPrefix": "Входя в систему, вы соглашаетесь с", + "terms": "Условиями использования", + "and": "и", + "privacy": "Политикой конфиденциальности" + }, + "register": { + "title": "Создать аккаунт", + "subtitle": "Зарегистрируйтесь локально или войдите через OAuth.", + "tabLocal": "Локальный аккаунт", + "tabOAuth": "OAuth", + "username": "Имя пользователя", + "email": "Электронная почта", + "password": "Пароль", + "usernamePlaceholder": "3–64 символа: буквы, цифры или подчёркивания", + "emailPlaceholder": "Введите email", + "emailRequired": "Email обязателен", + "passwordPlaceholder": "Не менее 8 символов, минимум 3 типа символов", + "submitting": "Регистрация...", + "submit": "Зарегистрироваться и войти", + "hasAccount": "Уже есть аккаунт?", + "login": "Вернуться ко входу", + "oauthHint": "Войдите напрямую через существующий OAuth-аккаунт — локальный пароль не нужен.", + "usernameRequired": "Имя пользователя обязательно", + "usernameInvalid": "Допустимы только буквы, цифры или подчёркивания (3–64 символа)", + "usernameExists": "Имя пользователя уже занято", + "passwordRequired": "Пароль обязателен", + "passwordTooShort": "Пароль должен содержать не менее 8 символов", + "passwordTooWeak": "Пароль должен содержать минимум 3 типа символов (заглавные, строчные, цифры, спецсимволы)", + "emailInvalid": "Неверный формат email", + "emailExists": "Email уже используется" + }, + "resetPassword": { + "title": "Сброс пароля", + "subtitle": "Введите email, код подтверждения и новый пароль.", + "email": "Электронная почта", + "emailPlaceholder": "Введите email", + "emailRequired": "Введите email", + "emailInvalid": "Введите корректный адрес email", + "code": "Код подтверждения", + "codePlaceholder": "Введите 6-значный код", + "sendCode": "Отправить код", + "sendingCode": "Отправка...", + "codeSentMessage": "Если аккаунт подходит, код подтверждения отправлен.", + "codeRequired": "Введите код подтверждения", + "newPassword": "Новый пароль", + "newPasswordPlaceholder": "Введите новый пароль", + "newPasswordRequired": "Введите новый пароль", + "confirmPassword": "Подтвердите пароль", + "confirmPasswordPlaceholder": "Повторите новый пароль", + "passwordMismatch": "Пароли не совпадают", + "submit": "Сбросить пароль", + "submitting": "Сброс...", + "successMessage": "Пароль успешно сброшен. Войдите с новым паролем.", + "genericError": "Не удалось сбросить пароль", + "backToLogin": "Вернуться ко входу" + }, + "device": { + "title": "Авторизация устройства", + "subtitle": "Введите 8-значный код пользователя, показанный на устройстве", + "codeLabel": "Код пользователя", + "codeHint": "Формат: XXXX-XXXX (можно вставить из буфера)", + "incompleteCode": "Введите полный 8-значный код пользователя", + "success": "Устройство успешно авторизовано!", + "defaultError": "Авторизация не удалась, проверьте код пользователя", + "submitting": "Авторизация...", + "submit": "Авторизовать устройство", + "notice": "После авторизации устройство получит доступ к вашему аккаунту" + }, + "cliAuth": { + "validating": "Проверка...", + "pleaseWait": "Подождите", + "creatingToken": "Создание токена...", + "almostThere": "Почти готово", + "success": "Авторизация успешна", + "redirecting": "Перенаправление в CLI...", + "fallbackInstructions": "Если браузер не перенаправил автоматически, скопируйте токен ниже:", + "error": "Авторизация не удалась", + "notAuthenticated": "Вы не вошли в систему", + "invalidRedirectUri": "Некорректный redirect URI", + "missingState": "Отсутствует параметр безопасности", + "windowsUrlBug": "Известная проблема Windows: браузер открыл неполный URL. Скопируйте полный URL из вывода CLI (строка, начинающаяся с 'Opening browser:') и вставьте его в адресную строку браузера.", + "tokenCreationFailed": "Не удалось создать токен", + "loginRequired": "Сначала войдите в систему, чтобы авторизовать доступ CLI", + "goToLogin": "Перейти ко входу" + }, + "dashboard": { + "title": "Панель управления", + "subtitle": "Аккаунт, скиллы и учётные данные доступа — в одном месте", + "backToDashboard": "Назад к панели", + "userInfo": "Сведения об аккаунте", + "userInfoDesc": "Основные данные аккаунта и роли на платформе", + "loginVia": "Вход через {{provider}}", + "platformRoles": "Роли платформы", + "starsAndRatings": "Звёзды и рейтинги", + "viewStars": "Мои звёзды", + "subscriptions": "Подписки", + "viewSubscriptions": "Мои подписки", + "mySkillsTitle": "Мои скиллы", + "openMySkills": "Перейти к моим скиллам", + "mySkillsPreviewDescription": "Показаны 5 последних скиллов. Откройте скилл или перейдите в «Мои скиллы», чтобы увидеть все.", + "mySkillsPreviewEmpty": "Вы ещё не публиковали скиллы", + "credentials": "Учётные данные", + "openTokens": "Токены API", + "governanceTitle": "Рецензирование и контроль", + "viewGovernance": "Центр управления", + "viewPromotions": "Продвижения", + "reportsTitle": "Управление жалобами", + "viewReports": "Жалобы на скиллы", + "previewMore": "...", + "previewMoreLabel": "Смотреть все", + "userId": "ID пользователя" + }, + "mySkills": { + "title": "Мои скиллы", + "subtitle": "Управление опубликованными скиллами", + "searchPlaceholder": "Поиск по имени, slug или описанию", + "namespaceFilterLabel": "Фильтр по пространству имён", + "namespaceFilterAll": "Все пространства имён", + "clearSearch": "Сбросить фильтры", + "emptySearchTitle": "Нет подходящих скиллов", + "emptySearchDescription": "Измените ключевое слово или выберите другое пространство имён.", + "filters": { + "ALL": "Все", + "PENDING_REVIEW": "На рецензии", + "PUBLISHED": "Опубликованы", + "REJECTED": "Отклонены", + "ARCHIVED": "В архиве", + "HIDDEN": "Скрыты" + }, + "publishNew": "Опубликовать новый скилл", + "update": "Обновить", + "archive": "В архив", + "unarchive": "Восстановить", + "statusArchived": "В архиве", + "statusPendingReview": "На рецензии", + "statusPublished": "Опубликован", + "statusRejected": "Отклонён", + "statusHidden": "Скрыт", + "statusScanning": "Сканирование", + "statusScanFailed": "Ошибка сканирования", + "archiveConfirmTitle": "Архивировать скилл", + "archiveConfirmDescription": "После архивирования обычные пользователи больше не смогут просматривать или скачивать «{{skill}}». Продолжить?", + "unarchiveConfirmTitle": "Восстановить скилл", + "unarchiveConfirmDescription": "«{{skill}}» снова станет видимым и сможет публиковать новые версии после восстановления.", + "archiveSuccessTitle": "Скилл архивирован", + "archiveSuccessDescription": "«{{skill}}» архивирован.", + "archiveErrorTitle": "Не удалось архивировать скилл", + "unarchiveSuccessTitle": "Скилл восстановлен", + "unarchiveSuccessDescription": "«{{skill}}» восстановлен и снова может публиковать новые версии.", + "unarchiveErrorTitle": "Не удалось восстановить скилл", + "withdrawReview": "Отозвать рецензию", + "withdrawConfirmTitle": "Отозвать загрузку", + "withdrawConfirmDescription": "После отзыва «{{skill}}» больше не будет рецензироваться, а ожидающая версия будет удалена.", + "withdrawSuccessTitle": "Загрузка отозвана", + "withdrawSuccessDescription": "Ожидающая версия для «{{skill}}» отозвана.", + "withdrawErrorTitle": "Не удалось отозвать загрузку", + "promoteToGlobal": "Продвинуть в Global", + "promotionConfirmTitle": "Отправить запрос на продвижение", + "promotionConfirmDescription": "Отправить v{{version}} скилла «{{skill}}» на продвижение в глобальное пространство имён?", + "promotionSuccessTitle": "Запрос на продвижение отправлен", + "promotionSuccessDescription": "v{{version}} скилла «{{skill}}» добавлена в очередь рецензирования глобального продвижения.", + "promotionDuplicateTitle": "Продвижение уже ожидает", + "promotionDuplicateDescription": "У этой версии уже есть ожидающий запрос на продвижение.", + "promotionAlreadyPromotedTitle": "Скилл уже продвинут", + "promotionAlreadyPromotedDescription": "Этот скилл уже продвинут в глобальное пространство имён.", + "promotionErrorTitle": "Не удалось отправить запрос на продвижение", + "emptyTitle": "Скиллов пока нет", + "emptyDescription": "Опубликуйте свой первый скилл", + "emptyFilteredTitle": "Нет скиллов по этому фильтру", + "emptyFilteredDescription": { + "PENDING_REVIEW": "Сейчас нет скиллов, ожидающих рецензии.", + "PUBLISHED": "Сейчас нет опубликованных скиллов.", + "REJECTED": "Сейчас нет отклонённых скиллов.", + "ARCHIVED": "Сейчас нет архивных скиллов.", + "HIDDEN": "Сейчас нет скрытых скиллов." + }, + "publishSkill": "Опубликовать скилл" + }, + "myNamespaces": { + "title": "Мои пространства имён", + "subtitle": "Управление пространствами имён и командами", + "create": "Создать пространство имён", + "creating": "Создание...", + "createSubmit": "Создать", + "createDialogTitle": "Создать командное пространство имён", + "createDialogDescription": "После создания вы станете OWNER и сможете управлять участниками, рецензиями и публикацией скиллов.", + "createSlugLabel": "Slug пространства имён", + "createSlugPlaceholder": "напр. team-ml", + "createSlugHint": "2–64 символа: строчные буквы, цифры или дефисы, без дефиса в начале и конце.", + "createSlugRequired": "Slug пространства имён обязателен", + "createSlugLength": "Slug пространства имён должен быть от {{min}} до {{max}} символов", + "createSlugPattern": "Slug пространства имён может содержать только строчные буквы, цифры или дефисы", + "createSlugDoubleHyphen": "Slug пространства имён не может содержать подряд идущие дефисы", + "createSlugReserved": "«{{slug}}» зарезервирован. Выберите другой slug.", + "createDisplayNameLabel": "Отображаемое имя", + "createDisplayNamePlaceholder": "напр. Команда машинного обучения", + "createDisplayNameRequired": "Отображаемое имя обязательно", + "createDisplayNameLength": "Отображаемое имя — не более {{max}} символов", + "createDescriptionLabel": "Описание", + "createDescriptionPlaceholder": "Кратко опишите назначение пространства имён, кто им пользуется и как оно управляется", + "createDescriptionHint": "Необязательно, до 512 символов.", + "createDescriptionLength": "Описание — не более {{max}} символов", + "createSuccessTitle": "Пространство имён создано", + "createSuccessDescription": "«{{name}}» готово. Можно управлять участниками или публиковать скиллы.", + "createErrorTitle": "Не удалось создать пространство имён", + "typeGlobal": "Глобальное", + "typeTeam": "Команда", + "roleLabel": "Текущая роль", + "roleUnknown": "Неизвестно", + "manageMembers": "Участники", + "reviewTasks": "Задачи рецензирования", + "freeze": "Заморозить", + "unfreeze": "Разморозить", + "archive": "В архив", + "restore": "Восстановить", + "delete": "Удалить", + "activeHint": "Это пространство имён полностью активно: можно управлять участниками, рецензиями и публикацией скиллов.", + "frozenHint": "Пространство имён заморожено. Участники могут его просматривать, но оно только для чтения: публикация и изменение состава недоступны.", + "archivedHint": "Пространство имён в архиве. Публичные точки входа скрыты, но его можно просмотреть и восстановить из панели управления.", + "immutableHint": "Это встроенное системное пространство имён и всегда только для чтения.", + "freezeConfirmTitle": "Заморозить пространство имён", + "freezeConfirmDescription": "«{{name}}» станет доступно только для чтения. Продолжить?", + "unfreezeConfirmTitle": "Разморозить пространство имён", + "unfreezeConfirmDescription": "После разморозки «{{name}}» снова получит обычное управление и публикацию.", + "archiveConfirmTitle": "Архивировать пространство имён", + "archiveConfirmDescription": "«{{name}}» будет скрыто из публичных точек входа и останется доступным только для восстановления из панели управления.", + "restoreConfirmTitle": "Восстановить пространство имён", + "restoreConfirmDescription": "«{{name}}» вернётся в активное рабочее состояние.", + "deleteConfirmTitle": "Удалить пространство имён", + "deleteConfirmDescription": "«{{name}}» будет удалено безвозвратно. Это действие нельзя отменить.", + "freezeSuccessTitle": "Пространство имён заморожено", + "freezeSuccessDescription": "«{{name}}» теперь только для чтения.", + "freezeErrorTitle": "Не удалось заморозить пространство имён", + "unfreezeSuccessTitle": "Пространство имён разморожено", + "unfreezeSuccessDescription": "«{{name}}» снова активно.", + "unfreezeErrorTitle": "Не удалось разморозить пространство имён", + "archiveSuccessTitle": "Пространство имён архивировано", + "archiveSuccessDescription": "«{{name}}» скрыто из публичных точек входа.", + "archiveErrorTitle": "Не удалось архивировать пространство имён", + "restoreSuccessTitle": "Пространство имён восстановлено", + "restoreSuccessDescription": "«{{name}}» снова работает в обычном режиме.", + "restoreErrorTitle": "Не удалось восстановить пространство имён", + "deleteSuccessTitle": "Пространство имён удалено", + "deleteSuccessDescription": "«{{name}}» удалено безвозвратно.", + "deleteErrorTitle": "Не удалось удалить пространство имён", + "emptyTitle": "Пространств имён пока нет", + "emptyDescription": "Создайте пространство имён, чтобы организовать скиллы" + }, + "tokens": { + "pageTitle": "Управление токенами", + "pageSubtitle": "Учётные данные доступа для CLI и API" + }, + "reviews": { + "title": "Центр рецензирования", + "subtitle": "Задачи рецензирования на платформе", + "typeSkill": "Рецензии скиллов", + "typeProfile": "Рецензии профилей", + "tabPending": "Ожидают", + "tabApproved": "Одобрены", + "tabRejected": "Отклонены", + "empty": "Нет задач рецензирования", + "colSkill": "Скилл", + "colVersion": "Версия", + "colSubmitter": "Отправитель", + "colSubmitTime": "Отправлено", + "colReviewer": "Рецензент", + "colReviewTime": "Рецензировано", + "sortLabel": "Порядок по времени", + "sortNewest": "Сначала новые", + "sortOldest": "Сначала старые", + "pageSummary": "Всего записей: {{total}}, страница {{page}}", + "prevPage": "Назад", + "nextPage": "Вперёд" + }, + "profileReview": { + "queueTitle": "Очередь рецензий профилей", + "queueSubtitle": "Обработка запросов на смену отображаемого имени: фильтры статуса, пагинация и действия рецензирования.", + "empty": "Нет задач рецензирования профилей", + "colUser": "Пользователь", + "colCurrentName": "Текущее имя", + "colRequestedName": "Запрошенное имя", + "colSubmittedAt": "Отправлено", + "colMachineResult": "Машинная проверка", + "colActions": "Действия", + "colReviewInfo": "Сведения о рецензии", + "approve": "Одобрить", + "reject": "Отклонить", + "approveSuccess": "Смена профиля одобрена", + "approveFailed": "Не удалось одобрить", + "rejectSuccess": "Смена профиля отклонена", + "rejectFailed": "Не удалось отклонить", + "confirmApproveTitle": "Подтвердить одобрение", + "confirmApproveDesc": "Одобрить смену «{{from}}» на «{{to}}»?", + "confirmRejectTitle": "Отклонить изменение", + "confirmRejectDesc": "Укажите причину отклонения.", + "rejectPlaceholder": "Причина отклонения...", + "userId": "ID пользователя", + "reviewer": "Рецензент", + "comment": "Комментарий", + "totalItems": "Всего записей рецензий профилей: {{total}}", + "sortLabel": "Порядок по времени", + "sortNewest": "Сначала новые", + "sortOldest": "Сначала старые", + "pageSummary": "Всего записей: {{total}}, страница {{page}}", + "prevPage": "Назад", + "nextPage": "Вперёд", + "tabPending": "Ожидают", + "tabApproved": "Одобрены", + "tabRejected": "Отклонены" + }, + "stars": { + "title": "Мои звёзды", + "subtitle": "Избранные скиллы", + "empty": "Избранных скиллов пока нет" + }, + "subscriptions": { + "title": "Мои подписки", + "subtitle": "Скиллы, на обновления которых вы подписаны", + "empty": "Вы ещё не подписались ни на один скилл." + }, + "promotions": { + "title": "Рецензирование продвижений", + "subtitle": "Запросы на продвижение командных скиллов в глобальное пространство имён", + "tabPending": "Ожидают", + "tabApproved": "Одобрены", + "tabRejected": "Отклонены", + "commentPlaceholder": "Комментарий рецензии (необязательно)", + "approve": "Одобрить", + "reject": "Отклонить", + "empty": "Нет запросов на продвижение", + "historyTableLabel": "История продвижений", + "colSkill": "Скилл", + "colVersion": "Версия", + "colSubmitter": "Отправитель", + "colReviewer": "Рецензент", + "colReviewedAt": "Рецензировано", + "colReviewComment": "Комментарий рецензии", + "sortReviewedTimeAsc": "Сортировка по времени рецензии по возрастанию", + "sortReviewedTimeDesc": "Сортировка по времени рецензии по убыванию", + "emptyValue": "-", + "versionTag": "v{{version}}", + "submitterTag": "Отправитель {{user}}", + "fileCountTag": "Файлов: {{count}}", + "packageSizeTag": "{{size}}", + "downloadCountTag": "Загрузок: {{value}}", + "starCountTag": "Звёзд: {{value}}" + }, + "adminUsers": { + "title": "Управление пользователями", + "subtitle": "Управление пользователями платформы и правами доступа", + "searchLabel": "Поиск пользователей", + "searchPlaceholder": "Поиск по имени пользователя или email...", + "searchHint": "Введите имя пользователя или email, затем нажмите Enter или «Поиск».", + "searchAction": "Поиск", + "clearSearch": "Очистить", + "filterLabel": "Фильтр по статусу", + "filterAll": "Все", + "filterActive": "Активные", + "filterPending": "Ожидающие", + "filterDisabled": "Отключённые", + "empty": "Нет данных о пользователях", + "colUsername": "Имя пользователя", + "colUserId": "ID пользователя", + "copyUserId": "Скопировать ID пользователя для {{username}}", + "colEmail": "Электронная почта", + "colStatus": "Статус", + "colRole": "Роль", + "colCreatedAt": "Создан", + "colActions": "Действия", + "statusActive": "Активен", + "statusPending": "Ожидает", + "statusDisabled": "Отключён", + "changeRole": "Изменить роль", + "approveUser": "Одобрить", + "disable": "Отключить", + "enable": "Включить", + "resetPassword": "Сбросить пароль", + "totalRecords": "Всего {{total}} записей, страница {{page}}", + "prevPage": "Назад", + "nextPage": "Вперёд", + "changeRoleTitle": "Изменить роль пользователя", + "changeRoleDesc": "Назначить новую роль пользователю {{username}}", + "roleLabel": "Роль", + "selectRole": "Выберите роль", + "roleUser": "Пользователь", + "roleReviewer": "Ревьюер", + "roleUserAdmin": "Администратор пользователей", + "roleAuditor": "Аудитор", + "roleSuperAdmin": "Суперадминистратор", + "confirmAction": "Подтвердить действие", + "confirmDisable": "Вы уверены, что хотите отключить пользователя {{username}}?", + "confirmEnable": "Вы уверены, что хотите включить пользователя {{username}}?", + "confirmResetPassword": "Отправить код подтверждения сброса пароля пользователю {{username}}?" + }, + "adminLabels": { + "title": "Управление метками", + "subtitle": "Управление определениями меток, видимостью, переводами и порядком в фильтрах.", + "createAction": "Создать метку", + "editAction": "Редактировать", + "deleteAction": "Удалить", + "saveAction": "Сохранить изменения", + "cancelAction": "Отмена", + "moveUp": "Выше", + "moveDown": "Ниже", + "empty": "Метки ещё не определены.", + "summaryDefinitionsTitle": "Определения", + "summaryVisibleTitle": "Видимые фильтры", + "summaryPrivilegedTitle": "Привилегированные метки", + "colLabel": "Метка", + "colType": "Тип", + "colVisibility": "Видимость", + "colSortOrder": "Порядок сортировки", + "colTranslations": "Переводы", + "colCreatedAt": "Создана", + "colActions": "Действия", + "typeRecommended": "Рекомендуемая", + "typePrivileged": "Привилегированная", + "visibilityVisible": "Видна в фильтрах", + "visibilityHidden": "Скрыта из фильтров", + "createDialogTitle": "Создать метку", + "createDialogDescription": "Задайте новую метку и переводы, которые будут отображаться в поиске и на экранах деталей.", + "editDialogTitle": "Редактировать метку", + "editDialogDescription": "Обновите правила отображения и переводы для этого определения метки.", + "deleteDialogTitle": "Удалить метку", + "deleteDialogDescription": "Удалить метку «{{slug}}» и снять её со всех привязанных скиллов.", + "formSlug": "Slug", + "formType": "Тип", + "formVisibility": "Видимость", + "formSortOrder": "Порядок сортировки", + "formTranslations": "Переводы", + "formTranslationsHint": "Укажите хотя бы одну пару локаль и отображаемое имя.", + "translationLocalePlaceholder": "Локаль, например en или zh-CN", + "translationDisplayNamePlaceholder": "Отображаемое имя", + "addTranslation": "Добавить перевод", + "removeTranslation": "Удалить", + "validationSlugTitle": "Требуется slug", + "validationSlugDescription": "Введите стабильный slug перед сохранением метки.", + "validationSlugPatternDescription": "Используйте только строчные буквы, цифры и одиночные дефисы. Slug должен начинаться и заканчиваться буквой или цифрой.", + "validationTranslationsTitle": "Требуются переводы", + "validationTranslationsDescription": "Нужна хотя бы одна полная пара локаль и отображаемое имя.", + "validationDuplicateLocaleDescription": "Каждая локаль перевода может встречаться только один раз.", + "createSuccessTitle": "Метка создана", + "createSuccessDescription": "Определение метки теперь доступно.", + "createErrorTitle": "Не удалось создать метку", + "updateSuccessTitle": "Метка обновлена", + "updateSuccessDescription": "Определение метки обновлено.", + "updateErrorTitle": "Не удалось обновить метку", + "deleteSuccessTitle": "Метка удалена", + "deleteSuccessDescription": "Определение метки удалено.", + "deleteErrorTitle": "Не удалось удалить метку", + "sortSuccessTitle": "Порядок сортировки обновлён", + "sortSuccessDescription": "Порядок меток сохранён.", + "sortErrorTitle": "Не удалось обновить порядок сортировки", + "fallbackErrorDescription": "Действие с меткой не удалось выполнить." + }, + "auditLog": { + "title": "Журнал аудита", + "subtitle": "Просмотр записей о системных операциях", + "filterAll": "Все", + "filterCliPublish": "Публикация CLI", + "filterCompatPublish": "Совместимая публикация", + "filterReviewSubmit": "Ревью отправлено", + "filterReviewApprove": "Ревью одобрено", + "filterReviewReject": "Ревью отклонено", + "filterPromotionSubmit": "Продвижение отправлено", + "filterPromotionApprove": "Продвижение одобрено", + "filterPromotionReject": "Продвижение отклонено", + "filterReportSkill": "Скилл пожалован", + "filterResolveSkillReport": "Жалоба обработана", + "filterDismissSkillReport": "Жалоба отклонена", + "filterHideSkill": "Скилл скрыт", + "filterArchiveSkill": "Скилл архивирован", + "filterUnhideSkill": "Скилл показан", + "filterUnarchiveSkill": "Скилл восстановлен", + "filterYankVersion": "Версия отозвана", + "filterRebuildSearchIndex": "Индекс поиска перестроен", + "quickFilterSearchRebuild": "Только перестройки индекса поиска", + "clearFilters": "Сбросить фильтры", + "userIdPlaceholder": "ID пользователя...", + "requestIdPlaceholder": "ID запроса...", + "ipPlaceholder": "IP-адрес...", + "resourceTypePlaceholder": "Тип ресурса...", + "resourceIdPlaceholder": "ID ресурса...", + "empty": "Нет записей аудита", + "colTime": "Время", + "colAction": "Действие", + "colUserId": "ID пользователя", + "colUsername": "Имя пользователя", + "colIp": "IP-адрес", + "colDetail": "Подробности", + "totalRecords": "Всего {{total}} записей, страница {{page}}", + "prevPage": "Назад", + "nextPage": "Вперёд" + }, + "profile": { + "title": "Настройки профиля", + "subtitle": "Управление отображаемым именем и личной информацией.", + "displayName": "Отображаемое имя", + "email": "Электронная почта", + "resetPassword": "Сбросить пароль", + "edit": "Редактировать", + "save": "Сохранить", + "saving": "Сохранение...", + "cancel": "Отмена", + "successTitle": "Профиль обновлён", + "successDescription": "Ваше отображаемое имя обновлено.", + "pendingReviewTitle": "Отправлено на проверку", + "pendingReviewDescription": "Изменение отображаемого имени ожидает проверки и вступит в силу после одобрения.", + "defaultError": "Не удалось обновить профиль. Попробуйте снова.", + "validation": { + "length": "Отображаемое имя должно содержать 2–32 символа.", + "pattern": "Отображаемое имя может содержать только китайские и английские символы, цифры, пробелы, подчёркивания и дефисы." + }, + "pendingReview": "Изменение отображаемого имени на «{{name}}» ожидает проверки.", + "rejected": "Изменение отображаемого имени отклонено.", + "rejectedReason": "Причина: {{reason}}", + "reviewHint": "Некоторые изменения требуют проверки администратором перед вступлением в силу.", + "partiallyAppliedTitle": "Применено частично", + "partiallyAppliedDescription": "Часть изменений применена сразу. Остальные ожидают проверки администратором.", + "noChanges": "Нет изменений для сохранения.", + "userId": "ID пользователя" + }, + "security": { + "title": "Настройки безопасности", + "subtitle": "Обновите пароль, если включён вход через локальную учётную запись.", + "currentPassword": "Текущий пароль", + "newPassword": "Новый пароль", + "currentPasswordRequired": "Введите текущий пароль", + "newPasswordRequired": "Введите новый пароль", + "invalidCurrentPassword": "Текущий пароль неверен", + "success": "Пароль успешно изменён", + "successTitle": "Пароль успешно изменён", + "successDescription": "Войдите снова с новым паролем.", + "defaultError": "Не удалось изменить пароль", + "unavailableTitle": "Смена пароля недоступна для этой учётной записи.", + "unavailableDescription": "Эта учётная запись входит через внешнего поставщика идентификации или не имеет локальных учётных данных пароля.", + "submitting": "Отправка...", + "submit": "Обновить пароль" + }, + "accounts": { + "initiateTitle": "Начать объединение учётных записей", + "initiateDesc": "Введите идентификатор вторичной учётной записи. Поддерживается локальное имя пользователя или формат `provider:subject` для внешних идентичностей.", + "secondaryLabel": "Вторичный идентификатор", + "secondaryPlaceholder": "например: other_user или github:123456", + "initiating": "Инициализация...", + "initiate": "Начать объединение", + "initiateSuccess": "Запрос на объединение создан, secondary={{secondaryUserId}}", + "initiateError": "Не удалось начать объединение", + "verifyTitle": "Проверить и завершить объединение", + "verifyDesc": "Сначала завершите проверку токена, затем подтвердите выполнение миграции данных.", + "mergeRequestId": "ID запроса на объединение", + "verificationToken": "Токен проверки", + "verifying": "Проверка...", + "verify": "Завершить объединение", + "verifySuccess": "Проверка успешна, подтвердите выполнение объединения", + "verifyError": "Проверка объединения не удалась", + "confirming": "Подтверждение...", + "confirm": "Подтвердить и завершить объединение", + "confirmSuccess": "Объединение учётных записей завершено", + "confirmError": "Подтверждение объединения не удалось" + }, + "namespace": { + "notFound": "Пространство имён не найдено", + "skillList": "Скиллы", + "emptyTitle": "Нет скиллов", + "emptyDescription": "В этом пространстве имён ещё не опубликовано ни одного скилла" + }, + "skillDetail": { + "back": "Назад", + "notFound": "Скилл не найден", + "notFoundDesc": "Этот скилл мог быть удалён или никогда не существовал", + "loginRequired": "Требуется вход", + "loginRequiredDesc": "Этот скилл является приватным. Войдите, чтобы просмотреть подробности.", + "accessDenied": "Доступ запрещён", + "accessDeniedDesc": "У вас нет прав на просмотр этого скилла", + "tabReadme": "README", + "tabOverview": "Обзор", + "tabFiles": "Файлы", + "tabVersions": "Версии", + "noReadme": "Нет README", + "readmeUnavailable": "README временно недоступен. Попробуйте позже или загрузите эту версию скилла заново.", + "documentationSource": "Источник: {{path}}", + "documentationUnavailableTitle": "Документация недоступна", + "documentationUnavailable": "Не удалось загрузить файл документации. Вы всё ещё можете просмотреть содержимое пакета в списке файлов.", + "packageLinkMissingTitle": "Файл не найден", + "packageLinkMissingDescription": "Эта ссылка указывает на файл, которого нет в текущей версии скилла.", + "authorLabel": "Автор: {{name}}", + "expandOverview": "Развернуть полный обзор", + "collapseOverview": "Свернуть содержимое", + "noDocumentationTitle": "Нет документации пакета", + "noDocumentationDescription": "В этой версии скилла нет читаемого файла обзора.", + "noDocumentationHint": "Многие скиллы содержат только исполняемые файлы. Вы можете продолжить со списком файлов и сведениями о версиях ниже.", + "summaryLabel": "Краткое описание", + "noFiles": "Нет файлов", + "noVersions": "Нет версий", + "fileCount": "{{count}} файлов", + "version": "Версия", + "downloads": "Загрузки", + "rating": "Оценка", + "ratingNone": "Нет", + "namespaceLabel": "Пространство имён", + "loginToRate": "Войдите, чтобы отметить звёздочкой и оценить", + "install": "Установить", + "installMethodClawhub": "ClawHub CLI", + "installMethodSkillhub": "SkillHub CLI", + "download": "Скачать", + "labelsSectionTitle": "Метки", + "labelsSectionDescription": "Прикрепите или удалите рекомендуемые метки, которые помогают пользователям фильтровать и находить этот скилл.", + "labelsSectionDescriptionSuperAdmin": "Управляйте всеми метками этого скилла, включая привилегированные, скрытые из публичных фильтров.", + "currentLabelsTitle": "Текущие метки", + "availableLabelsTitle": "Доступные метки", + "noLabelsAssigned": "К этому скиллу ещё не прикреплено ни одной метки.", + "loadingAvailableLabels": "Загрузка доступных меток...", + "noAvailableLabels": "Сейчас нельзя прикрепить больше меток.", + "addLabel": "Добавить {{label}}", + "removeLabel": "Удалить", + "labelRestrictedHint": "Только суперадминистратор", + "labelAttachSuccessTitle": "Метка прикреплена", + "labelAttachSuccessDescription": "Метки скилла обновлены.", + "labelAttachErrorTitle": "Не удалось прикрепить метку", + "labelDetachSuccessTitle": "Метка удалена", + "labelDetachSuccessDescription": "Метки скилла обновлены.", + "labelDetachErrorTitle": "Не удалось удалить метку", + "labelActionFallbackError": "Действие с меткой не удалось выполнить.", + "lifecycle": "Жизненный цикл", + "lifecycleHint": "Вы можете архивировать этот скилл. Архивированные скиллы скрыты от обычных пользователей и недоступны для скачивания.", + "archivedPublishHint": "Этот скилл архивирован. Восстановите его перед публикацией новой версии.", + "archivedInstallHint": "Этот скилл архивирован и недоступен для публичного скачивания.", + "statusActive": "Активен", + "statusArchived": "Архивирован", + "statusHidden": "Скрыт", + "versionStatusDraft": "Черновик", + "versionStatusScanning": "Сканирование", + "versionStatusScanFailed": "Сканирование не удалось", + "versionStatusUploaded": "Загружена", + "versionStatusPendingReview": "Ожидает ревью", + "versionStatusPublished": "Опубликована", + "versionStatusRejected": "Отклонена", + "versionStatusYanked": "Отозвана", + "pendingPreviewBadge": "Предпросмотр ожидающей", + "rejectedBadge": "Ревью отклонено", + "pendingPreviewTitle": "Вы просматриваете ожидающую версию", + "pendingPreviewDescription": "Эта версия видна только вам. До одобрения ревью вы можете просмотреть README, файлы и сведения о версии, но не можете отмечать звёздочкой, оценивать, жаловаться или скачивать.", + "pendingPreviewInteractionHint": "Звёздочки, оценки и жалобы отключены, пока эта версия ожидает ревью.", + "rejectedPreviewDescription": "Эта версия отклонена и сейчас видна только вам. Обновите её по замечаниям ниже и опубликуйте снова.", + "rejectedPreviewInteractionHint": "Отклонённые версии для предпросмотра нельзя отмечать звёздочкой, оценивать или жаловаться на них.", + "rejectedFeedbackTitle": "Ревью отклонено", + "rejectedFeedbackLabel": "Комментарий ревьюера", + "rejectedFeedbackFallback": "Комментарий об отклонении не предоставлен.", + "governance": "Управление", + "processing": "Обработка...", + "archiveSkill": "Архивировать скилл", + "unarchiveSkill": "Восстановить скилл", + "deleteSkill": "Удалить скилл", + "deleteSkillContinue": "Продолжить", + "deleteSkillFinal": "Удалить безвозвратно", + "withdrawReview": "Отозвать ревью", + "hideSkill": "Скрыть скилл", + "unhideSkill": "Показать скилл", + "archiveConfirmTitle": "Архивировать скилл", + "archiveConfirmDescription": "После архивирования обычные пользователи больше не смогут просматривать или скачивать «{{skill}}». Продолжить?", + "unarchiveConfirmTitle": "Восстановить скилл", + "unarchiveConfirmDescription": "«{{skill}}» снова станет видимым и сможет публиковать новые версии после восстановления.", + "deleteSkillConfirmTitle": "Безвозвратно удалить скилл", + "deleteSkillConfirmDescription": "Будут удалены все версии, файлы и пакеты загрузки для «{{skill}}». Это действие нельзя отменить.", + "deleteSkillInputTitle": "Введите slug скилла для подтверждения", + "deleteSkillInputDescription": "Введите slug скилла «{{slug}}», чтобы продолжить.", + "deleteSkillInputPlaceholder": "Введите slug скилла", + "deleteSkillWarning": "Это физическое жёсткое удаление. Исторические версии и пакеты будут удалены безвозвратно.", + "archiveSuccessTitle": "Скилл архивирован", + "archiveSuccessDescription": "«{{skill}}» архивирован.", + "archiveErrorTitle": "Не удалось архивировать скилл", + "unarchiveSuccessTitle": "Скилл восстановлен", + "unarchiveSuccessDescription": "«{{skill}}» восстановлен.", + "unarchiveErrorTitle": "Не удалось восстановить скилл", + "deleteSkillSuccessTitle": "Скилл удалён", + "deleteSkillSuccessDescription": "«{{skill}}» безвозвратно удалён.", + "deleteSkillErrorTitle": "Не удалось удалить скилл", + "withdrawReviewConfirmTitle": "Отозвать ревью", + "withdrawReviewConfirmDescription": "После отзыва версия {{version}} покинет очередь ревью и вернётся в черновик, чтобы её можно было отправить снова позже.", + "withdrawReviewSuccessTitle": "Ревью отозвано", + "withdrawReviewSuccessDescription": "Версия {{version}} отозвана из ревью.", + "withdrawReviewErrorTitle": "Не удалось отозвать ревью", + "confirmPublish": "Подтвердить публикацию", + "confirmPublishDialogTitle": "Подтвердить публикацию", + "confirmPublishDialogDescription": "Опубликовать версию {{version}} как приватный скилл? Она будет доступна вам для скачивания и установки, но не видна на маркетплейсе.", + "confirmPublishSuccessTitle": "Версия опубликована", + "confirmPublishSuccessDescription": "Версия {{version}} опубликована как приватный скилл.", + "confirmPublishErrorTitle": "Не удалось подтвердить публикацию", + "submitReview": "Отправить на ревью", + "submitReviewDialogTitle": "Отправить на ревью", + "submitReviewDialogDescription": "Отправить версию {{version}} на публичное ревью? После одобрения она станет видимой на маркетплейсе.", + "submitReviewSuccessTitle": "Отправлено на ревью", + "submitReviewSuccessDescription": "Версия {{version}} отправлена на ревью.", + "submitReviewErrorTitle": "Не удалось отправить на ревью", + "deleteVersion": "Удалить версию", + "deleteVersionConfirmTitle": "Удалить версию", + "deleteVersionConfirmDescription": "Версию {{version}} нельзя будет восстановить после удаления. Продолжить?", + "deleteVersionSuccessTitle": "Версия удалена", + "deleteVersionSuccessDescription": "Версия {{version}} удалена.", + "deleteVersionErrorTitle": "Не удалось удалить версию", + "currentVersion": "Текущая", + "currentPublicVersion": "Текущая публичная версия", + "pendingReviewSectionTitle": "Версия на ревью", + "pendingReviewSectionDescription": "v{{pendingVersion}} сейчас на ревью. Публичная и устанавливаемая версия остаётся v{{publishedVersion}}.", + "pendingReviewVersionLabel": "Ожидающая версия", + "pendingReviewStatusLabel": "Статус ревью", + "pendingReviewStatusValue": "На ревью", + "lifecyclePublicVersionLabel": "Текущая публичная версия", + "lifecycleNoPublishedVersion": "Нет опубликованной версии", + "lifecyclePendingVersionLabel": "Версия, ожидающая ревью", + "lifecyclePendingVersionValue": "v{{version}} на ревью", + "lifecycleNoPendingVersion": "Нет версии, ожидающей ревью", + "lifecycleContainerStateLabel": "Состояние контейнера скилла", + "compareVersions": "Сравнить", + "compareDialogTitle": "Сравнение версий", + "compareDialogDescription": "Сравнить v{{source}} с v{{target}}.", + "compareSourceLabel": "Выбранная версия", + "compareTargetLabel": "Сравнение с", + "versionCompareUnavailableTitle": "Недостаточно версий для сравнения", + "versionCompareUnavailableDescription": "Опубликуйте хотя бы две версии, прежде чем использовать сравнение версий.", + "metadataChanges": "Изменения метаданных", + "noMetadataChanges": "Нет изменений метаданных", + "readmeChange": "Изменение README", + "readmeChanged": "Содержимое README изменено", + "readmeUnchanged": "Содержимое README не изменено", + "fileChanges": "Изменения файлов", + "filesAdded": "Добавлено", + "filesRemoved": "Удалено", + "filesChanged": "Изменено", + "expandDiff": "Показать diff", + "collapseDiff": "Скрыть diff", + "binaryFileNotice": "Бинарный файл — нельзя отобразить diff", + "largeFileWarning": "Этот файл большой. Отрисовка может быть медленной.", + "diffLoadError": "Не удалось загрузить содержимое файла", + "diffRetry": "Повторить", + "diffViewUnified": "Объединённый", + "diffViewSplit": "Разделённый", + "rereleaseVersion": "Переиздать", + "rereleaseDialogTitle": "Переиздание из версии", + "rereleaseDialogDescription": "Создать новую опубликованную версию на основе v{{version}}.", + "rereleaseSourceVersion": "Исходная версия", + "rereleaseTargetVersion": "Новая версия", + "rereleaseSuccessTitle": "Версия переиздана", + "rereleaseSuccessDescription": "Создана v{{target}} из v{{source}}.", + "rereleaseErrorTitle": "Не удалось переиздать версию", + "rereleaseWarningTitle": "Предупреждение перед публикацией", + "rereleaseWarningDescription": "Обнаружены следующие напоминания о рисках. Если вы их понимаете и всё равно хотите продолжить, можно продолжить переиздание.", + "rereleaseWarningConfirm": "Продолжить переиздание", + "yankVersion": "Отозвать текущую версию", + "promoteToGlobal": "Продвинуть в Global", + "promotionSectionTitle": "Продвинуть в Global", + "promotionSectionDescription": "Отправить текущую опубликованную версию v{{version}} на ревью в глобальное пространство имён.", + "promotionConfirmTitle": "Отправить запрос на продвижение", + "promotionConfirmDescription": "Отправить v{{version}} скилла «{{skill}}» на продвижение в глобальное пространство имён?", + "promotionSuccessTitle": "Запрос на продвижение отправлен", + "promotionSuccessDescription": "v{{version}} скилла «{{skill}}» теперь в очереди ревью на продвижение в Global.", + "promotionDuplicateTitle": "Продвижение уже ожидает", + "promotionDuplicateDescription": "Для этой версии уже есть ожидающий запрос на продвижение.", + "promotionAlreadyPromotedTitle": "Скилл уже продвинут", + "promotionAlreadyPromotedDescription": "Этот скилл уже продвинут в глобальное пространство имён.", + "promotionErrorTitle": "Не удалось отправить запрос на продвижение", + "reportSkill": "Пожаловаться на скилл", + "reportedSkill": "Жалоба отправлена", + "reportDialogTitle": "Пожаловаться на скилл", + "reportDialogDescription": "Укажите причину, чтобы администраторы могли быстро проверить и принять меры.", + "reportReasonPlaceholder": "Причина, например нарушение политики, вводящий в заблуждение контент или нарушение прав", + "reportDetailsPlaceholder": "Дополнительные сведения (необязательно)", + "submitReport": "Отправить жалобу", + "reportReasonRequired": "Укажите причину жалобы", + "reportSuccessTitle": "Жалоба отправлена", + "reportSuccessDescription": "Администраторы скоро рассмотрят эту жалобу.", + "downloadErrorTitle": "Скачивание не удалось", + "reportErrorTitle": "Жалоба не отправлена", + "share": { + "button": "Поделиться", + "copied": "Скопировано", + "defaultDescription": "Полезный скилл" + } + }, + "skillCompare": { + "pageTitle": "Сравнение версий", + "filesChanged": "Изменено файлов: {{count}}", + "searchFiles": "Поиск файлов", + "errorLoadingCompare": "Не удалось загрузить сравнение версий", + "loadDiff": "Загрузить diff", + "loading": "Загрузка...", + "totalFiles": "Файлы", + "addedLines": "Добавленные строки", + "removedLines": "Удалённые строки", + "baseVersion": "Базовая версия", + "headVersion": "Целевая версия", + "fileList": "Список файлов", + "binaryFile": "Бинарный файл — нельзя отобразить diff", + "truncatedFile": "Вывод diff обрезан", + "notEnoughPublishedVersions": "Опубликуйте хотя бы две версии перед сравнением.", + "noFilesFound": "Подходящие файлы не найдены.", + "changeTypeAdded": "Добавлен", + "changeTypeModified": "Изменён", + "changeTypeRemoved": "Удалён" + }, + "reports": { + "title": "Жалобы на скиллы", + "subtitle": "Обработка жалоб на скиллы, отправленных пользователями", + "tabPending": "Ожидающие", + "tabResolved": "Обработанные", + "tabDismissed": "Отклонённые", + "empty": "Нет жалоб", + "reporter": "Автор жалобы", + "handledBy": "Обработал", + "resolve": "Обработать", + "resolveAndHide": "Обработать и скрыть", + "resolveAndArchive": "Обработать и архивировать", + "dismiss": "Отклонить", + "resolveConfirmTitle": "Обработать жалобу", + "resolveConfirmDescription": "Отметить жалобу на «{{skill}}» как обработанную?", + "resolveAndHideConfirmDescription": "Обработать жалобу на «{{skill}}» и скрыть скилл от публичного доступа?", + "resolveAndArchiveConfirmDescription": "Обработать жалобу на «{{skill}}» и архивировать скилл?", + "dismissConfirmTitle": "Отклонить жалобу", + "dismissConfirmDescription": "Отклонить жалобу на «{{skill}}»?", + "resolveSuccessTitle": "Жалоба обработана", + "resolveSuccessDescription": "Жалоба на «{{skill}}» отмечена как обработанная.", + "resolveAndHideSuccessTitle": "Жалоба обработана, скилл скрыт", + "resolveAndHideSuccessDescription": "«{{skill}}» скрыт после обработки жалобы.", + "resolveAndArchiveSuccessTitle": "Жалоба обработана, скилл архивирован", + "resolveAndArchiveSuccessDescription": "«{{skill}}» архивирован после обработки жалобы.", + "dismissSuccessTitle": "Жалоба отклонена", + "dismissSuccessDescription": "Жалоба на «{{skill}}» отклонена.", + "resolveErrorTitle": "Не удалось обработать жалобу", + "resolveAndHideErrorTitle": "Не удалось обработать жалобу и скрыть скилл", + "resolveAndArchiveErrorTitle": "Не удалось обработать жалобу и архивировать скилл", + "dismissErrorTitle": "Не удалось отклонить жалобу" + }, + "governance": { + "title": "Центр управления", + "subtitle": "Отслеживайте ревью, продвижения, жалобы и активность аудита в одном месте.", + "pendingReviews": "Ожидающие ревью", + "pendingPromotions": "Ожидающие продвижения", + "pendingReports": "Ожидающие жалобы", + "unreadNotifications": "Непрочитанные уведомления", + "inboxTitle": "Входящие управления", + "inboxSubtitle": "Единая очередь операционных задач, требующих действия.", + "tabAll": "Все", + "tabReview": "Ревью", + "tabPromotion": "Продвижения", + "tabReport": "Жалобы", + "emptyInbox": "Сейчас нет задач управления.", + "openItem": "Открыть", + "notificationsTitle": "Уведомления", + "notificationsSubtitle": "Недавние обновления управления, требующие вашего внимания.", + "emptyNotifications": "Уведомлений пока нет.", + "markRead": "Отметить как прочитанное", + "activityTitle": "Активность управления", + "activitySubtitle": "Недавние события аудита по ревью, продвижению, жалобам и действиям жизненного цикла.", + "emptyActivity": "Нет недавней активности управления.", + "unknownActor": "Неизвестный участник", + "searchMaintenanceTitle": "Обслуживание индекса поиска", + "searchMaintenanceDescription": "Перестроить полный индекс поиска скиллов. Это действие доступно только суперадминистраторам и предназначено для полной перезагрузки после изменения правил поиска.", + "searchMaintenanceHint": "Это может занять время. Не запускайте повторно слишком часто.", + "searchRebuildAction": "Перестроить полный индекс поиска", + "searchRebuildRunning": "Перестроение...", + "searchRebuildConfirmTitle": "Перестроить полный индекс поиска?", + "searchRebuildConfirmDescription": "Система перестроит документы поиска для всех скиллов по текущим правилам индексации. Операция может занять некоторое время.", + "searchRebuildSuccessTitle": "Перестроение индекса поиска запущено", + "searchRebuildSuccessDescription": "Система перестраивает полный индекс поиска по текущим правилам.", + "searchRebuildErrorTitle": "Не удалось перестроить индекс поиска" + }, + "members": { + "title": "Управление участниками", + "addMember": "Добавить участника", + "addingMember": "Добавление...", + "addDialogTitle": "Добавить участника пространства имён", + "addDialogDescription": "Найдите пользователей платформы или введите ID пользователя напрямую. Добавленные пользователи сразу смогут работать в этом пространстве имён.", + "searchLabel": "Поиск пользователей", + "searchPlaceholder": "Поиск по имени пользователя, email или ID пользователя", + "searchHint": "Введите не менее 2 символов для поиска. В результатах будет ID пользователя, которого можно добавить напрямую.", + "searchAction": "Поиск", + "searchTooShort": "Ключевые слова поиска должны содержать не менее 2 символов", + "searchResultsTitle": "Кандидаты", + "searchEmpty": "Нет активных пользователей для добавления", + "selectCandidate": "Использовать этого пользователя", + "manualUserIdLabel": "Ввести ID пользователя вручную", + "manualUserIdPlaceholder": "Например: user-123 или usr_abcd", + "manualUserIdHint": "Если вы уже знаете ID пользователя, можете добавить его здесь напрямую.", + "userIdRequired": "Требуется ID пользователя", + "roleLabel": "Роль участника", + "roleOwner": "OWNER", + "roleAdmin": "ADMIN", + "roleMember": "MEMBER", + "saveRole": "Сохранить роль", + "savingRole": "Сохранение...", + "changeRole": "Изменить роль", + "colUserId": "ID пользователя", + "colUsername": "Имя пользователя", + "colEmail": "Электронная почта", + "colRole": "Роль", + "colJoinedAt": "Дата вступления", + "colActions": "Действия", + "remove": "Удалить", + "empty": "Нет участников", + "namespaceNotFound": "Пространство имён не найдено", + "globalReadOnly": "Это встроенное системное пространство имён. Состав участников можно только просматривать, но нельзя изменять.", + "frozenReadOnly": "Это пространство имён заморожено. Вы можете просматривать участников, но не можете добавлять, удалять или менять роли.", + "archivedReadOnly": "Это пространство имён архивировано. Вы можете просматривать участников, но не можете менять состав, пока оно не будет восстановлено.", + "memberReadOnly": "Вы обычный участник этого пространства имён. Список участников можно просматривать, но изменять его могут только OWNER или ADMIN.", + "addSuccessTitle": "Участник добавлен", + "addSuccessDescription": "Пользователь {{userId}} добавлен в это пространство имён.", + "addErrorTitle": "Не удалось добавить участника", + "updateRoleSuccessTitle": "Роль участника обновлена", + "updateRoleSuccessDescription": "Пользователь {{userId}} теперь {{role}}.", + "updateRoleErrorTitle": "Не удалось обновить роль участника", + "removeConfirmTitle": "Удалить участника", + "removeConfirmDescription": "После удаления пользователь {{userId}} потеряет доступ к этому пространству имён.", + "removeSuccessTitle": "Участник удалён", + "removeSuccessDescription": "Пользователь {{userId}} удалён из этого пространства имён.", + "removeErrorTitle": "Не удалось удалить участника", + "searchEmptyMemberHint": "Если пользователь уже в этом пространстве имён, он здесь не появится — проверьте список участников.", + "transferOwnership": "Передать владение", + "transferDialogTitle": "Передать владение пространством имён", + "transferDialogDescription": "Передайте владение другому участнику. Вы станете ADMIN.", + "transferWarning": "Это действие нельзя отменить. После передачи вы больше не будете владельцем.", + "transferNewOwnerLabel": "Новый владелец", + "transferSelectPlaceholder": "Выберите участника", + "transferNoCandidates": "Нет других участников, которым можно передать владение.", + "transferConfirmSlugPrompt": "Введите slug пространства имён для подтверждения", + "transferConfirmSlugHint": "Введите {{slug}} для подтверждения", + "transferConfirmAction": "Передать владение", + "transferring": "Передача...", + "transferSuccessTitle": "Владение передано", + "transferSuccessDescription": "{{userId}} теперь владелец этого пространства имён.", + "transferErrorTitle": "Не удалось передать владение", + "batchImport": "Пакетный импорт", + "batchDialogTitle": "Пакетный импорт участников", + "batchDialogDescription": "Загрузите CSV-файл, чтобы добавить нескольких участников сразу. В каждой строке должны быть ID пользователя и роль.", + "batchStepUpload": "Загрузить CSV", + "batchStepPreview": "Предпросмотр", + "batchStepResults": "Результаты", + "batchDownloadTemplate": "Скачать шаблон CSV", + "batchDropHint": "Перетащите CSV-файл сюда или нажмите, чтобы выбрать", + "batchFormatHint": "Поддерживается только формат .csv", + "batchParseError": "Не удалось разобрать CSV-файл", + "batchEmptyFile": "CSV-файл пуст или не содержит допустимых строк", + "batchPreviewTitle": "Предпросмотр ({{count}} строк)", + "batchColUserId": "ID пользователя", + "batchColRole": "Роль", + "batchColStatus": "Статус", + "batchValidationMissingUserId": "Отсутствует ID пользователя", + "batchValidationInvalidRole": "Недопустимая роль (должна быть MEMBER или ADMIN)", + "batchValidationDuplicate": "Дублирующийся ID пользователя", + "batchValidationValid": "Готово", + "batchValidRows": "{{valid}} допустимых, {{invalid}} недопустимых", + "batchSubmitting": "Импорт...", + "batchSubmit": "Импортировать {{count}} участников", + "batchResultTitle": "Результаты импорта", + "batchResultSummary": "{{success}} успешно, {{failure}} с ошибкой из {{total}} всего", + "batchResultSuccess": "Добавлен", + "batchResultAlreadyMember": "Уже участник", + "batchResultUserNotFound": "Пользователь не найден", + "batchResultInvalidRole": "Недопустимая роль", + "batchResultUnknownError": "Неизвестная ошибка", + "batchDone": "Готово", + "batchBack": "Назад" + }, + "namespaceEdit": { + "editButton": "Редактировать", + "dialogTitle": "Редактировать пространство имён", + "displayNameLabel": "Отображаемое имя", + "displayNameRequired": "Требуется отображаемое имя", + "descriptionLabel": "Описание", + "saveAction": "Сохранить", + "saving": "Сохранение...", + "saveSuccess": "Пространство имён успешно обновлено", + "saveErrorTitle": "Не удалось обновить пространство имён" + }, + "apiError": { + "unauthorized": "Сессия истекла, войдите снова", + "forbidden": "У вас нет прав для этого действия", + "notFound": "Запрошенный ресурс не найден", + "serverError": "Ошибка сервера, повторите попытку позже", + "networkError": "Сбой сетевого подключения, проверьте сеть", + "unknown": "Операция не выполнена" + }, + "copyButton": { + "copied": "Скопировано", + "copy": "Копировать" + }, + "createToken": { + "title": "Создать API-токен", + "description": "Создать новый API-токен для доступа через CLI или API", + "nameLabel": "Имя токена", + "namePlaceholder": "например: my-cli-token", + "nameRequired": "Укажите имя токена", + "nameTooLong": "Имя токена не более {{max}} символов", + "nameDuplicate": "У вас уже есть токен с таким именем", + "creating": "Создание...", + "create": "Создать", + "successTitle": "Токен создан", + "successDescription": "Скопируйте и сохраните токен сейчас. Он показывается и копируется только при создании. Если понадобится снова — создайте новый токен.", + "tokenLabel": "Токен", + "nameDisplay": "Имя", + "expirationLabel": "Срок действия", + "expirationHint": "По умолчанию токены не истекают. Можно задать автоматический срок действия.", + "expirationNever": "Без срока", + "expiration7d": "Истекает через 7 дней", + "expiration30d": "Истекает через 30 дней", + "expiration90d": "Истекает через 90 дней", + "expirationCustom": "Своя дата и время", + "expiresAtRequired": "Выберите свой срок действия", + "expiresAtDisplay": "Истекает", + "copyToken": "Копировать токен", + "copySuccess": "Токен скопирован в буфер обмена", + "copyFailed": "Не удалось скопировать токен. Повторите попытку." + }, + "dialog": { + "confirm": "Подтвердить", + "cancel": "Отмена", + "delete": "Удалить", + "close": "Закрыть" + }, + "error": { + "auth": { + "local": { + "invalidCredentials": "Неверное имя пользователя или пароль", + "accountDisabled": "Эта учётная запись отключена", + "accountPending": "Эта учётная запись ожидает активации", + "accountMerged": "Эта учётная запись объединена и больше не может использоваться для входа", + "locked": "Слишком много неудачных попыток. Повторите позже" + }, + "direct": { + "disabled": "Совместимость прямой аутентификации отключена", + "providerUnsupported": "Этот способ входа не поддерживается" + }, + "sessionBootstrap": { + "disabled": "Инициализация сессии отключена", + "providerUnsupported": "Этот способ SSO-инициализации не поддерживается", + "notAuthenticated": "Внешняя аутентифицированная сессия не найдена" + } + } + }, + "filePreview": { + "loadError": "Не удалось загрузить файл", + "tooLarge": "Файл слишком большой для предпросмотра", + "binaryFile": "Бинарные файлы нельзя просмотреть", + "unsupported": "Этот тип файла не поддерживается для предпросмотра", + "downloadFile": "Скачать файл", + "downloadHint": "Скачать {{name}}", + "copy": "Копировать содержимое", + "copySuccess": "Скопировано в буфер обмена", + "close": "Закрыть" + }, + "fileTree": { + "title": "Файлы" + }, + "footer": { + "resources": "Ресурсы", + "docs": "Документация", + "api": "API", + "community": "Сообщество", + "privacy": "Политика конфиденциальности", + "terms": "Условия использования", + "copyright": "© 2026 SkillHub. Все права защищены." + }, + "layout": { + "footerDescription": "Реестр скиллов: эффективное управление и распространение скиллов для разработчиков." + }, + "loginButton": { + "loading": "Загрузка...", + "loginWith": "Войти через {{name}}" + }, + "namespaceStatus": { + "active": "Активно", + "frozen": "Заморожено", + "archived": "В архиве", + "frozenHint": "Это пространство имён сейчас только для чтения: нельзя публиковать, рецензировать или менять участников.", + "archivedHint": "Это пространство имён скрыто из публичных точек входа и видно только участникам на панели.", + "immutableHint": "Это встроенное системное пространство имён: управление и состав участников менять нельзя." + }, + "notification": { + "title": "Уведомления", + "empty": "Нет уведомлений", + "markAllRead": "Отметить все прочитанными", + "deleteRead": "Удалить прочитанные", + "viewAll": "Все уведомления", + "unread": "Непрочитанные", + "all": "Все", + "publish": "Публикация", + "review": "Ревью", + "promotion": "Продвижение", + "report": "Жалоба", + "timeAgo": "{{time}} назад", + "preferences": { + "title": "Настройки уведомлений", + "description": "Управление предпочтениями уведомлений", + "publish": "Уведомления о публикации", + "publishDesc": "Уведомлять, когда скилл опубликован", + "review": "Уведомления о ревью", + "reviewDesc": "Уведомлять об отправке, одобрении или отклонении ревью", + "promotion": "Уведомления о продвижении", + "promotionDesc": "Уведомлять о заявке, одобрении или отклонении продвижения", + "report": "Уведомления о жалобах", + "reportDesc": "Уведомлять о подаче или разрешении жалобы" + } + }, + "nsReviews": { + "title": "Ревью пространства имён", + "loadingNamespace": "Загрузка сведений о пространстве имён", + "reviewsFor": "Задачи ревью для {{name}}", + "empty": "Нет записей ревью", + "version": "Версия {{version}}", + "tabPending": "Ожидают", + "tabApproved": "Одобрено", + "tabRejected": "Отклонено", + "sortLabel": "Порядок по времени", + "sortNewest": "Сначала новые", + "sortOldest": "Сначала старые", + "openReview": "Открыть ревью", + "pageSummary": "Всего записей: {{total}}, страница {{page}}", + "prevPage": "Назад", + "nextPage": "Вперёд", + "globalReadOnly": "Это встроенное системное пространство имён. История ревью видна, но новые действия ревью недоступны.", + "frozenReadOnly": "Это пространство имён заморожено. Историю ревью можно смотреть, но обрабатывать задачи нельзя.", + "archivedReadOnly": "Это пространство имён в архиве. Историю ревью можно смотреть, но обрабатывать задачи нельзя, пока оно не восстановлено." + }, + "pagination": { + "prev": "Назад", + "next": "Вперёд", + "pagePrefix": "Стр.", + "pageSuffix": "", + "goToPage": "Перейти на страницу {{page}}" + }, + "publish": { + "title": "Опубликовать скилл", + "subtitle": "Загрузить пакет скилла в SkillHub", + "reviewNotice": { + "title": "Уведомление о ревью", + "description": "Отправленные пакеты скиллов проходят ревью администратора перед публикацией." + }, + "namespace": "Пространство имён", + "selectNamespace": "Выберите пространство имён", + "visibility": "Видимость", + "visibilityOptions": { + "public": "Публичный", + "namespaceOnly": "Только пространство имён", + "loggedInUsersOnly": "Только вошедшие пользователи", + "private": "Приватный" + }, + "file": "Файл пакета скилла", + "removeSelectedFile": "Убрать выбранный файл", + "publishing": "Публикация...", + "confirm": "Подтвердить публикацию", + "success": "Успешно опубликовано", + "successDescription": "{{skill}} отправлен на ревью и станет доступен после одобрения администратором", + "publishedTitle": "Успешно опубликовано", + "publishedDescription": "{{skill}} уже доступен для скачивания", + "pendingReviewTitle": "Отправлено на ревью", + "pendingReviewDescription": "{{skill}} отправлен на ревью и станет доступен после одобрения администратором", + "error": "Публикация не удалась", + "timeoutTitle": "Истекло время публикации", + "timeoutDescription": "Запрос на публикацию занял слишком много времени. Позже проверьте список скиллов или повторите попытку.", + "versionExistsTitle": "Версия уже существует", + "versionExistsDescription": "Эта версия скилла уже опубликована. Обновите версию в SKILL.md, пересоберите пакет и загрузите снова.", + "precheckFailedTitle": "Проверка перед публикацией не пройдена", + "precheckFailedDescription": "В пакете, похоже, есть секрет, токен или пароль. Замените реальные учётные данные на плейсхолдеры и повторите попытку.", + "warningConfirmTitle": "Предупреждение перед публикацией", + "warningConfirmDescription": "Обнаружены следующие напоминания о рисках. Если вы их понимаете и всё равно хотите продолжить — можно опубликовать.", + "warningConfirmContinue": "Продолжить публикацию", + "warningConfirmCancel": "Вернуться и исправить", + "frontmatterFailedTitle": "Неверный формат SKILL.md", + "frontmatterFailedDescription": "Проверьте YAML frontmatter в начале SKILL.md. Если значение поля содержит двоеточие, заключите его в кавычки.", + "selectRequired": "Выберите пространство имён и файл" + }, + "ratingInput": { + "yourRating": "Ваша оценка: {{score}} зв." + }, + "review": { + "detail": "Детали ревью", + "id": "ID ревью", + "backToList": "К списку", + "namespace": "Пространство имён / Slug", + "version": "Версия", + "status": "Статус", + "statusPending": "Ожидает", + "statusApproved": "Одобрено", + "statusRejected": "Отклонено", + "submitter": "Отправитель", + "submitTime": "Отправлено", + "reviewer": "Рецензент", + "reviewTime": "Рассмотрено", + "reviewComment": "Комментарий ревью", + "actions": "Действия ревью", + "commentLabel": "Комментарий (необязательно)", + "commentPlaceholder": "Введите комментарий ревью...", + "approve": "Одобрить", + "reject": "Отклонить", + "confirmReject": "Подтвердить отклонение", + "cancelReject": "Отмена", + "rejectReasonRequired": "При отклонении нужна причина", + "approveTitle": "Одобрить ревью", + "approveDescription": "Одобрить это ревью?", + "approveConfirm": "Одобрить", + "rejectTitle": "Отклонить ревью", + "rejectDescription": "Отклонить это ревью?", + "rejectConfirm": "Отклонить", + "approveSuccess": "Ревью одобрено", + "approveFailed": "Не удалось одобрить", + "rejectSuccess": "Ревью отклонено", + "rejectFailed": "Не удалось отклонить", + "approveDisabledScanning": "Сканирование безопасности ещё выполняется. Одобрение станет доступно после завершения.", + "notFound": "Задача ревью не найдена", + "skillDetailTitle": "Карточка скилла", + "skillDetailDescription": "Разделы ниже зафиксированы на ожидающей версии, привязанной к этой задаче ревью: {{skill}}", + "skillDetailError": "Не удалось загрузить карточку скилла для этого ревью.", + "activeReviewVersion": "Версия на ревью", + "downloadSkillZip": "Скачать ZIP скилла", + "noDocumentation": "В этой версии на ревью нет читаемого файла документации." + }, + "routeGuard": { + "forbiddenTitle": "У вас нет прав для просмотра этой страницы", + "forbiddenDescription": "Права вашей учётной записи изменились. Возврат на предыдущую страницу." + }, + "securityAudit": { + "title": "Аудит безопасности", + "dialogDescription": "Подробные результаты сканирования безопасности для этой версии.", + "findings": "Находки", + "findingsCount": "Находок: {{count}}", + "totalFindings": "Всего находок: {{count}}", + "scanDuration": "{{seconds}} с", + "statusScanning": "Сканирование", + "statusScanFailed": "Сканирование не удалось", + "remediation": "Рекомендации", + "viewDetails": "Подробности", + "verdict": { + "SAFE": "Безопасно", + "SUSPICIOUS": "Подозрительно", + "DANGEROUS": "Опасно", + "BLOCKED": "Высокий риск" + }, + "severity": { + "CRITICAL": "Критический", + "HIGH": "Высокий", + "MEDIUM": "Средний", + "LOW": "Низкий", + "INFO": "Инфо" + } + }, + "skillCard": { + "starred": "В избранном", + "starredAction": "Нажмите, чтобы убрать из избранного", + "unstarTitle": "Убрать из избранного", + "unstarDescription": "Убрать «{{name}}» из избранных скиллов?", + "unstarConfirm": "Убрать" + }, + "starButton": { + "starred": "В избранном", + "star": "В избранное" + }, + "subscribeButton": { + "subscribed": "Подписка оформлена", + "subscribe": "Подписаться" + }, + "toast": { + "success": "Успех", + "error": "Ошибка", + "warning": "Предупреждение", + "info": "Инфо" + }, + "token": { + "title": "API-токены", + "createNew": "Создать токен", + "copyHint": "Из соображений безопасности открытый текст токена можно скопировать только один раз при создании. Позже его нельзя просмотреть или скопировать снова. Если токен нужен — создайте новый.", + "empty": "Токены ещё не созданы", + "emptyHint": "Нажмите кнопку выше, чтобы создать первый токен", + "name": "Имя", + "prefix": "Префикс токена", + "createdAt": "Создан", + "lastUsed": "Последнее использование", + "expiresAt": "Истекает", + "neverExpires": "Без срока", + "actions": "Действия", + "copy": "Копировать", + "copySuccess": "Токен скопирован в буфер обмена", + "copyFailed": "Не удалось скопировать токен. Повторите попытку.", + "copyUnavailableTitle": "Открытый текст старого токена нельзя скопировать снова", + "copyUnavailableDescription": "Открытый текст токена показывается только один раз после создания. Если его нет — создайте новый токен.", + "editExpiration": "Изменить срок", + "editExpirationTitle": "Изменить срок действия токена", + "editExpirationDescription": "Задайте новый срок действия для токена «{{name}}».", + "saveExpiration": "Сохранить срок", + "updatingExpiration": "Сохранение...", + "updateExpirationSuccess": "Срок действия токена обновлён", + "updateExpirationFailed": "Не удалось обновить срок действия токена", + "delete": "Удалить", + "deleteTitle": "Удалить токен", + "deleteDescription": "Удалить токен «{{name}}»? Это действие нельзя отменить.", + "deleteSuccess": "Токен удалён", + "deleteFailed": "Не удалось удалить", + "loading": "Загрузка..." + }, + "upload": { + "dropHint": "Отпустите для загрузки...", + "dragHint": "Перетащите ZIP-файл сюда или нажмите, чтобы выбрать", + "formatHint": "Поддерживается только формат .zip" + }, + "user": { + "menu": { + "dashboard": "Панель", + "mySkills": "Мои скиллы", + "myNamespaces": "Мои пространства имён", + "governance": "Центр управления", + "stars": "Избранное", + "subscriptions": "Мои подписки", + "reviews": "Управление ревью", + "promotions": "Управление продвижением", + "reports": "Управление жалобами", + "users": "Пользователи", + "labels": "Метки", + "auditLog": "Журнал аудита", + "security": "Безопасность", + "profile": "Профиль", + "notifications": "Уведомления", + "accounts": "Объединение учётных записей", + "logout": "Выйти" + } + }, + "routeError": { + "title": "Что-то пошло не так", + "description": "На этой странице произошла ошибка. Можно вернуться назад или перезагрузить раздел.", + "reset": "Попробовать снова" + } +} diff --git a/web/src/i18n/ru-locale.test.ts b/web/src/i18n/ru-locale.test.ts new file mode 100644 index 000000000..9f262eadc --- /dev/null +++ b/web/src/i18n/ru-locale.test.ts @@ -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).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)[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)[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) + }) +}) diff --git a/web/src/pages/notifications.tsx b/web/src/pages/notifications.tsx index 891c5732c..858b07181 100644 --- a/web/src/pages/notifications.tsx +++ b/web/src/pages/notifications.tsx @@ -12,6 +12,7 @@ import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { Pagination } from '@/shared/components/pagination' import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' +import { formatRelativeTime } from '@/shared/lib/format-relative-time' const PAGE_SIZE = 20 @@ -29,19 +30,6 @@ function getCategoryKey(cat: Category): string { } } -function formatRelativeTime(dateStr: string, lang: string): string { - const diff = Date.now() - new Date(dateStr).getTime() - const minutes = Math.floor(diff / 60_000) - 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() -} - function CategoryBadge({ category }: { category: NotificationItem['category'] }) { const { t } = useTranslation() const colorMap: Record = { diff --git a/web/src/shared/components/language-switcher.tsx b/web/src/shared/components/language-switcher.tsx index d28f54e55..8feae28a5 100644 --- a/web/src/shared/components/language-switcher.tsx +++ b/web/src/shared/components/language-switcher.tsx @@ -24,9 +24,10 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) { const languages = [ { code: 'zh', name: '中文' }, { code: 'en', name: 'English' }, + { code: 'ru', name: 'Русский' }, ] - // 获取当前语言的主要代码(去掉地区代码) + // Primary language code only (strip region, e.g. ru-RU → ru). const currentLangCode = i18n.language?.split('-')[0] || 'zh' const currentLanguage = languages.find((lang) => lang.code === currentLangCode) || languages[0] diff --git a/web/src/shared/lib/api-error.ts b/web/src/shared/lib/api-error.ts index 218f38b1c..2a75e7196 100644 --- a/web/src/shared/lib/api-error.ts +++ b/web/src/shared/lib/api-error.ts @@ -30,6 +30,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() @@ -43,6 +44,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 { diff --git a/web/src/shared/lib/format-relative-time.ts b/web/src/shared/lib/format-relative-time.ts new file mode 100644 index 000000000..b0f022772 --- /dev/null +++ b/web/src/shared/lib/format-relative-time.ts @@ -0,0 +1,34 @@ +/** + * Formats a timestamp as a compact relative time string for notification UI. + * Mirrors the zh inline pattern; ru support added for the Russian locale. + */ +export function formatRelativeTime(dateStr: string, lang: string): string { + const diff = Date.now() - new Date(dateStr).getTime() + const minutes = Math.floor(diff / 60_000) + const hours = Math.floor(diff / 3_600_000) + const days = Math.floor(diff / 86_400_000) + const isChinese = lang.startsWith('zh') + 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(isRussian ? 'ru-RU' : undefined) +}