diff --git a/apps/web/src/components/directories/directories-doh-grid.tsx b/apps/web/src/components/directories/directories-doh-grid.tsx index ded0d79..5d8bbe0 100644 --- a/apps/web/src/components/directories/directories-doh-grid.tsx +++ b/apps/web/src/components/directories/directories-doh-grid.tsx @@ -15,7 +15,7 @@ import { import type { DohProfile } from '@/types/api' const filterFields: FilterField[] = [ - createSearchFilterField('search', 'Поиск', 'Поиск DoH профилей…'), + createSearchFilterField('search', 'Поиск', 'Поиск DoH-профилей…'), ] export function DirectoriesDohGrid({ @@ -81,7 +81,7 @@ export function DirectoriesDohGrid({ return ( ) } diff --git a/apps/web/src/components/lookup/lookup-add-step.tsx b/apps/web/src/components/lookup/lookup-add-step.tsx index 2b21a1f..845a107 100644 --- a/apps/web/src/components/lookup/lookup-add-step.tsx +++ b/apps/web/src/components/lookup/lookup-add-step.tsx @@ -20,6 +20,7 @@ import { import { CascaderInput, CascaderValue } from '@/components/reui/cascader/cascader-nav' import type { CascaderNode } from '@/components/reui/cascader/cascader-types' import { ApiError, apiMutate } from '@/lib/api-client' +import { CASCADER_LABELS_RU } from '@/lib/reui-i18n-ru' import type { BgpCommunity, LookupQueryKind, @@ -202,9 +203,7 @@ export function LookupAddStep({ selectable={allowEmptyCommunity ? 'any' : 'leaf'} mode="columns" labels={{ - search: 'Поиск', - empty: 'Ничего не найдено', - back: 'Назад', + ...CASCADER_LABELS_RU, rootLevel: 'Модули', }} > diff --git a/apps/web/src/components/network/speaker-form-dialog.tsx b/apps/web/src/components/network/speaker-form-dialog.tsx index 4955354..df86ec1 100644 --- a/apps/web/src/components/network/speaker-form-dialog.tsx +++ b/apps/web/src/components/network/speaker-form-dialog.tsx @@ -185,7 +185,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps label="Роль" items={[ { value: 'replica', label: 'Реплика' }, - { value: 'master', label: 'Мастер (плоскость)' }, + { value: 'master', label: 'Основной' }, ]} value={role} onValueChange={(v) => setRole(v ?? 'replica')} diff --git a/apps/web/src/components/operations/operations-jobs-kanban.tsx b/apps/web/src/components/operations/operations-jobs-kanban.tsx index 22274bd..84cf8aa 100644 --- a/apps/web/src/components/operations/operations-jobs-kanban.tsx +++ b/apps/web/src/components/operations/operations-jobs-kanban.tsx @@ -31,7 +31,7 @@ import type { JobRow } from '@/types/api' const COLUMN_ORDER = ['queued', 'running', 'succeeded', 'failed'] as const const COLUMN_LABELS: Record<(typeof COLUMN_ORDER)[number], string> = { - queued: 'Очередь', + queued: 'В очереди', running: 'Выполняются', succeeded: 'Успешные', failed: 'Ошибки', diff --git a/apps/web/src/components/reui-kit/frame-data-grid.tsx b/apps/web/src/components/reui-kit/frame-data-grid.tsx index 762015b..11320e4 100644 --- a/apps/web/src/components/reui-kit/frame-data-grid.tsx +++ b/apps/web/src/components/reui-kit/frame-data-grid.tsx @@ -47,7 +47,7 @@ import { FrameTitle, } from '@/components/reui/frame' import { EmptyState } from '@/components/empty-state' -import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults' +import { DATA_GRID_I18N_RU } from '@/lib/reui-i18n-ru' export type DataGridColumnDef = ColumnDef @@ -298,6 +298,7 @@ function FrameDataGridBody({ recordCount={data.length} onRowClick={onRowClick} emptyMessage={emptyTitle} + i18n={DATA_GRID_I18N_RU} tableLayout={kitDataGridTableLayout({ dense, width: tableWidth, @@ -317,7 +318,7 @@ function FrameDataGridBody({ <> - + ) : null} diff --git a/apps/web/src/components/settings/session-settings-tab.tsx b/apps/web/src/components/settings/session-settings-tab.tsx index 9b41e0c..0468365 100644 --- a/apps/web/src/components/settings/session-settings-tab.tsx +++ b/apps/web/src/components/settings/session-settings-tab.tsx @@ -18,7 +18,7 @@ import { } from '@evobgp/ui/components/item' const ROLE_LABELS: Record = { - viewer: 'Просмотр', + viewer: 'Наблюдатель', editor: 'Редактор', operator: 'Оператор', node: 'Нода', diff --git a/apps/web/src/lib/filters-i18n.ts b/apps/web/src/lib/filters-i18n.ts index 4d60fb9..e797553 100644 --- a/apps/web/src/lib/filters-i18n.ts +++ b/apps/web/src/lib/filters-i18n.ts @@ -53,6 +53,46 @@ export const FILTERS_LABELS_RU: Partial = { noValue: 'нет значения', selectCondition: 'Выберите условие', incomplete: 'незавершённый фильтр', + branchAffordance: 'открывает список', + exclusiveHint: 'нельзя сочетать с другими вариантами', + exclusiveAnnouncement: (label, cleared) => + cleared === 1 + ? `Выбрано «${label}». 1 другой вариант сброшен.` + : `Выбрано «${label}». Других вариантов сброшено: ${cleared}.`, + itemCount: (count) => `Элементов: ${count}`, + fieldsLabel: 'Поля', + resultsAnnouncement: (count) => + count === 1 ? '1 результат' : `Результатов: ${count}`, + actionsLabel: 'Действия', + rowLabel: (condition, depth) => `${condition}, уровень ${depth}`, + groupLabel: (description, depth) => `${description}, уровень ${depth}`, + groupAnnouncement: (added) => (added ? 'Группа добавлена' : 'Группа удалена'), + reorderAnnouncement: (label, position, total) => + `${label} перемещён на позицию ${position} из ${total}`, + moveAnnouncement: (label, destination, position, total) => + `${label} перемещён в ${destination}, позиция ${position} из ${total}`, + combinatorLabel: (word) => `${word}, изменить связку`, + chipMenu: (fieldLabel) => `Параметры фильтра «${fieldLabel}»`, + stepAnnouncement: (step, label) => { + if (step === 'field') return `Выберите поле. ${label}` + if (step === 'operator') return `Выберите условие для ${label}` + return `Введите значение для ${label}` + }, + countAnnouncement: (count) => + count === 1 ? 'Применён 1 фильтр' : `Применено фильтров: ${count}`, + valueCount: (count) => `Выбрано: ${count}`, + valueRange: (from, to) => `${from} – ${to}`, + rangeFrom: (fieldLabel) => `${fieldLabel} от`, + rangeTo: (fieldLabel) => `${fieldLabel} до`, + rangeSeparator: '—', + negated: (operatorLabel) => `не ${operatorLabel}`, + issueOperator: 'Выберите условие', + issueValue: 'Введите значение', + issueRange: 'Заполните оба конца диапазона', + issueRangeOrder: 'Конец диапазона раньше его начала', + issueEmptyGroup: 'В этой группе пока нет условий', + issueSummary: (count) => + count === 1 ? '1 строка требует внимания' : `Строк требует внимания: ${count}`, } export const FILTERS_OPERATOR_LABELS_RU: FilterOperatorLabels = { diff --git a/apps/web/src/lib/metrics/module-type-breakdown.ts b/apps/web/src/lib/metrics/module-type-breakdown.ts index cbf08b1..09b3430 100644 --- a/apps/web/src/lib/metrics/module-type-breakdown.ts +++ b/apps/web/src/lib/metrics/module-type-breakdown.ts @@ -3,7 +3,7 @@ import type { ModuleRow, ModuleType } from '@/types/api' import type { BreakdownSlice } from './types' const TYPE_META: Record = { - AS_PREFIXES: { label: 'AS / префиксы', color: 'var(--color-chart-1)' }, + AS_PREFIXES: { label: 'AS-префиксы', color: 'var(--color-chart-1)' }, DOMAINS: { label: 'Домены', color: 'var(--color-chart-2)' }, CDN_CIDRS: { label: 'CDN', color: 'var(--color-chart-3)' }, IP_RANGES: { label: 'IP-диапазоны', color: 'var(--color-chart-4)' }, diff --git a/apps/web/src/lib/reui-i18n-ru.ts b/apps/web/src/lib/reui-i18n-ru.ts new file mode 100644 index 0000000..37bfffc --- /dev/null +++ b/apps/web/src/lib/reui-i18n-ru.ts @@ -0,0 +1,126 @@ +import type { + DataGridI18nLabels, + DataGridI18nOverrides, +} from '@/components/reui/data-grid/data-grid-i18n' +import type { CascaderLabels } from '@/components/reui/cascader/cascader-types' + +/** + * Русские формы множественного числа: 1 элемент, 2 элемента, 5 элементов. + */ +function ruPlural(count: number, one: string, few: string, many: string): string { + const mod10 = count % 10 + const mod100 = count % 100 + if (mod10 === 1 && mod100 !== 11) return one + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return few + return many +} + +/** + * Единый русский словарь ReUI DataGrid — официальный i18n-слой (@reui/data-grid-i18n). + * Подключается один раз через `i18n={DATA_GRID_I18N_RU}` на + * (FrameDataGrid и ResourcePage) — наследуют все гриды приложения. + * Полнота словаря проверяется компилятором: тип DataGridI18nLabels требует все ключи. + * + * @see https://reui.io/docs/components/base/data-grid + */ +const DATA_GRID_LABELS_RU: DataGridI18nLabels = { + /* Меню заголовка столбца. */ + sortAscending: 'По возрастанию', + sortDescending: 'По убыванию', + pinColumnStart: 'Закрепить слева', + pinColumnEnd: 'Закрепить справа', + moveColumnStart: 'Сдвинуть влево', + moveColumnEnd: 'Сдвинуть вправо', + columnsMenu: 'Столбцы', + unpinColumn: (title) => `Открепить столбец «${title}»`, + toggleColumns: 'Настройка столбцов', + /* Афордансы строк и ячеек. */ + rowCreate: 'Добавить строку', + pinRow: 'Закрепить строку', + unpinRow: 'Открепить строку', + selectRow: 'Выбрать строку', + selectAll: 'Выбрать всё', + expandRow: 'Развернуть строку', + collapseRow: 'Свернуть строку', + dragToReorder: 'Перетащите, чтобы изменить порядок', + dragToReorderRow: 'Перетащите, чтобы изменить порядок строк', + reorderingUnavailable: 'Изменение порядка недоступно', + /* Состояния грида. */ + loading: 'Загрузка…', + empty: 'Нет данных', + allRowsLoaded: 'Все записи загружены', + /* Пагинация. */ + rowsPerPage: 'Строк на странице', + paginationInfo: ({ from, to, count }) => `${from}–${to} из ${count}`, + previousPage: 'Предыдущая страница', + nextPage: 'Следующая страница', + goToPage: (page) => `Перейти на страницу ${page}`, + paginationEllipsis: '…', + /* Фасетный фильтр столбца. */ + filterSelectedCount: (count) => `Выбрано: ${count}`, + filterNoResults: 'Ничего не найдено.', + filterClear: 'Сбросить фильтры', +} + +export const DATA_GRID_I18N_RU: DataGridI18nOverrides = { + labels: DATA_GRID_LABELS_RU, +} + +/** + * Единый русский словарь ReUI Cascader (@reui/cascader-i18n). + * Передаётся в `labels` каскадера; поверх — контекстный `rootLevel`. + * + * @see https://reui.io/docs/components/base/cascader + */ +const CASCADER_ROOT_LEVEL_RU = 'Корневой уровень' + +const cascaderItemCount = (count: number) => + `${count} ${ruPlural(count, 'элемент', 'элемента', 'элементов')}` + +export const CASCADER_LABELS_RU: CascaderLabels = { + search: (parentLabel) => + parentLabel ? `Поиск: ${parentLabel}…` : 'Поиск…', + back: 'Назад', + loading: 'Загрузка…', + loadingMore: 'Загрузка…', + loadMore: 'Ещё', + error: 'Не удалось загрузить элементы.', + retry: 'Повторить', + empty: 'Ничего не найдено.', + selectedCount: (count) => `Выбрано: ${count}`, + breadcrumbLabel: 'Навигация', + chipsLabel: 'Выбранные элементы', + removeChip: (label) => `Удалить «${label}»`, + pathSeparator: '/', + rootLevel: CASCADER_ROOT_LEVEL_RU, + itemCount: cascaderItemCount, + branchAffordance: 'подменю', + selectedState: 'выбрано', + partiallySelectedState: 'частично выбрано', + columnsLabel: 'Уровни', + actionsLabel: 'Действия', + submenuAffordance: 'открывает меню', + panelLabel: 'Варианты', + keyboardHint: (mode, dir) => { + const open = dir === 'rtl' ? 'влево' : 'вправо' + const back = dir === 'rtl' ? 'вправо' : 'влево' + if (mode === 'tree') { + return `Стрелка ${open} — раскрыть уровень, стрелка ${back} — свернуть.` + } + if (mode === 'columns') { + return `Стрелка ${open} — открыть следующий столбец, стрелка ${back} — вернуться.` + } + return `Стрелка ${open} — открыть ветку, стрелка ${back} — вернуться.` + }, + rootAnnouncement: (count) => `${CASCADER_ROOT_LEVEL_RU}, ${cascaderItemCount(count)}`, + expandedAnnouncement: (label, count) => `${label} раскрыт, ${cascaderItemCount(count)}`, + collapsedAnnouncement: (label) => `${label} свёрнут`, + levelAnnouncement: (parentLabel, depth, count) => + `${parentLabel}, уровень ${depth}, ${cascaderItemCount(count)}`, + resultsAnnouncement: (count) => + count === 1 ? '1 результат' : `Результатов: ${count}`, + maxReachedAnnouncement: (max) => `Достигнут предел выбора: ${max}`, + cascadeAnnouncement: (label, count, selecting) => + `${label} ${selecting ? 'выбрано' : 'снято'}, вложенных затронуто: ${count}`, + searchingAnnouncement: 'Поиск…', +} diff --git a/apps/web/src/lib/ui-labels.ts b/apps/web/src/lib/ui-labels.ts index 92904fa..a10728e 100644 --- a/apps/web/src/lib/ui-labels.ts +++ b/apps/web/src/lib/ui-labels.ts @@ -16,7 +16,7 @@ export function dohPolicyRu(policy: DohResolverPolicy | string | null | undefine export function moduleTypeRu(type: string): string { switch (type) { case 'AS_PREFIXES': - return 'AS (номера)' + return 'AS-префиксы' case 'CDN_CIDRS': return 'CDN CIDR' case 'DOMAINS': @@ -30,7 +30,7 @@ export function moduleTypeRu(type: string): string { const JOB_KIND_RU: Record = { module_refresh: 'Обновление модуля', - tenant_refresh: 'Обновление тенанта', + tenant_refresh: 'Обновление арендатора', peer_reconcile: 'Согласование пиров', deploy_apply: 'Применение на спикеры', apply: 'Применение конфигурации', @@ -60,7 +60,7 @@ export function jobKindRu(kind: string): string { const JOB_TRIGGER_RU: Record = { scheduler: 'по расписанию', - api: 'вручную', + api: 'через API', peer_patch: 'правка пиров', } @@ -129,7 +129,7 @@ const JOB_STATUS_RU: Record = { disabled: 'Выключен', archived: 'В архиве', block: 'Блокировать', - accept: 'Принимать', + accept: 'Принять', } export function jobStatusRu(status: string): string { diff --git a/apps/web/src/routes/_auth/directories.tsx b/apps/web/src/routes/_auth/directories.tsx index a1282c1..ab7eb30 100644 --- a/apps/web/src/routes/_auth/directories.tsx +++ b/apps/web/src/routes/_auth/directories.tsx @@ -55,7 +55,7 @@ function DirectoriesComponent() { ), }, { - label: 'DoH профили', + label: 'DoH-профили', value: dohProfiles.length, icon: , footer: ( @@ -137,7 +137,7 @@ function DirectoriesComponent() { defaultValue="communities" items={[ { value: 'communities', label: 'BGP community', count: communities.length }, - { value: 'doh', label: 'DoH профили', count: dohProfiles.length, badgeVariant: 'info-light' }, + { value: 'doh', label: 'DoH-профили', count: dohProfiles.length, badgeVariant: 'info-light' }, ]} >