mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-11 03:37:57 +00:00
feat(i18n): add Russian locale for web UI and server messages
Register ru in i18next and the language switcher, with MessageSource bundle messages_ru.properties. Keep CLI/commands and brand names as-is. Signed-off-by: Mikhail Neradkov <michael.neradkov@gmail.com>
This commit is contained in:
parent
2e4a69d590
commit
62c27696d2
8 changed files with 1824 additions and 9 deletions
180
server/skillhub-app/src/main/resources/messages_ru.properties
Normal file
180
server/skillhub-app/src/main/resources/messages_ru.properties
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
response.success=Успешно
|
||||
response.success.read=Успешно получено
|
||||
response.success.created=Успешно создано
|
||||
response.success.updated=Успешно обновлено
|
||||
response.success.deleted=Успешно удалено
|
||||
response.success.published=Успешно опубликовано
|
||||
response.success.revoked=Успешно отозвано
|
||||
response.success.health=Сервис работает
|
||||
|
||||
validation.namespace.slug.notBlank=Slug не может быть пустым
|
||||
validation.namespace.slug.size=Slug должен содержать от 2 до 64 символов
|
||||
validation.namespace.displayName.notBlank=Отображаемое имя не может быть пустым
|
||||
validation.namespace.displayName.size=Отображаемое имя не должно превышать 128 символов
|
||||
validation.namespace.description.size=Описание не должно превышать 512 символов
|
||||
validation.member.userId.notNull=Требуется ID пользователя
|
||||
validation.member.role.notNull=Требуется роль
|
||||
validation.auth.local.username.notBlank=Имя пользователя не может быть пустым
|
||||
validation.auth.local.password.notBlank=Пароль не может быть пустым
|
||||
validation.auth.local.email.notBlank=Email не может быть пустым
|
||||
validation.auth.local.currentPassword.notBlank=Текущий пароль не может быть пустым
|
||||
validation.auth.local.newPassword.notBlank=Новый пароль не может быть пустым
|
||||
validation.auth.local.email.invalid=Некорректный формат email
|
||||
validation.token.name.notBlank=Имя токена не может быть пустым
|
||||
validation.token.name.size=Имя токена должно содержать не более 64 символов
|
||||
validation.token.expiresAt.invalid=Некорректный формат времени истечения
|
||||
validation.token.expiresAt.future=Время истечения должно быть в будущем
|
||||
error.token.name.duplicate=У вас уже есть токен с таким именем
|
||||
error.token.notFound=Токен не найден: {0}
|
||||
|
||||
error.auth.required=Требуется аутентификация
|
||||
error.auth.local.username.exists=Имя пользователя уже занято
|
||||
error.auth.local.email.exists=Email уже занят
|
||||
error.auth.local.password.tooShort=Пароль должен содержать не менее 8 символов
|
||||
error.auth.local.password.tooLong=Пароль не должен превышать 128 символов
|
||||
error.auth.local.password.tooWeak=Пароль должен включать не менее 3 типов символов
|
||||
error.auth.local.username.invalid=Имя пользователя: 3–64 символа, только буквы, цифры или подчёркивания
|
||||
error.auth.local.invalidCredentials=Неверное имя пользователя или пароль
|
||||
error.auth.local.notEnabled=Вход по локальной учётной записи для этого пользователя не включён
|
||||
error.auth.local.accountDisabled=Эта учётная запись отключена
|
||||
error.auth.local.accountPending=Эта учётная запись ожидает активации
|
||||
error.auth.local.accountMerged=Эта учётная запись объединена и больше не может использоваться для входа
|
||||
error.auth.local.locked=Слишком много неудачных попыток. Повторите через {0} мин.
|
||||
error.auth.login.throttled=Слишком много попыток входа. Повторите через {0} мин.
|
||||
error.auth.direct.disabled=Совместимость прямой аутентификации отключена
|
||||
error.auth.direct.providerUnsupported=Неподдерживаемый провайдер прямой аутентификации: {0}
|
||||
error.auth.sessionBootstrap.disabled=Инициализация сессии отключена
|
||||
error.auth.sessionBootstrap.providerUnsupported=Неподдерживаемый провайдер инициализации сессии: {0}
|
||||
error.auth.sessionBootstrap.notAuthenticated=Внешняя аутентифицированная сессия не найдена
|
||||
error.badRequest=Некорректный запрос
|
||||
error.forbidden=Доступ запрещён
|
||||
error.request.timeout=Время ожидания запроса истекло
|
||||
error.rateLimit.exceeded=Превышен лимит запросов
|
||||
error.storage.unavailable=Объектное хранилище временно недоступно. Повторите попытку позже.
|
||||
error.internal=Произошла непредвиденная ошибка
|
||||
error.slug.blank=Slug не может быть пустым
|
||||
error.slug.length=Длина slug должна быть от {0} до {1} символов
|
||||
error.slug.pattern=Slug может содержать только строчные буквы, цифры и дефисы и должен начинаться и заканчиваться буквой или цифрой
|
||||
error.slug.doubleHyphen=Slug не может содержать два дефиса подряд
|
||||
error.slug.reserved=Slug ''{0}'' зарезервирован и не может быть использован
|
||||
label.definition.too_many=Достигнут лимит определений меток ({0})
|
||||
label.sort_order.empty=Полезная нагрузка обновления порядка сортировки не может быть пустой
|
||||
label.translation.empty=Требуется хотя бы один перевод метки
|
||||
label.translation.locale.blank=Локаль перевода метки не может быть пустой
|
||||
label.translation.display_name.blank=Отображаемое имя перевода метки не может быть пустым
|
||||
label.translation.locale.duplicate=Дублирующаяся локаль перевода метки: {0}
|
||||
label.translation.locale.conflict=Дублирующаяся локаль перевода метки
|
||||
error.namespace.slug.exists=Slug пространства имён ''{0}'' уже существует
|
||||
error.namespace.id.notFound=Пространство имён не найдено: {0}
|
||||
error.namespace.slug.notFound=Пространство имён не найдено: {0}
|
||||
error.namespace.membership.required=Требуется членство в пространстве имён
|
||||
error.namespace.global.members.platformAdmin.required=Только администраторы пользователей платформы могут просматривать участников глобального пространства имён
|
||||
error.namespace.admin.required=Требуется роль владельца или администратора пространства имён
|
||||
error.namespace.owner.required=Требуется роль владельца пространства имён
|
||||
error.namespace.create.platformAdminRequired=Создавать пространства имён могут только SKILL_ADMIN или SUPER_ADMIN
|
||||
error.namespace.delete.hasDependencies=Пространство имён нельзя удалить, пока в нём есть скиллы или записи управления
|
||||
error.namespace.member.owner.assignDirect=Нельзя назначить роль OWNER напрямую
|
||||
error.namespace.member.alreadyExists=Пользователь уже является участником пространства имён
|
||||
error.namespace.member.notFound=Участник не найден
|
||||
error.namespace.member.owner.remove=Нельзя удалить владельца пространства имён
|
||||
error.namespace.member.owner.setDirect=Нельзя задать роль OWNER напрямую, используйте передачу владения
|
||||
error.namespace.member.search.tooShort=Поисковый запрос должен содержать не менее 2 символов
|
||||
error.namespace.owner.current.notFound=Текущий владелец не найден
|
||||
error.namespace.owner.current.invalid=Текущий пользователь не является владельцем пространства имён
|
||||
error.namespace.owner.new.notFound=Новый владелец не является участником пространства имён
|
||||
error.skill.metadata.content.empty=Содержимое SKILL.md не может быть пустым
|
||||
error.skill.metadata.frontmatter.missingStart=Отсутствует начальный маркер frontmatter ''---''
|
||||
error.skill.metadata.frontmatter.missingContent=Отсутствует содержимое frontmatter после начального маркера
|
||||
error.skill.metadata.frontmatter.missingEnd=Отсутствует конечный маркер frontmatter ''---''
|
||||
error.skill.metadata.yaml.notMap=Frontmatter должен быть YAML-объектом
|
||||
error.skill.metadata.yaml.invalid=Некорректный YAML во frontmatter: {0}
|
||||
error.skill.metadata.requiredField.missing=Отсутствует обязательное поле: {0}
|
||||
error.skill.publish.publisher.notMember=Публикующий не является участником пространства имён: {0}
|
||||
error.skill.publish.package.invalid=Проверка пакета не пройдена: {0}
|
||||
error.skill.publish.skillMd.notFound=SKILL.md не найден
|
||||
error.skill.publish.precheck.confirmRequired=Предупреждения перед публикацией требуют подтверждения:\n{0}
|
||||
error.skill.publish.precheck.failed=Проверка перед публикацией не пройдена: {0}
|
||||
error.security.scanner.required=Перед публикацией публичных или видимых в пространстве имён скиллов необходимо включить сканер безопасности
|
||||
error.skill.publish.archived=Архивный скилл нужно восстановить перед публикацией: {0}
|
||||
review.withdraw.not_pending=Отозвать можно только заявки на ревью со статусом pending: {0}
|
||||
review.withdraw.not_submitter=Отозвать это ревью может только отправитель
|
||||
review.approve.scan_in_progress=Сканирование безопасности ещё выполняется. Одобрение недоступно до завершения сканирования.
|
||||
review_task.not_found_for_version=Не найдена ожидающая заявка на ревью для версии: {0}
|
||||
error.skill.publish.summary.tooLong=Описание скилла не должно превышать {0} символов
|
||||
error.skill.notFound=Скилл не найден: {0}
|
||||
error.skill.access.denied=Нет доступа к скиллу: {0}
|
||||
error.skill.status.notActive=Скилл не активен
|
||||
error.skill.lifecycle.noPermission=Управлять этим скиллом может только владелец скилла или администратор пространства имён
|
||||
error.skill.version.exists=Версия уже существует: {0}
|
||||
error.skill.version.notFound=Версия не найдена: {0}
|
||||
error.skill.version.notPublished=Версия не опубликована: {0}
|
||||
error.skill.version.delete.unsupported=Удалять можно только версии в статусах DRAFT, UPLOADED, REJECTED или SCAN_FAILED: {0}
|
||||
error.skill.version.delete.lastVersion=Нельзя удалить последнюю оставшуюся версию: {0}
|
||||
error.skill.version.compare.same=Нельзя сравнить версию саму с собой
|
||||
error.skill.report.reason.required=Укажите причину жалобы
|
||||
error.skill.report.unavailable=Сейчас на этот скилл нельзя пожаловаться: {0}
|
||||
error.skill.report.self=Нельзя пожаловаться на свой собственный скилл
|
||||
error.skill.report.duplicate=У вас уже есть ожидающая жалоба на этот скилл
|
||||
error.skill.report.notFound=Жалоба на скилл не найдена: {0}
|
||||
error.skill.report.alreadyHandled=Эта жалоба на скилл уже обработана
|
||||
error.skill.report.status.invalid=Неподдерживаемый статус жалобы на скилл: {0}
|
||||
error.skill.version.latest.unavailable=Нет опубликованной версии для скилла: {0}
|
||||
error.skill.version.latest.notFound=Последняя опубликованная версия не найдена
|
||||
error.skill.file.notFound=Файл не найден: {0}
|
||||
error.skill.tag.latest.reserved=Имя тега ''latest'' зарезервировано
|
||||
error.skill.tag.latest.delete=Имя тега ''latest'' зарезервировано и не может быть удалено
|
||||
error.skill.tag.notFound=Тег не найден: {0}
|
||||
error.skill.tag.targetVersion.notPublished=Целевая версия должна быть опубликована
|
||||
error.skill.tag.version.missing=Тег не указывает на версию: {0}
|
||||
error.skill.tag.version.notFound=Версия, на которую указывает тег, не найдена: {0}
|
||||
error.skill.bundle.notFound=Опубликованный пакет не найден в хранилище
|
||||
error.skill.resolve.versionTag.conflict=Параметры version и tag нельзя использовать вместе
|
||||
error.deviceAuth.userCode.invalid=Неверный или просроченный код пользователя
|
||||
error.deviceAuth.deviceCode.expired=Код устройства истёк
|
||||
error.deviceAuth.deviceCode.invalid=Код устройства истёк или недействителен
|
||||
error.deviceAuth.deviceCode.used=Код устройства уже использован
|
||||
error.admin.user.notFound=Пользователь не найден: {0}
|
||||
error.admin.user.role.invalid=Неверная роль: {0}
|
||||
error.admin.user.role.superAdmin.assignDenied=Изменять состояние роли SUPER_ADMIN может только SUPER_ADMIN
|
||||
error.admin.user.systemAccount.immutable=Системные учётные записи нельзя изменять из управления пользователями
|
||||
error.admin.user.status.invalid=Неверный статус пользователя: {0}
|
||||
error.admin.user.status.unsupported=Здесь можно управлять только статусами ACTIVE или DISABLED
|
||||
error.skill.publish.nameConflict=Опубликованный скилл с именем ''{0}'' уже существует в этом пространстве имён
|
||||
error.skill.publish.nameConflict.private=Приватный скилл с именем ''{0}'' уже опубликован в этом пространстве имён
|
||||
error.skill.approve.nameConflict=Нельзя одобрить: опубликованный скилл с именем ''{0}'' уже существует в этом пространстве имён
|
||||
error.skill.version.submit.notUploaded=Версия ''{0}'' не в статусе UPLOADED и не может быть отправлена на ревью
|
||||
error.skill.version.confirm.notUploaded=Версия ''{0}'' не в статусе UPLOADED и не может быть подтверждена
|
||||
error.skill.confirm.notPrivate=Confirm-publish доступен только для PRIVATE скиллов
|
||||
error.skill.version.notDownloadable=Версия ''{0}'' недоступна для скачивания
|
||||
|
||||
# Profile update
|
||||
error.profile.displayName.length=Отображаемое имя должно содержать от 2 до 32 символов
|
||||
error.profile.displayName.pattern=Отображаемое имя может содержать только китайские иероглифы, латинские буквы, цифры, пробелы, подчёркивания и дефисы
|
||||
error.profile.noChanges=Необходимо указать хотя бы одно поле
|
||||
response.profile.updated=Профиль успешно обновлён
|
||||
response.profile.pendingReview=Изменения профиля отправлены на ревью
|
||||
|
||||
error.profileReview.notFound=Заявка на изменение профиля не найдена
|
||||
error.profileReview.notPending=Эта заявка уже рассмотрена
|
||||
error.profileReview.commentRequired=Требуется причина отклонения
|
||||
error.profileReview.commentTooLong=Причина отклонения не должна превышать 500 символов
|
||||
error.profileReview.status.invalid=Неверный статус ревью: {0}
|
||||
error.profileReview.userDisabled=Нельзя применить изменения — учётная запись пользователя отключена
|
||||
|
||||
# Password reset
|
||||
response.auth.password.reset.requested=Если учётная запись подходит, код подтверждения для сброса пароля отправлен.
|
||||
response.auth.password.reset.confirmed=Пароль успешно сброшен. Войдите с новым паролем.
|
||||
error.auth.password.reset.invalid.code=Код подтверждения недействителен или истёк.
|
||||
error.auth.password.reset.not.eligible=Эта учётная запись не подходит для сброса пароля.
|
||||
error.auth.password.reset.no.credential=У этой учётной записи нет локальных учётных данных.
|
||||
error.auth.password.reset.email.failed=Не удалось отправить код подтверждения для сброса пароля. Повторите попытку позже.
|
||||
validation.auth.password.reset.email.notBlank=Email не может быть пустым
|
||||
validation.auth.password.reset.email.invalid=Некорректный формат email
|
||||
validation.auth.password.reset.code.notBlank=Код подтверждения не может быть пустым
|
||||
validation.auth.password.reset.code.invalid=Код подтверждения должен состоять из 6 цифр
|
||||
validation.auth.password.reset.newPassword.notBlank=Новый пароль не может быть пустым
|
||||
promotion.target_skill_conflict=Целевой глобальный скилл "{0}" уже существует
|
||||
promotion.status.invalid=Неподдерживаемый статус продвижения: {0}
|
||||
promotion.sort.field.invalid=Неподдерживаемое поле сортировки продвижения: {0}
|
||||
promotion.sort.direction.invalid=Неподдерживаемое направление сортировки продвижения: {0}
|
||||
promotion.sort.pending_unsupported=Ожидающие заявки на продвижение не поддерживают сортировку по времени ревью
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import i18n from 'i18next'
|
|||
import { initReactI18next } from 'react-i18next'
|
||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||
import en from './locales/en.json'
|
||||
import ru from './locales/ru.json'
|
||||
import zh from './locales/zh.json'
|
||||
|
||||
/**
|
||||
|
|
@ -15,6 +16,7 @@ i18n
|
|||
.init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
ru: { translation: ru },
|
||||
zh: { translation: zh },
|
||||
},
|
||||
fallbackLng: 'en',
|
||||
|
|
|
|||
1552
web/src/i18n/locales/ru.json
Normal file
1552
web/src/i18n/locales/ru.json
Normal file
File diff suppressed because it is too large
Load diff
51
web/src/i18n/ru-locale.test.ts
Normal file
51
web/src/i18n/ru-locale.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import en from './locales/en.json'
|
||||
import ru from './locales/ru.json'
|
||||
|
||||
function leafKeys(value: unknown, prefix = ''): string[] {
|
||||
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return Object.entries(value as Record<string, unknown>).flatMap(([key, child]) =>
|
||||
leafKeys(child, prefix ? `${prefix}.${key}` : key),
|
||||
)
|
||||
}
|
||||
return [prefix]
|
||||
}
|
||||
|
||||
function placeholders(text: string): string[] {
|
||||
return [...text.matchAll(/\{\{[^}]+\}\}/g)].map((match) => match[0]).sort()
|
||||
}
|
||||
|
||||
describe('russian locale', () => {
|
||||
it('mirrors the english key tree', () => {
|
||||
expect(leafKeys(ru).sort()).toEqual(leafKeys(en).sort())
|
||||
})
|
||||
|
||||
it('preserves interpolation placeholders', () => {
|
||||
const enMap = Object.fromEntries(leafKeys(en).map((key) => {
|
||||
const parts = key.split('.')
|
||||
let cursor: unknown = en
|
||||
for (const part of parts) {
|
||||
cursor = (cursor as Record<string, unknown>)[part]
|
||||
}
|
||||
return [key, String(cursor)]
|
||||
}))
|
||||
const mismatches: string[] = []
|
||||
for (const key of leafKeys(ru)) {
|
||||
const parts = key.split('.')
|
||||
let cursor: unknown = ru
|
||||
for (const part of parts) {
|
||||
cursor = (cursor as Record<string, unknown>)[part]
|
||||
}
|
||||
if (placeholders(String(cursor)).join() !== placeholders(enMap[key] ?? '').join()) {
|
||||
mismatches.push(key)
|
||||
}
|
||||
}
|
||||
expect(mismatches).toEqual([])
|
||||
})
|
||||
|
||||
it('translates core navigation labels', () => {
|
||||
expect(ru.nav.home).not.toBe(en.nav.home)
|
||||
expect(ru.nav.home.length).toBeGreaterThan(0)
|
||||
expect(ru.login.title.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -35,11 +35,28 @@ function formatRelativeTime(dateStr: string, lang: string): string {
|
|||
const hours = Math.floor(diff / 3_600_000)
|
||||
const days = Math.floor(diff / 86_400_000)
|
||||
const isChinese = lang.startsWith('zh')
|
||||
if (minutes < 1) return isChinese ? '刚刚' : 'just now'
|
||||
if (minutes < 60) return isChinese ? `${minutes}分钟` : `${minutes}m`
|
||||
if (hours < 24) return isChinese ? `${hours}小时` : `${hours}h`
|
||||
if (days < 30) return isChinese ? `${days}天` : `${days}d`
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
const isRussian = lang.startsWith('ru')
|
||||
if (minutes < 1) {
|
||||
if (isChinese) return '刚刚'
|
||||
if (isRussian) return 'только что'
|
||||
return 'just now'
|
||||
}
|
||||
if (minutes < 60) {
|
||||
if (isChinese) return `${minutes}分钟`
|
||||
if (isRussian) return `${minutes} мин`
|
||||
return `${minutes}m`
|
||||
}
|
||||
if (hours < 24) {
|
||||
if (isChinese) return `${hours}小时`
|
||||
if (isRussian) return `${hours} ч`
|
||||
return `${hours}h`
|
||||
}
|
||||
if (days < 30) {
|
||||
if (isChinese) return `${days}天`
|
||||
if (isRussian) return `${days} д`
|
||||
return `${days}d`
|
||||
}
|
||||
return new Date(dateStr).toLocaleDateString(lang.startsWith('ru') ? 'ru-RU' : undefined)
|
||||
}
|
||||
|
||||
function CategoryBadge({ category }: { category: NotificationItem['category'] }) {
|
||||
|
|
|
|||
|
|
@ -19,11 +19,15 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
|
|||
const languages = [
|
||||
{ code: 'zh', name: '中文' },
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'ru', name: 'Русский' },
|
||||
]
|
||||
|
||||
// 获取当前语言的主要代码(去掉地区代码)
|
||||
const currentLangCode = i18n.language?.split('-')[0] || 'zh'
|
||||
const currentLanguage = languages.find((lang) => lang.code === currentLangCode) || languages[0]
|
||||
// Primary language code only (strip region, e.g. ru-RU → ru).
|
||||
const currentLangCode = i18n.language?.split('-')[0] || 'en'
|
||||
const currentLanguage =
|
||||
languages.find((lang) => lang.code === currentLangCode)
|
||||
|| languages.find((lang) => lang.code === 'en')
|
||||
|| languages[0]
|
||||
|
||||
const changeLanguage = (langCode: string) => {
|
||||
i18n.changeLanguage(langCode)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ function isAccountDisabledError(error: ApiError): boolean {
|
|||
const accountDisabledMessages = [
|
||||
i18n.t('apiError.auth.accountDisabled'),
|
||||
i18n.getFixedT('en')('apiError.auth.accountDisabled'),
|
||||
i18n.getFixedT('ru')('apiError.auth.accountDisabled'),
|
||||
i18n.getFixedT('zh')('apiError.auth.accountDisabled'),
|
||||
]
|
||||
const normalizedServerMessage = (error.serverMessage ?? '').toLowerCase()
|
||||
|
|
@ -42,6 +43,8 @@ function isAccountDisabledError(error: ApiError): boolean {
|
|||
|| normalizedMessage.includes('disabled')
|
||||
|| (error.serverMessage ?? '').includes('禁用')
|
||||
|| error.message.includes('禁用')
|
||||
|| (error.serverMessage ?? '').toLowerCase().includes('отключ')
|
||||
|| error.message.toLowerCase().includes('отключ')
|
||||
}
|
||||
|
||||
export function handleApiError(error: unknown): void {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue