feat: Удаление устаревших файлов документации и оптимизация структуры проекта. Упрощение кода и улучшение читаемости за счет удаления ненужных компонентов и отчетов, что способствует более эффективному управлению проектом.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m34s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m34s
This commit is contained in:
@@ -1,321 +0,0 @@
|
|||||||
# Улучшения Router Lists UI - 2025
|
|
||||||
|
|
||||||
## 📋 Обзор
|
|
||||||
|
|
||||||
Данный документ описывает улучшения, внесенные в проект Router Lists UI. Все изменения внедрены постепенно и аккуратно, чтобы не нарушить работу существующего кода.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Реализованные улучшения
|
|
||||||
|
|
||||||
### 1. 🔐 Валидация и Безопасность входных данных
|
|
||||||
|
|
||||||
#### Backend (`backend/lib/validators.js`)
|
|
||||||
Создан новый модуль валидации с функциями:
|
|
||||||
|
|
||||||
- **`isValidIPv4(ip)`** - валидация IPv4 адресов
|
|
||||||
- **`isValidIPv6(ip)`** - валидация IPv6 адресов
|
|
||||||
- **`isValidCIDRv4(cidr)`** - валидация CIDR блоков IPv4
|
|
||||||
- **`isValidCIDRv6(cidr)`** - валидация CIDR блоков IPv6
|
|
||||||
- **`isValidDomain(domain)`** - валидация доменных имен (поддержка IDN)
|
|
||||||
- **`isValidWildcardDomain(domain)`** - валидация wildcard доменов (*.example.com)
|
|
||||||
- **`isValidASN(asn)`** - валидация номеров ASN
|
|
||||||
- **`isValidCommunity(community)`** - валидация BGP Community (форматы N и N:N)
|
|
||||||
- **`isValidGateway(gateway)`** - валидация имен gateway для MikroTik
|
|
||||||
- **`sanitizeString(str)`** - санитизация строк от опасных символов
|
|
||||||
- **`isSafeSQLString(str)`** - проверка на SQL injection паттерны
|
|
||||||
- **`isSafeXSSString(str)`** - проверка на XSS паттерны
|
|
||||||
- **`validateData(data, schema)`** - комплексная валидация по схеме
|
|
||||||
|
|
||||||
#### Улучшенный Rate Limiting (`backend/server.js`)
|
|
||||||
- **Общий лимитер**: 1000 запросов за 15 минут
|
|
||||||
- **Лимитер для записи**: 100 запросов за 5 минут (применен к POST endpoints)
|
|
||||||
- **Лимитер для BGP**: 5 запросов за 1 минуту (применен к `/api/update-bgp/background`)
|
|
||||||
|
|
||||||
#### Интеграция валидации
|
|
||||||
Добавлена серверная валидация для всех POST endpoints:
|
|
||||||
- `/api/domains-new` - валидация доменов и communities
|
|
||||||
- `/api/ip-ranges` - валидация IP/CIDR и communities
|
|
||||||
- `/api/asns` - валидация ASN и communities
|
|
||||||
- `/api/communities` - валидация community значений
|
|
||||||
|
|
||||||
**Преимущества:**
|
|
||||||
- ✅ Защита от невалидных данных на уровне сервера
|
|
||||||
- ✅ Предотвращение XSS и injection атак
|
|
||||||
- ✅ Контроль частоты запросов (защита от DDoS)
|
|
||||||
- ✅ Детальные сообщения об ошибках валидации
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 🏷️ Расширенная работа с Community
|
|
||||||
|
|
||||||
#### Backend
|
|
||||||
|
|
||||||
**Новый endpoint** (`/api/communities/stats`):
|
|
||||||
- Подсчет использования каждого community
|
|
||||||
- Статистика по всем типам данных: домены, IP ranges, ASNs, фильтры
|
|
||||||
- Сортировка по частоте использования
|
|
||||||
|
|
||||||
**Расширенные поля communities**:
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
value: '65000:100',
|
|
||||||
name: 'Social Media',
|
|
||||||
description: 'Traffic for social networks',
|
|
||||||
tags: ['video', 'social'],
|
|
||||||
gatewayDefault: 'SWE-HIPHOST',
|
|
||||||
color: 'blue',
|
|
||||||
// Новые поля:
|
|
||||||
category: 'Media',
|
|
||||||
priority: 10,
|
|
||||||
enabled: true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Frontend
|
|
||||||
|
|
||||||
**Новый компонент** (`frontend/src/components/CommunityStats.jsx`):
|
|
||||||
- Отображение общей статистики использования
|
|
||||||
- Top-10 самых используемых communities
|
|
||||||
- Группировка по категориям
|
|
||||||
- Визуализация через progress bars
|
|
||||||
- Интерактивные графики
|
|
||||||
|
|
||||||
**Улучшения CommunitiesManager**:
|
|
||||||
- Вкладки "Список" и "Статистика"
|
|
||||||
- Переключение между режимами просмотра
|
|
||||||
- Поддержка новых полей (category, priority, enabled)
|
|
||||||
|
|
||||||
**Преимущества:**
|
|
||||||
- ✅ Понимание какие communities используются чаще всего
|
|
||||||
- ✅ Группировка и категоризация для лучшей организации
|
|
||||||
- ✅ Визуальное представление статистики
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. 📊 Улучшенная визуализация данных
|
|
||||||
|
|
||||||
#### Новый компонент (`frontend/src/components/TopNStats.jsx`)
|
|
||||||
|
|
||||||
**Top-5 стран**:
|
|
||||||
- Подсчет серверов по странам
|
|
||||||
- Флаги стран (эмодзи)
|
|
||||||
- Progress bars для визуализации
|
|
||||||
- Выделение лидера (🏆)
|
|
||||||
|
|
||||||
**Top-5 провайдеров**:
|
|
||||||
- Подсчет серверов по провайдерам
|
|
||||||
- Цветовые индикаторы
|
|
||||||
- Progress bars
|
|
||||||
- Выделение лидера
|
|
||||||
|
|
||||||
#### Интеграция в Dashboard
|
|
||||||
- Добавлена секция "Топ статистика"
|
|
||||||
- Отображение после основных метрик
|
|
||||||
- Responsive дизайн
|
|
||||||
|
|
||||||
**Преимущества:**
|
|
||||||
- ✅ Быстрый обзор распределения инфраструктуры
|
|
||||||
- ✅ Наглядная визуализация
|
|
||||||
- ✅ Помощь в принятии решений о расширении
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. ⚙️ Улучшенный Filter Manager
|
|
||||||
|
|
||||||
#### Backend (`backend/lib/mikrotik-validator.js`)
|
|
||||||
|
|
||||||
**Валидация синтаксиса MikroTik**:
|
|
||||||
- Проверка сбалансированности фигурных скобок
|
|
||||||
- Валидация if/else конструкций
|
|
||||||
- Проверка команд set gw
|
|
||||||
- Валидация bgp-communities includes
|
|
||||||
- Проверка корректности community форматов
|
|
||||||
- Проверка gateway имен
|
|
||||||
|
|
||||||
**Новый endpoint** (`/api/mikrotik/validate`):
|
|
||||||
```javascript
|
|
||||||
POST /api/mikrotik/validate
|
|
||||||
{
|
|
||||||
"config": "// MikroTik config..."
|
|
||||||
}
|
|
||||||
|
|
||||||
Response:
|
|
||||||
{
|
|
||||||
"valid": true|false,
|
|
||||||
"errors": [
|
|
||||||
{ "line": 42, "message": "..." }
|
|
||||||
],
|
|
||||||
"warnings": [
|
|
||||||
{ "line": 15, "message": "..." }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Преимущества:**
|
|
||||||
- ✅ Проверка синтаксиса перед применением конфигурации
|
|
||||||
- ✅ Раннее обнаружение ошибок
|
|
||||||
- ✅ Детальные сообщения с номерами строк
|
|
||||||
- ✅ Предупреждения о потенциальных проблемах
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📁 Структура новых файлов
|
|
||||||
|
|
||||||
```
|
|
||||||
backend/
|
|
||||||
├── lib/
|
|
||||||
│ ├── validators.js ✨ НОВЫЙ: Модуль валидации данных
|
|
||||||
│ └── mikrotik-validator.js ✨ НОВЫЙ: Валидация MikroTik конфигураций
|
|
||||||
|
|
||||||
frontend/
|
|
||||||
└── src/
|
|
||||||
└── components/
|
|
||||||
├── CommunityStats.jsx ✨ НОВЫЙ: Статистика communities
|
|
||||||
└── TopNStats.jsx ✨ НОВЫЙ: Top-N статистика для Dashboard
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Измененные файлы
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
- `backend/server.js` - интеграция валидаторов, новые endpoints, улучшенный rate limiting
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- `frontend/src/CommunitiesManager.jsx` - добавлены вкладки и интеграция статистики
|
|
||||||
- `frontend/src/Dashboard.jsx` - добавлена Top-N статистика
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Как использовать новые возможности
|
|
||||||
|
|
||||||
### 1. Валидация данных
|
|
||||||
Все POST запросы автоматически валидируются на сервере. В случае ошибок валидации API вернет:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": "E_VALIDATION",
|
|
||||||
"message": "Ошибки валидации",
|
|
||||||
"details": {
|
|
||||||
"errors": [
|
|
||||||
"Элемент 0: неверный домен \"invalid..domain\"",
|
|
||||||
"Элемент 5: неверный community \"abc\""
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Статистика Communities
|
|
||||||
1. Перейдите в раздел **Данные → Communities**
|
|
||||||
2. Нажмите вкладку **"Статистика использования"**
|
|
||||||
3. Просмотрите:
|
|
||||||
- Общую статистику
|
|
||||||
- Top-10 самых используемых
|
|
||||||
- Распределение по категориям
|
|
||||||
|
|
||||||
### 3. Top-N статистика на Dashboard
|
|
||||||
Откройте главную страницу (Dashboard) - внизу появится секция "Топ статистика" с:
|
|
||||||
- Top-5 стран
|
|
||||||
- Top-5 провайдеров
|
|
||||||
|
|
||||||
### 4. Валидация MikroTik конфигурации
|
|
||||||
Используйте API endpoint для проверки конфигурации перед применением:
|
|
||||||
```javascript
|
|
||||||
const response = await fetch('/api/mikrotik/validate', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ config: yourConfigString })
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
// result.valid, result.errors, result.warnings
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Достигнутые цели
|
|
||||||
|
|
||||||
### Безопасность
|
|
||||||
- ✅ Серверная валидация всех входных данных
|
|
||||||
- ✅ Защита от XSS и injection атак
|
|
||||||
- ✅ Улучшенный rate limiting с разными лимитами для разных операций
|
|
||||||
|
|
||||||
### Функциональность
|
|
||||||
- ✅ Статистика использования communities
|
|
||||||
- ✅ Расширенные поля для communities (категории, приоритет)
|
|
||||||
- ✅ Top-N статистика на Dashboard
|
|
||||||
- ✅ Валидация синтаксиса MikroTik конфигураций
|
|
||||||
|
|
||||||
### UX/UI
|
|
||||||
- ✅ Вкладки в Communities Manager
|
|
||||||
- ✅ Визуализация статистики через progress bars
|
|
||||||
- ✅ Эмодзи флаги для стран
|
|
||||||
- ✅ Детальные сообщения об ошибках
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐛 Исправления
|
|
||||||
|
|
||||||
### Docker сборка
|
|
||||||
**Проблема**: При первой сборке модули `lib/validators.js` и `lib/mikrotik-validator.js` не копировались в Docker контейнер.
|
|
||||||
|
|
||||||
**Решение**: Обновлен `Dockerfile.fast` - добавлена строка:
|
|
||||||
```dockerfile
|
|
||||||
COPY backend/lib ./lib
|
|
||||||
```
|
|
||||||
|
|
||||||
Теперь папка `lib` с модулями валидации корректно копируется в контейнер.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ Обратная совместимость
|
|
||||||
|
|
||||||
Все изменения **обратно совместимы**:
|
|
||||||
- ✅ Существующий код продолжает работать без изменений
|
|
||||||
- ✅ Новые поля в communities опциональны
|
|
||||||
- ✅ Валидация не блокирует существующие данные
|
|
||||||
- ✅ Rate limiting имеет достаточно высокие лимиты для нормальной работы
|
|
||||||
- ✅ Dockerfile обновлен для корректной работы в Docker
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Рекомендации по дальнейшему использованию
|
|
||||||
|
|
||||||
1. **Мониторинг rate limiting** - если пользователи сталкиваются с 429 ошибками, увеличьте лимиты
|
|
||||||
2. **Наполнение категорий** - добавьте категории к существующим communities для лучшей организации
|
|
||||||
3. **Использование валидации** - интегрируйте `/api/mikrotik/validate` в Filter Manager UI
|
|
||||||
4. **Анализ статистики** - регулярно проверяйте статистику communities для оптимизации
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔜 Идеи для будущих улучшений
|
|
||||||
|
|
||||||
1. **Шаблоны фильтров** - сохранение и быстрое применение готовых наборов фильтров
|
|
||||||
2. **Автоматическое тестирование** - unit и integration тесты
|
|
||||||
3. **Графики динамики** - отображение изменений данных во времени
|
|
||||||
4. **Экспорт отчетов** - генерация PDF/Excel отчетов со статистикой
|
|
||||||
5. **WebSocket real-time** - обновление данных в реальном времени
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📞 Поддержка
|
|
||||||
|
|
||||||
При возникновении проблем проверьте:
|
|
||||||
1. Логи backend (`console.log` и Pino логи)
|
|
||||||
2. Network вкладку в DevTools браузера
|
|
||||||
3. Prometheus метрики (`/metrics`)
|
|
||||||
|
|
||||||
Все новые endpoints логируются с помощью Pino и имеют requestId для трассировки.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ Итого
|
|
||||||
|
|
||||||
**Добавлено файлов**: 4
|
|
||||||
**Изменено файлов**: 4
|
|
||||||
**Новых API endpoints**: 2
|
|
||||||
**Новых компонентов**: 2
|
|
||||||
**Улучшений безопасности**: ✅ Существенные
|
|
||||||
**Совместимость**: ✅ Полная
|
|
||||||
|
|
||||||
Все изменения протестированы и готовы к использованию! 🎉
|
|
||||||
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
# Отчет об оптимизации кода проекта Router Lists UI
|
|
||||||
|
|
||||||
Дата: 2 октября 2025
|
|
||||||
Статус: Завершено
|
|
||||||
|
|
||||||
## 🎯 Цель оптимизации
|
|
||||||
|
|
||||||
Упростить и оптимизировать кодовую базу проекта без нарушения функциональности.
|
|
||||||
|
|
||||||
## 📊 Что было сделано
|
|
||||||
|
|
||||||
### 1. ✅ Frontend - Исправление дублирования QueryClient
|
|
||||||
|
|
||||||
**Проблема:** QueryClient создавался дважды (в `main.jsx` и `App.jsx`), что приводило к избыточности и потенциальным проблемам с кэшированием.
|
|
||||||
|
|
||||||
**Решение:**
|
|
||||||
- Удален дублирующий экземпляр из `main.jsx`
|
|
||||||
- Оставлен единственный экземпляр в `App.jsx` с правильной конфигурацией
|
|
||||||
- Упрощены импорты
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `frontend/src/main.jsx` - убран QueryClient и QueryClientProvider
|
|
||||||
- `frontend/src/App.jsx` - объединены импорты, создан единый QueryClient
|
|
||||||
|
|
||||||
**Результат:** Уменьшено дублирование кода, улучшена производительность
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. ✅ Backend - Объединение дублирующихся валидаторов
|
|
||||||
|
|
||||||
**Проблема:** Функции `isValidCommunity` и `isValidGateway` были продублированы в двух файлах:
|
|
||||||
- `backend/lib/validators.js`
|
|
||||||
- `backend/lib/mikrotik-validator.js`
|
|
||||||
|
|
||||||
**Решение:**
|
|
||||||
- Удалены дублирующие функции из `mikrotik-validator.js`
|
|
||||||
- Добавлен импорт из `validators.js` для переиспользования
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `backend/lib/mikrotik-validator.js` - убраны дублирующие валидаторы
|
|
||||||
- `backend/lib/validators.js` - основной источник валидаторов
|
|
||||||
|
|
||||||
**Результат:** Устранено дублирование ~50 строк кода
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. ✅ Backend - Создание общего S3 сервиса
|
|
||||||
|
|
||||||
**Проблема:** Логика работы с S3 была размазана по всему `server.js` (~2486 строк), множество повторяющихся операций.
|
|
||||||
|
|
||||||
**Решение:**
|
|
||||||
Создан централизованный сервис `backend/services/s3Service.js` со следующими функциями:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Основные операции
|
|
||||||
- readS3TextObject() // Чтение текстовых файлов с кэшированием
|
|
||||||
- writeS3TextObject() // Запись текстовых файлов
|
|
||||||
- writeS3JsonObject() // Запись JSON файлов
|
|
||||||
- deleteS3Object() // Удаление объектов
|
|
||||||
- headS3ObjectEtag() // Получение ETag
|
|
||||||
- headMeta() // Получение метаданных
|
|
||||||
- streamPaginatedText() // Потоковое чтение с пагинацией
|
|
||||||
- invalidateCacheForKey() // Инвалидация кэша
|
|
||||||
|
|
||||||
// Вспомогательные
|
|
||||||
- streamToString() // Конвертация stream в string
|
|
||||||
- getCache() / setCache() // Работа с кэшем
|
|
||||||
```
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `backend/services/s3Service.js` - новый централизованный сервис
|
|
||||||
|
|
||||||
**Результат:**
|
|
||||||
- Переиспользуемая логика S3
|
|
||||||
- Централизованное кэширование
|
|
||||||
- Упрощение основного кода
|
|
||||||
- ~300 строк вынесено в отдельный модуль
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. ✅ Backend - Вынос middleware в отдельные файлы
|
|
||||||
|
|
||||||
**Проблема:** Вся логика middleware, обработки ошибок и блокировок была в `server.js`.
|
|
||||||
|
|
||||||
**Решение:**
|
|
||||||
Созданы отдельные модули:
|
|
||||||
|
|
||||||
#### `backend/middleware/errorHandler.js`
|
|
||||||
```javascript
|
|
||||||
- sendOk() // Отправка успешного ответа
|
|
||||||
- sendError() // Отправка ошибки
|
|
||||||
- checkIfNoneMatch() // Проверка ETag
|
|
||||||
- errorHandler() // Центральный обработчик ошибок
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `backend/middleware/lockManager.js`
|
|
||||||
```javascript
|
|
||||||
- getLockStatus() // Получить статус блокировки
|
|
||||||
- acquireLock() // Получить/обновить блокировку
|
|
||||||
- releaseLock() // Освободить блокировку
|
|
||||||
- cleanupExpiredLocks() // Очистка истекших блокировок
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `backend/utils/helpers.js`
|
|
||||||
```javascript
|
|
||||||
- toIso() // Конвертация даты
|
|
||||||
- splitWhitespace() // Разбиение строки
|
|
||||||
- sha256OfString() // SHA256 хэш
|
|
||||||
- mapAjvErrors() // Форматирование ошибок AJV
|
|
||||||
- resourceToKey() // Маппинг ресурсов на S3 ключи
|
|
||||||
- buildNestedGatewayBlocks() // Генерация MikroTik конфигурации
|
|
||||||
```
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `backend/middleware/errorHandler.js`
|
|
||||||
- `backend/middleware/lockManager.js`
|
|
||||||
- `backend/utils/helpers.js`
|
|
||||||
|
|
||||||
**Результат:** ~200 строк вынесено в переиспользуемые модули
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. ✅ Backend - Создание общих роутов для однотипных эндпоинтов
|
|
||||||
|
|
||||||
**Проблема:** Дублирование логики для похожих эндпоинтов (domains, asns, ip-ranges, servers, filters, billing).
|
|
||||||
|
|
||||||
**Решение:**
|
|
||||||
Созданы фабрики роутов:
|
|
||||||
|
|
||||||
#### `backend/routes/textDataRoutes.js`
|
|
||||||
Для текстовых данных (domains, asns, ip-ranges):
|
|
||||||
```javascript
|
|
||||||
- createTextDataGET() // Общий GET эндпоинт
|
|
||||||
- createTextDataPOST() // Общий POST эндпоинт
|
|
||||||
- createTextDataRoutes() // Фабрика роутов
|
|
||||||
```
|
|
||||||
|
|
||||||
Поддерживает:
|
|
||||||
- Пагинацию (`offset`, `limit`)
|
|
||||||
- Поиск (`q=`)
|
|
||||||
- Подсчет (`countOnly=true`)
|
|
||||||
- Кэширование
|
|
||||||
- Валидацию ETag
|
|
||||||
- Потоковое чтение больших файлов
|
|
||||||
|
|
||||||
#### `backend/routes/jsonDataRoutes.js`
|
|
||||||
Для JSON данных (servers, filters, billing):
|
|
||||||
```javascript
|
|
||||||
- createJsonDataGET() // Общий GET эндпоинт
|
|
||||||
- createJsonDataPOST() // Общий POST эндпоинт
|
|
||||||
- createJsonDataRoutes() // Фабрика роутов
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `backend/routes/communitiesRoutes.js`
|
|
||||||
Специализированные роуты для communities:
|
|
||||||
```javascript
|
|
||||||
- getCommunities() // GET /api/communities
|
|
||||||
- postCommunities() // POST /api/communities
|
|
||||||
- getCommunityStats() // GET /api/communities/stats
|
|
||||||
```
|
|
||||||
|
|
||||||
**Использование:**
|
|
||||||
```javascript
|
|
||||||
const domainsRoutes = createTextDataRoutes({
|
|
||||||
s3Key: 'bgp_data/domains_community.txt',
|
|
||||||
mapLine: (line) => { /* парсинг */ },
|
|
||||||
formatLine: (obj) => { /* форматирование */ },
|
|
||||||
validate: validateSchema,
|
|
||||||
validateItem: validateItemFunc,
|
|
||||||
cachePrefix: 'domains-new'
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/domains-new', domainsRoutes.get);
|
|
||||||
app.post('/api/domains-new', domainsRoutes.post);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `backend/routes/textDataRoutes.js`
|
|
||||||
- `backend/routes/jsonDataRoutes.js`
|
|
||||||
- `backend/routes/communitiesRoutes.js`
|
|
||||||
|
|
||||||
**Результат:**
|
|
||||||
- ~400 строк дублирующего кода заменено на переиспользуемые фабрики
|
|
||||||
- Упрощение добавления новых эндпоинтов
|
|
||||||
- Единообразие в обработке данных
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📈 Итоговые улучшения
|
|
||||||
|
|
||||||
### Метрики
|
|
||||||
|
|
||||||
| Метрика | До | После | Улучшение |
|
|
||||||
|---------|----|----|-----------|
|
|
||||||
| Размер server.js | 2486 строк | ~1500 строк (после полного рефакторинга) | -40% |
|
|
||||||
| Дублирование кода | Высокое | Минимальное | -70% |
|
|
||||||
| Модульность | Низкая (1 файл) | Высокая (10+ модулей) | +900% |
|
|
||||||
| Переиспользуемость | 10% | 80% | +700% |
|
|
||||||
| Валидаторы | 2 копии | 1 источник истины | -50% |
|
|
||||||
| QueryClient (frontend) | 2 экземпляра | 1 экземпляр | -50% |
|
|
||||||
|
|
||||||
### Качественные улучшения
|
|
||||||
|
|
||||||
✅ **Читаемость кода**
|
|
||||||
- Каждый модуль имеет четкую ответственность
|
|
||||||
- Логика разделена по слоям (routes/services/middleware)
|
|
||||||
- Комментарии и JSDoc документация
|
|
||||||
|
|
||||||
✅ **Поддерживаемость**
|
|
||||||
- Легко найти нужную логику
|
|
||||||
- Изменения в одном месте вместо нескольких
|
|
||||||
- Упрощенное тестирование
|
|
||||||
|
|
||||||
✅ **Масштабируемость**
|
|
||||||
- Простое добавление новых эндпоинтов через фабрики
|
|
||||||
- Централизованная логика S3 и кэширования
|
|
||||||
- Модульная структура
|
|
||||||
|
|
||||||
✅ **Производительность**
|
|
||||||
- Единый QueryClient на фронтенде
|
|
||||||
- Централизованное кэширование S3 операций
|
|
||||||
- Оптимизированное потоковое чтение
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🗂️ Новая структура проекта
|
|
||||||
|
|
||||||
```
|
|
||||||
backend/
|
|
||||||
├── lib/
|
|
||||||
│ ├── validators.js ✨ Оптимизировано (убраны дубликаты)
|
|
||||||
│ └── mikrotik-validator.js ✨ Оптимизировано (использует validators.js)
|
|
||||||
├── middleware/
|
|
||||||
│ ├── errorHandler.js 🆕 Новый модуль
|
|
||||||
│ └── lockManager.js 🆕 Новый модуль
|
|
||||||
├── routes/
|
|
||||||
│ ├── textDataRoutes.js 🆕 Фабрика роутов для текстовых данных
|
|
||||||
│ ├── jsonDataRoutes.js 🆕 Фабрика роутов для JSON данных
|
|
||||||
│ └── communitiesRoutes.js 🆕 Специализированные роуты
|
|
||||||
├── services/
|
|
||||||
│ └── s3Service.js 🆕 Централизованный S3 сервис
|
|
||||||
├── utils/
|
|
||||||
│ └── helpers.js 🆕 Вспомогательные утилиты
|
|
||||||
└── server.js ⏳ Готов к рефакторингу (использует новые модули)
|
|
||||||
|
|
||||||
frontend/
|
|
||||||
├── src/
|
|
||||||
│ ├── main.jsx ✨ Оптимизировано (убран дублирующий QueryClient)
|
|
||||||
│ └── App.jsx ✨ Оптимизировано (единый QueryClient, упрощены импорты)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Дальнейшие рекомендации
|
|
||||||
|
|
||||||
### Краткосрочные (можно сделать сразу)
|
|
||||||
1. **Переписать server.js** - использовать созданные модули для всех эндпоинтов
|
|
||||||
2. **Добавить unit-тесты** для новых модулей
|
|
||||||
3. **Создать роуты для остальных эндпоинтов** (filters, auto-urls, billing)
|
|
||||||
|
|
||||||
### Среднесрочные
|
|
||||||
1. **Добавить TypeScript** для лучшей типизации
|
|
||||||
2. **Настроить ESLint** с правилами для обнаружения дублирования
|
|
||||||
3. **Создать документацию API** на базе новых модулей
|
|
||||||
|
|
||||||
### Долгосрочные
|
|
||||||
1. **Добавить интеграционные тесты**
|
|
||||||
2. **Настроить CI/CD** с проверкой качества кода
|
|
||||||
3. **Рассмотреть переход на более современную архитектуру** (например, NestJS)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Проверка работоспособности
|
|
||||||
|
|
||||||
Все изменения **обратно совместимы**. API остался без изменений:
|
|
||||||
|
|
||||||
- ✅ Frontend работает как раньше
|
|
||||||
- ✅ Все эндпоинты доступны
|
|
||||||
- ✅ Валидация работает
|
|
||||||
- ✅ S3 операции работают
|
|
||||||
- ✅ Кэширование работает
|
|
||||||
- ✅ Блокировки работают
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Заключение
|
|
||||||
|
|
||||||
Выполнена успешная оптимизация кодовой базы с **нулевым breaking change**. Код стал:
|
|
||||||
- 🎯 **Проще** - меньше дублирования
|
|
||||||
- 🔧 **Удобнее** - модульная структура
|
|
||||||
- 🚀 **Быстрее** - оптимизированное кэширование
|
|
||||||
- 📚 **Понятнее** - четкое разделение ответственности
|
|
||||||
|
|
||||||
Все изменения готовы к продакшену и могут быть развернуты немедленно.
|
|
||||||
|
|
||||||
-2309
File diff suppressed because it is too large
Load Diff
@@ -1,531 +0,0 @@
|
|||||||
# 🎨 UX/UI Улучшения Router Lists UI - 2025
|
|
||||||
|
|
||||||
## 📋 Обзор
|
|
||||||
|
|
||||||
Данный документ описывает комплексные UX/UI улучшения, внесенные в проект Router Lists UI. Все компоненты разработаны **исключительно на стилях Tabler UI** без использования сторонних UI библиотек.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ РЕАЛИЗОВАННЫЕ УЛУЧШЕНИЯ
|
|
||||||
|
|
||||||
### 🎯 ВЫСОКИЙ ПРИОРИТЕТ
|
|
||||||
|
|
||||||
#### 1. Оптимизация навигации и информационной архитектуры
|
|
||||||
|
|
||||||
##### 📍 Улучшенный компонент Breadcrumbs
|
|
||||||
**Файл**: `frontend/src/components/Breadcrumbs.jsx`
|
|
||||||
|
|
||||||
**Новые возможности**:
|
|
||||||
- ✅ Кнопка "Назад" с сохранением состояния навигации
|
|
||||||
- ✅ Динамические счетчики результатов в breadcrumbs
|
|
||||||
- ✅ Отображение информации об активных фильтрах
|
|
||||||
- ✅ Автоматический подсчет записей с правильными склонениями
|
|
||||||
|
|
||||||
**Использование**:
|
|
||||||
```jsx
|
|
||||||
<Breadcrumbs
|
|
||||||
items={[
|
|
||||||
{ label: 'Главная', to: '/dashboard' },
|
|
||||||
{ label: 'Домены', count: 1250 }
|
|
||||||
]}
|
|
||||||
showBack={true}
|
|
||||||
filterInfo="Community: Cloudflare"
|
|
||||||
count={1250}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 2. Улучшения таблиц и списков данных
|
|
||||||
|
|
||||||
##### 🔖 SavedFilters - Сохраненные фильтры
|
|
||||||
**Файл**: `frontend/src/components/SavedFilters.jsx`
|
|
||||||
|
|
||||||
**Возможности**:
|
|
||||||
- ✅ Сохранение комбинаций фильтров в localStorage
|
|
||||||
- ✅ Быстрое применение сохраненных фильтров
|
|
||||||
- ✅ Удаление неактуальных фильтров
|
|
||||||
- ✅ Отображение даты создания фильтра
|
|
||||||
|
|
||||||
**Использование**:
|
|
||||||
```jsx
|
|
||||||
<SavedFilters
|
|
||||||
pageKey="domains" // уникальный ключ страницы
|
|
||||||
currentFilters={{ searchTerm, filterCommunity, sortField }}
|
|
||||||
onApplyFilter={(filters) => {
|
|
||||||
setSearchTerm(filters.searchTerm)
|
|
||||||
setFilterCommunity(filters.filterCommunity)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
##### ☑️ BulkActionsBar - Массовые операции
|
|
||||||
**Файл**: `frontend/src/components/BulkActionsBar.jsx`
|
|
||||||
|
|
||||||
**Возможности**:
|
|
||||||
- ✅ Выбор множества элементов через чекбоксы
|
|
||||||
- ✅ Кнопка "Выбрать все" / "Снять выбор"
|
|
||||||
- ✅ Массовое удаление, редактирование, экспорт
|
|
||||||
- ✅ Sticky позиционирование при скролле
|
|
||||||
- ✅ Кастомные действия через props
|
|
||||||
|
|
||||||
**Использование**:
|
|
||||||
```jsx
|
|
||||||
<BulkActionsBar
|
|
||||||
selectedCount={selectedItems.size}
|
|
||||||
totalCount={totalItems}
|
|
||||||
onSelectAll={() => selectAll()}
|
|
||||||
onDeselectAll={() => clearSelection()}
|
|
||||||
onDelete={() => handleBulkDelete()}
|
|
||||||
onExport={() => handleBulkExport()}
|
|
||||||
customActions={[
|
|
||||||
{
|
|
||||||
icon: IconEdit,
|
|
||||||
label: 'Изменить community',
|
|
||||||
onClick: handleBulkEditCommunity
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 3. Формы и валидация
|
|
||||||
|
|
||||||
##### ✓ ValidatedInput - Input с inline валидацией
|
|
||||||
**Файл**: `frontend/src/components/ValidatedInput.jsx`
|
|
||||||
|
|
||||||
**Возможности**:
|
|
||||||
- ✅ Real-time валидация с debounce
|
|
||||||
- ✅ Визуальные индикаторы (галочка/крестик)
|
|
||||||
- ✅ Подсказки при ошибках
|
|
||||||
- ✅ Поддержка required полей
|
|
||||||
- ✅ Автофокус
|
|
||||||
|
|
||||||
**Использование**:
|
|
||||||
```jsx
|
|
||||||
<ValidatedInput
|
|
||||||
label="IP адрес"
|
|
||||||
value={ip}
|
|
||||||
onChange={setIp}
|
|
||||||
onValidate={(value) => {
|
|
||||||
const valid = /^(\d{1,3}\.){3}\d{1,3}$/.test(value)
|
|
||||||
return {
|
|
||||||
valid,
|
|
||||||
message: valid
|
|
||||||
? 'IP адрес корректен'
|
|
||||||
: 'Введите IPv4 (например: 1.1.1.1)'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
helpText="Введите IPv4 адрес"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 4. Feedback и уведомления
|
|
||||||
|
|
||||||
##### 🔔 ToastContainer - Система toast уведомлений
|
|
||||||
**Файлы**:
|
|
||||||
- `frontend/src/components/ToastContainer.jsx`
|
|
||||||
- Интеграция в `frontend/src/App.jsx`
|
|
||||||
|
|
||||||
**Возможности**:
|
|
||||||
- ✅ 4 типа уведомлений: success, error, warning, info
|
|
||||||
- ✅ Автозакрытие с настраиваемой длительностью
|
|
||||||
- ✅ Действия в уведомлениях (кнопки "Отменить", "Повторить")
|
|
||||||
- ✅ Анимированное появление/исчезновение
|
|
||||||
- ✅ Глобальный доступ через `window.toast`
|
|
||||||
|
|
||||||
**Использование**:
|
|
||||||
```jsx
|
|
||||||
// Через hook
|
|
||||||
const toast = useToast()
|
|
||||||
toast.success('Данные сохранены')
|
|
||||||
toast.error('Ошибка соединения', { duration: 0 }) // не закроется автоматически
|
|
||||||
toast.warning('Проверьте данные', { duration: 5000 })
|
|
||||||
toast.info('Обновление доступно', {
|
|
||||||
actions: [
|
|
||||||
{ label: 'Обновить', onClick: () => window.location.reload() }
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
// Через window (обратная совместимость)
|
|
||||||
window.toast.success('Успех!')
|
|
||||||
```
|
|
||||||
|
|
||||||
##### ⏳ ProgressIndicator - Индикаторы прогресса
|
|
||||||
**Файл**: `frontend/src/components/ProgressIndicator.jsx`
|
|
||||||
|
|
||||||
**Компоненты**:
|
|
||||||
1. **ProgressIndicator** - полный индикатор с кнопкой отмены
|
|
||||||
2. **LinearProgress** - компактный линейный
|
|
||||||
3. **CircularProgress** - кольцевой индикатор
|
|
||||||
|
|
||||||
**Использование**:
|
|
||||||
```jsx
|
|
||||||
// Полный индикатор
|
|
||||||
<ProgressIndicator
|
|
||||||
current={45}
|
|
||||||
total={100}
|
|
||||||
message="Обработка доменов..."
|
|
||||||
variant="blue"
|
|
||||||
onCancel={() => cancelOperation()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
// Линейный
|
|
||||||
<LinearProgress
|
|
||||||
progress={75}
|
|
||||||
message="Загрузка..."
|
|
||||||
variant="green"
|
|
||||||
/>
|
|
||||||
|
|
||||||
// Кольцевой
|
|
||||||
<CircularProgress
|
|
||||||
progress={60}
|
|
||||||
size={64}
|
|
||||||
variant="purple"
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
##### 💀 Skeleton Loaders - Улучшенные загрузчики
|
|
||||||
**Файл**: `frontend/src/components/TableSkeleton.jsx`
|
|
||||||
|
|
||||||
**Компоненты**:
|
|
||||||
1. **TableSkeleton** - для таблиц (с поддержкой чекбоксов)
|
|
||||||
2. **CardSkeleton** - для карточек статистики
|
|
||||||
3. **ListSkeleton** - для списков
|
|
||||||
4. **FormSkeleton** - для форм
|
|
||||||
|
|
||||||
**Использование**:
|
|
||||||
```jsx
|
|
||||||
import TableSkeleton, { CardSkeleton, ListSkeleton, FormSkeleton } from './components/TableSkeleton'
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<TableSkeleton rows={10} cols={5} hasCheckbox={true} />
|
|
||||||
) : (
|
|
||||||
<table>...</table>
|
|
||||||
)}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 5. Keyboard Shortcuts и Command Palette
|
|
||||||
|
|
||||||
##### ⌨️ CommandPalette - Глобальный поиск (Ctrl+K)
|
|
||||||
**Файлы**:
|
|
||||||
- `frontend/src/components/CommandPalette.jsx`
|
|
||||||
- Интеграция в `frontend/src/App.jsx`
|
|
||||||
|
|
||||||
**Возможности**:
|
|
||||||
- ✅ Открытие по Ctrl+K / Cmd+K
|
|
||||||
- ✅ Навигация стрелками ↑↓
|
|
||||||
- ✅ Фильтрация команд по keywords
|
|
||||||
- ✅ Быстрый переход по страницам
|
|
||||||
- ✅ Подсветка активной команды
|
|
||||||
|
|
||||||
**Горячие клавиши**:
|
|
||||||
- `Ctrl+K` - Открыть command palette
|
|
||||||
- `↑` `↓` - Навигация по списку
|
|
||||||
- `Enter` - Выполнить команду
|
|
||||||
- `Esc` - Закрыть
|
|
||||||
|
|
||||||
##### 📋 KeyboardShortcutsButton - Справка по горячим клавишам
|
|
||||||
**Использование**: Добавлена кнопка в навбар
|
|
||||||
|
|
||||||
**Список горячих клавиш**:
|
|
||||||
- `Ctrl+K` - Поиск команд
|
|
||||||
- `Ctrl+N` - Добавить новую запись
|
|
||||||
- `Ctrl+S` - Сохранить
|
|
||||||
- `Ctrl+F` - Поиск по таблице
|
|
||||||
- `Esc` - Закрыть / Отменить
|
|
||||||
- `↑` `↓` - Навигация
|
|
||||||
- `Enter` - Подтвердить
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🎨 СРЕДНИЙ ПРИОРИТЕТ
|
|
||||||
|
|
||||||
#### 6. Визуальная иерархия
|
|
||||||
|
|
||||||
##### 📊 Sparkline - Мини-графики
|
|
||||||
**Файл**: `frontend/src/components/Sparkline.jsx`
|
|
||||||
|
|
||||||
**Компоненты**:
|
|
||||||
1. **Sparkline** - линейный график
|
|
||||||
2. **SparklineWithTrend** - с процентом изменения
|
|
||||||
3. **SparklineBar** - столбчатый график
|
|
||||||
|
|
||||||
**Возможности**:
|
|
||||||
- ✅ Чистый SVG без библиотек
|
|
||||||
- ✅ Настраиваемые цвета и размеры
|
|
||||||
- ✅ Опциональная заливка под графиком
|
|
||||||
- ✅ Отображение точек на графике
|
|
||||||
|
|
||||||
**Использование**:
|
|
||||||
```jsx
|
|
||||||
// Простой sparkline
|
|
||||||
<Sparkline
|
|
||||||
data={[10, 20, 15, 30, 25, 40]}
|
|
||||||
width={100}
|
|
||||||
height={30}
|
|
||||||
strokeColor="#206bc4"
|
|
||||||
fillColor="#206bc4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
// С трендом
|
|
||||||
<SparklineWithTrend
|
|
||||||
data={[100, 105, 110, 120]}
|
|
||||||
label="за 7 дней"
|
|
||||||
showPercentage={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
// Столбчатый
|
|
||||||
<SparklineBar
|
|
||||||
data={[5, 8, 3, 12, 7]}
|
|
||||||
width={100}
|
|
||||||
height={30}
|
|
||||||
barColor="#2fb344"
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 КАК ИСПОЛЬЗОВАТЬ
|
|
||||||
|
|
||||||
### Интеграция в существующие страницы
|
|
||||||
|
|
||||||
#### Пример: DomainsNewManager с новыми компонентами
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import { useState } from 'react'
|
|
||||||
import Breadcrumbs from './components/Breadcrumbs'
|
|
||||||
import SavedFilters from './components/SavedFilters'
|
|
||||||
import BulkActionsBar from './components/BulkActionsBar'
|
|
||||||
import ValidatedInput from './components/ValidatedInput'
|
|
||||||
import TableSkeleton from './components/TableSkeleton'
|
|
||||||
import { useToast } from './components/ToastContainer'
|
|
||||||
|
|
||||||
function DomainsNewManager() {
|
|
||||||
const [selectedItems, setSelectedItems] = useState(new Set())
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [filters, setFilters] = useState({ searchTerm: '', community: '' })
|
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
const handleBulkDelete = async () => {
|
|
||||||
try {
|
|
||||||
await api.delete('/domains-new/bulk', { ids: Array.from(selectedItems) })
|
|
||||||
toast.success(`Удалено ${selectedItems.size} доменов`)
|
|
||||||
setSelectedItems(new Set())
|
|
||||||
} catch (e) {
|
|
||||||
toast.error('Ошибка удаления', { duration: 0 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Breadcrumbs с кнопкой назад */}
|
|
||||||
<Breadcrumbs
|
|
||||||
items={[
|
|
||||||
{ label: 'Главная', to: '/dashboard' },
|
|
||||||
{ label: 'Данные' },
|
|
||||||
{ label: 'Домены', count: items.length }
|
|
||||||
]}
|
|
||||||
showBack={true}
|
|
||||||
filterInfo={filters.community ? `Community: ${filters.community}` : null}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Сохраненные фильтры */}
|
|
||||||
<div className="mb-3">
|
|
||||||
<SavedFilters
|
|
||||||
pageKey="domains"
|
|
||||||
currentFilters={filters}
|
|
||||||
onApplyFilter={setFilters}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bulk actions */}
|
|
||||||
<BulkActionsBar
|
|
||||||
selectedCount={selectedItems.size}
|
|
||||||
totalCount={items.length}
|
|
||||||
onSelectAll={() => setSelectedItems(new Set(items.map(i => i.id)))}
|
|
||||||
onDeselectAll={() => setSelectedItems(new Set())}
|
|
||||||
onDelete={handleBulkDelete}
|
|
||||||
onExport={handleBulkExport}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Таблица с skeleton loader */}
|
|
||||||
{loading ? (
|
|
||||||
<TableSkeleton rows={10} cols={3} hasCheckbox={true} />
|
|
||||||
) : (
|
|
||||||
<table className="table">
|
|
||||||
{/* таблица */}
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📐 ДИЗАЙН-СИСТЕМА
|
|
||||||
|
|
||||||
### Цветовая палитра Tabler
|
|
||||||
|
|
||||||
```scss
|
|
||||||
// Основные цвета
|
|
||||||
$blue: #206bc4; // primary
|
|
||||||
$azure: #4299e1; // info
|
|
||||||
$indigo: #4263eb;
|
|
||||||
$purple: #ae3ec9;
|
|
||||||
$pink: #d6336c;
|
|
||||||
$red: #d63939; // danger
|
|
||||||
$orange: #f76707; // warning
|
|
||||||
$yellow: #f59f00;
|
|
||||||
$lime: #74b816;
|
|
||||||
$green: #2fb344; // success
|
|
||||||
$teal: #0ca678;
|
|
||||||
$cyan: #17a2b8;
|
|
||||||
|
|
||||||
// Semantic colors
|
|
||||||
$primary: $blue;
|
|
||||||
$success: $green;
|
|
||||||
$warning: $orange;
|
|
||||||
$danger: $red;
|
|
||||||
$info: $azure;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Использование badges
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// Успех
|
|
||||||
<span className="badge bg-green-lt text-green">Активен</span>
|
|
||||||
|
|
||||||
// Ошибка
|
|
||||||
<span className="badge bg-red-lt text-red">Офлайн</span>
|
|
||||||
|
|
||||||
// Предупреждение
|
|
||||||
<span className="badge bg-yellow-lt text-yellow">Внимание</span>
|
|
||||||
|
|
||||||
// Информация
|
|
||||||
<span className="badge bg-blue-lt text-blue">123</span>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Иконки (Tabler Icons)
|
|
||||||
|
|
||||||
Все иконки из `@tabler/icons-react`:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import {
|
|
||||||
IconCheck,
|
|
||||||
IconX,
|
|
||||||
IconAlertTriangle,
|
|
||||||
IconWorld,
|
|
||||||
IconServer,
|
|
||||||
IconFilter
|
|
||||||
} from '@tabler/icons-react'
|
|
||||||
|
|
||||||
<IconCheck size={20} className="text-success" />
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 СТАТИСТИКА УЛУЧШЕНИЙ
|
|
||||||
|
|
||||||
### Созданные компоненты
|
|
||||||
|
|
||||||
| Компонент | Файл | Строк кода | Статус |
|
|
||||||
|-----------|------|------------|--------|
|
|
||||||
| Breadcrumbs | `Breadcrumbs.jsx` | 82 | ✅ |
|
|
||||||
| SavedFilters | `SavedFilters.jsx` | 170 | ✅ |
|
|
||||||
| BulkActionsBar | `BulkActionsBar.jsx` | 110 | ✅ |
|
|
||||||
| ToastContainer | `ToastContainer.jsx` | 160 | ✅ |
|
|
||||||
| ValidatedInput | `ValidatedInput.jsx` | 120 | ✅ |
|
|
||||||
| ProgressIndicator | `ProgressIndicator.jsx` | 180 | ✅ |
|
|
||||||
| Sparkline | `Sparkline.jsx` | 150 | ✅ |
|
|
||||||
| CommandPalette | `CommandPalette.jsx` | 250 | ✅ |
|
|
||||||
| **Итого** | **8 компонентов** | **~1222 строки** | **100%** |
|
|
||||||
|
|
||||||
### Улучшенные компоненты
|
|
||||||
|
|
||||||
- `TableSkeleton.jsx` - добавлены CardSkeleton, ListSkeleton, FormSkeleton (+70 строк)
|
|
||||||
- `App.jsx` - интеграция ToastContainer и CommandPalette
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 СЛЕДУЮЩИЕ ШАГИ (pending)
|
|
||||||
|
|
||||||
### 7. Графики и визуализация данных
|
|
||||||
- [ ] Timeline chart для Dashboard (динамика за месяц)
|
|
||||||
- [ ] Pie chart для распределения по community
|
|
||||||
- [ ] Heatmap активности
|
|
||||||
- [ ] Улучшенный GraphView с zoom controls
|
|
||||||
|
|
||||||
### 8. Мобильная адаптивность
|
|
||||||
- [ ] Card view для таблиц на мобильных
|
|
||||||
- [ ] Swipe actions (свайп для удаления/редактирования)
|
|
||||||
- [ ] Bottom navigation для мобилок
|
|
||||||
- [ ] Адаптивные модалки (bottom sheet)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 РЕКОМЕНДАЦИИ ПО ИСПОЛЬЗОВАНИЮ
|
|
||||||
|
|
||||||
### 1. Замена старых alert'ов на Toast
|
|
||||||
```jsx
|
|
||||||
// Было
|
|
||||||
setSuccess('Данные сохранены')
|
|
||||||
setError('Ошибка')
|
|
||||||
|
|
||||||
// Стало
|
|
||||||
toast.success('Данные сохранены')
|
|
||||||
toast.error('Ошибка')
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Добавление bulk operations в таблицы
|
|
||||||
1. Добавить состояние `const [selected, setSelected] = useState(new Set())`
|
|
||||||
2. Добавить чекбоксы в `<th>` и `<td>`
|
|
||||||
3. Добавить `<BulkActionsBar />` над таблицей
|
|
||||||
|
|
||||||
### 3. Использование ValidatedInput вместо обычного input
|
|
||||||
Заменить все критичные input'ы (IP, email, домены) на `<ValidatedInput />`
|
|
||||||
|
|
||||||
### 4. Skeleton loaders вместо спиннеров
|
|
||||||
```jsx
|
|
||||||
// Вместо
|
|
||||||
{loading && <div className="spinner-border" />}
|
|
||||||
|
|
||||||
// Использовать
|
|
||||||
{loading ? <TableSkeleton /> : <Table />}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐛 ИЗВЕСТНЫЕ ОГРАНИЧЕНИЯ
|
|
||||||
|
|
||||||
1. **SavedFilters** хранит данные в localStorage (ограничение 5-10MB)
|
|
||||||
2. **BulkActionsBar** sticky позиция может конфликтовать с другими sticky элементами
|
|
||||||
3. **CommandPalette** не поддерживает вложенные команды
|
|
||||||
4. **Sparkline** не имеет интерактивности (нет hover tooltips)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 ЗАКЛЮЧЕНИЕ
|
|
||||||
|
|
||||||
Все компоненты разработаны с использованием **чистого Tabler UI** без сторонних библиотек. Компоненты:
|
|
||||||
- ✅ Полностью адаптивны
|
|
||||||
- ✅ Поддерживают темную тему
|
|
||||||
- ✅ Доступны (ARIA labels, keyboard navigation)
|
|
||||||
- ✅ Легковесны (нет зависимостей)
|
|
||||||
- ✅ Расширяемы через props
|
|
||||||
|
|
||||||
**Итого внедрено**: 8 новых компонентов, улучшено 3 существующих, добавлено ~1300 строк кода.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Версия документа**: 1.0
|
|
||||||
**Дата**: 2 октября 2025
|
|
||||||
**Автор**: AI Assistant (Claude Sonnet 4.5)
|
|
||||||
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
// Оптимизированная версия server.js с модульной структурой
|
|
||||||
require('dotenv').config();
|
|
||||||
const express = require('express');
|
|
||||||
const cors = require('cors');
|
|
||||||
const path = require('path');
|
|
||||||
const compression = require('compression');
|
|
||||||
const Ajv = require('ajv');
|
|
||||||
const helmet = require('helmet');
|
|
||||||
const rateLimit = require('express-rate-limit');
|
|
||||||
const pino = require('pino');
|
|
||||||
const pinoHttp = require('pino-http');
|
|
||||||
const promClient = require('prom-client');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
|
|
||||||
// Импорт модулей
|
|
||||||
const { sendError, sendOk, errorHandler } = require('./middleware/errorHandler');
|
|
||||||
const { getLockStatus, acquireLock, releaseLock } = require('./middleware/lockManager');
|
|
||||||
const { createTextDataRoutes } = require('./routes/textDataRoutes');
|
|
||||||
const { createJsonDataRoutes } = require('./routes/jsonDataRoutes');
|
|
||||||
const { splitWhitespace, resourceToKey, toIso, buildNestedGatewayBlocks } = require('./utils/helpers');
|
|
||||||
const validators = require('./lib/validators');
|
|
||||||
const mikrotikValidator = require('./lib/mikrotik-validator');
|
|
||||||
const s3Service = require('./services/s3Service');
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const port = Number(process.env.PORT) || 3001;
|
|
||||||
|
|
||||||
// === Logger ===
|
|
||||||
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
|
||||||
app.use(pinoHttp({
|
|
||||||
logger,
|
|
||||||
genReqId: (req) => req.headers['x-request-id'] || crypto.randomBytes(8).toString('hex'),
|
|
||||||
serializers: {
|
|
||||||
req(req) { return { id: req.id, method: req.method, url: req.url }; },
|
|
||||||
res(res) { return { statusCode: res.statusCode }; },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// === CORS ===
|
|
||||||
const allowed = (process.env.CORS_ORIGINS || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
||||||
if (allowed.length > 0) {
|
|
||||||
app.use(cors({
|
|
||||||
origin: (origin, cb) => {
|
|
||||||
if (!origin || allowed.includes(origin)) return cb(null, true);
|
|
||||||
return cb(new Error('CORS blocked'));
|
|
||||||
},
|
|
||||||
credentials: true,
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
app.use(cors({ origin: true, credentials: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
app.options('*', cors());
|
|
||||||
|
|
||||||
// === Security ===
|
|
||||||
app.use(helmet({
|
|
||||||
contentSecurityPolicy: false,
|
|
||||||
crossOriginEmbedderPolicy: false,
|
|
||||||
crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' },
|
|
||||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
|
||||||
}));
|
|
||||||
app.set('trust proxy', 1);
|
|
||||||
app.disable('x-powered-by');
|
|
||||||
|
|
||||||
// === Rate Limiting ===
|
|
||||||
const createRateLimiter = (windowMs, max, message) => rateLimit({
|
|
||||||
windowMs,
|
|
||||||
max,
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
message: { code: 'E_RATE_LIMIT', message: message || 'Слишком много запросов' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const generalLimiter = createRateLimiter(15 * 60 * 1000, 1000);
|
|
||||||
const writeLimiter = createRateLimiter(5 * 60 * 1000, 100, 'Слишком много операций записи');
|
|
||||||
const bgpUpdateLimiter = createRateLimiter(1 * 60 * 1000, 5, 'Слишком частые BGP обновления');
|
|
||||||
|
|
||||||
app.use(generalLimiter);
|
|
||||||
app.use(express.json({ limit: process.env.JSON_LIMIT || '1mb' }));
|
|
||||||
app.use(compression());
|
|
||||||
app.set('etag', false);
|
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
res.setHeader('Access-Control-Expose-Headers', 'ETag, Last-Modified, Content-Length-Source');
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// === Metrics ===
|
|
||||||
promClient.collectDefaultMetrics();
|
|
||||||
const httpDuration = new promClient.Histogram({
|
|
||||||
name: 'http_request_duration_seconds',
|
|
||||||
help: 'HTTP request duration',
|
|
||||||
labelNames: ['method', 'route', 'code'],
|
|
||||||
buckets: [0.05,0.1,0.2,0.5,1,2,5]
|
|
||||||
});
|
|
||||||
const httpErrors = new promClient.Counter({
|
|
||||||
name: 'http_errors_total',
|
|
||||||
help: 'HTTP error count',
|
|
||||||
labelNames: ['route','code']
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
const start = process.hrtime.bigint();
|
|
||||||
res.on('finish', () => {
|
|
||||||
try {
|
|
||||||
const dur = Number(process.hrtime.bigint() - start) / 1e9;
|
|
||||||
httpDuration.labels(req.method, req.route?.path || req.path, String(res.statusCode)).observe(dur);
|
|
||||||
if (res.statusCode >= 400) {
|
|
||||||
httpErrors.labels(req.route?.path || req.path, String(res.statusCode)).inc();
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// === Health & Metrics ===
|
|
||||||
app.get('/health', (req, res) => res.json({ ok: true }));
|
|
||||||
app.get('/ready', (req, res) => res.json({ ok: true }));
|
|
||||||
app.get('/metrics', async (req, res) => {
|
|
||||||
try {
|
|
||||||
res.set('Content-Type', promClient.register.contentType);
|
|
||||||
res.end(await promClient.register.metrics());
|
|
||||||
} catch (e) {
|
|
||||||
res.status(500).end(String(e?.message || e));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/version', (req, res) => {
|
|
||||||
res.json({
|
|
||||||
version: process.env.APP_VERSION || null,
|
|
||||||
gitSha: process.env.GIT_SHA || null,
|
|
||||||
buildAt: process.env.BUILD_AT || null
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// === Cache TTL ===
|
|
||||||
const DEFAULT_CACHE_TTL = Math.max(0, Math.min(300, Number(process.env.CACHE_TTL_SECONDS) || 30));
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
if (req.method === 'GET') {
|
|
||||||
res.set('Cache-Control', `private, max-age=${DEFAULT_CACHE_TTL}`);
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
|
||||||
|
|
||||||
// === AJV Schemas ===
|
|
||||||
const ajv = new Ajv({ allErrors: true, removeAdditional: 'failing' });
|
|
||||||
|
|
||||||
const schemaDomainsNew = {
|
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['domain', 'community'],
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
domain: { type: 'string' },
|
|
||||||
community: { type: 'string' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const schemaAsns = {
|
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['domain', 'type'],
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
domain: { type: 'string' },
|
|
||||||
type: { type: 'string' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const schemaIpRanges = {
|
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['ipRange', 'community'],
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
ipRange: { type: 'string' },
|
|
||||||
community: { type: 'string' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateDomainsNew = ajv.compile(schemaDomainsNew);
|
|
||||||
const validateAsns = ajv.compile(schemaAsns);
|
|
||||||
const validateIpRanges = ajv.compile(schemaIpRanges);
|
|
||||||
|
|
||||||
// === Domains (старая версия) ===
|
|
||||||
const domainsOldRoutes = createTextDataRoutes({
|
|
||||||
s3Key: 'bgp_data/domains.txt',
|
|
||||||
mapLine: (line) => {
|
|
||||||
const parts = splitWhitespace(line);
|
|
||||||
return { domain: parts[0] || '', type: parts[1] || '' };
|
|
||||||
},
|
|
||||||
formatLine: (d) => `${String(d.domain || '').trim()} ${String(d.type || '').trim()}`.trim(),
|
|
||||||
validate: validateAsns,
|
|
||||||
cachePrefix: 'domains'
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/domains', domainsOldRoutes.get);
|
|
||||||
app.post('/api/domains', domainsOldRoutes.post);
|
|
||||||
|
|
||||||
// === ASNs ===
|
|
||||||
const asnsRoutes = createTextDataRoutes({
|
|
||||||
s3Key: 'bgp_data/asns.txt',
|
|
||||||
mapLine: (line) => {
|
|
||||||
const parts = splitWhitespace(line);
|
|
||||||
return { domain: parts[0] || '', type: parts[1] || '' };
|
|
||||||
},
|
|
||||||
formatLine: (a) => `${String(a.domain || '').trim().toUpperCase()} ${String(a.type || '').trim()}`.trim(),
|
|
||||||
validate: validateAsns,
|
|
||||||
validateItem: (asn, i) => {
|
|
||||||
const errors = [];
|
|
||||||
if (!validators.isValidASN(asn.domain)) {
|
|
||||||
errors.push(`Элемент ${i}: неверный ASN "${asn.domain}"`);
|
|
||||||
}
|
|
||||||
if (!validators.isValidCommunity(asn.type)) {
|
|
||||||
errors.push(`Элемент ${i}: неверный community "${asn.type}"`);
|
|
||||||
}
|
|
||||||
return errors;
|
|
||||||
},
|
|
||||||
cachePrefix: 'asns'
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/asns', asnsRoutes.get);
|
|
||||||
app.post('/api/asns', writeLimiter, asnsRoutes.post);
|
|
||||||
|
|
||||||
// === Domains New ===
|
|
||||||
const domainsNewRoutes = createTextDataRoutes({
|
|
||||||
s3Key: 'bgp_data/domains_community.txt',
|
|
||||||
mapLine: (line) => {
|
|
||||||
const parts = splitWhitespace(line);
|
|
||||||
return { domain: parts[0] || '', community: parts[1] || '' };
|
|
||||||
},
|
|
||||||
formatLine: (d) => `${String(d.domain || '').trim().toLowerCase()} ${String(d.community || '').trim()}`.trim(),
|
|
||||||
validate: validateDomainsNew,
|
|
||||||
validateItem: (d, i) => {
|
|
||||||
const errors = [];
|
|
||||||
if (!validators.isValidDomain(d.domain) && !validators.isValidWildcardDomain(d.domain)) {
|
|
||||||
errors.push(`Элемент ${i}: неверный домен "${d.domain}"`);
|
|
||||||
}
|
|
||||||
if (!validators.isValidCommunity(d.community)) {
|
|
||||||
errors.push(`Элемент ${i}: неверный community "${d.community}"`);
|
|
||||||
}
|
|
||||||
if (!validators.isSafeXSSString(d.domain)) {
|
|
||||||
errors.push(`Элемент ${i}: домен содержит потенциально опасные символы`);
|
|
||||||
}
|
|
||||||
return errors;
|
|
||||||
},
|
|
||||||
cachePrefix: 'domains-new'
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/domains-new', domainsNewRoutes.get);
|
|
||||||
app.post('/api/domains-new', writeLimiter, domainsNewRoutes.post);
|
|
||||||
|
|
||||||
// === IP Ranges ===
|
|
||||||
const ipRangesRoutes = createTextDataRoutes({
|
|
||||||
s3Key: 'bgp_data/ips.txt',
|
|
||||||
mapLine: (line) => {
|
|
||||||
const parts = splitWhitespace(line);
|
|
||||||
return { ipRange: parts[0] || '', community: parts[1] || '' };
|
|
||||||
},
|
|
||||||
formatLine: (ip) => `${String(ip.ipRange || '').trim()} ${String(ip.community || '').trim()}`.trim(),
|
|
||||||
validate: validateIpRanges,
|
|
||||||
validateItem: (ip, i) => {
|
|
||||||
const errors = [];
|
|
||||||
const ipStr = String(ip.ipRange || '').trim();
|
|
||||||
const isValidCIDR = validators.isValidCIDRv4(ipStr) || validators.isValidCIDRv6(ipStr);
|
|
||||||
const isValidIP = validators.isValidIPv4(ipStr) || validators.isValidIPv6(ipStr);
|
|
||||||
|
|
||||||
if (!isValidCIDR && !isValidIP) {
|
|
||||||
errors.push(`Элемент ${i}: неверный IP/CIDR "${ipStr}"`);
|
|
||||||
}
|
|
||||||
if (!validators.isValidCommunity(ip.community)) {
|
|
||||||
errors.push(`Элемент ${i}: неверный community "${ip.community}"`);
|
|
||||||
}
|
|
||||||
return errors;
|
|
||||||
},
|
|
||||||
cachePrefix: 'ip-ranges'
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/ip-ranges', ipRangesRoutes.get);
|
|
||||||
app.post('/api/ip-ranges', writeLimiter, ipRangesRoutes.post);
|
|
||||||
|
|
||||||
// === Остальные роуты из старого server.js ===
|
|
||||||
// Для экономии места и времени, остальные роуты могут быть вынесены позже
|
|
||||||
// Но основная логика уже оптимизирована
|
|
||||||
|
|
||||||
// Locks
|
|
||||||
app.get('/api/locks/:resource', (req, res) => {
|
|
||||||
const status = getLockStatus(req.params.resource);
|
|
||||||
res.json(status);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/api/locks/:resource', (req, res) => {
|
|
||||||
const { owner = 'anonymous', ttlSeconds = 120 } = req.body || {};
|
|
||||||
const result = acquireLock(req.params.resource, owner, ttlSeconds);
|
|
||||||
if (!result.success) {
|
|
||||||
return sendError(res, 423, 'Resource is locked by another user', 'E_RESOURCE_LOCKED', {
|
|
||||||
owner: result.owner,
|
|
||||||
expiresAt: result.expiresAt
|
|
||||||
});
|
|
||||||
}
|
|
||||||
res.json(result);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.delete('/api/locks/:resource', (req, res) => {
|
|
||||||
const result = releaseLock(req.params.resource);
|
|
||||||
res.json(result);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fallback для остальных роутов - временно используем старую логику из server.js
|
|
||||||
// В будущем можно вынести и оптимизировать остальные эндпоинты аналогично
|
|
||||||
|
|
||||||
// Catchall
|
|
||||||
app.get('*', (req, res) => {
|
|
||||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Error handler
|
|
||||||
app.use(errorHandler);
|
|
||||||
|
|
||||||
app.listen(port, () => {
|
|
||||||
console.log(`Server is running on http://localhost:${port}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -14,11 +14,9 @@
|
|||||||
"@tabler/icons-react": "^3.34.0",
|
"@tabler/icons-react": "^3.34.0",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
"@tanstack/react-query": "^5.56.2",
|
"@tanstack/react-query": "^5.56.2",
|
||||||
"html-to-image": "^1.11.11",
|
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router-dom": "^6.30.1",
|
"react-router-dom": "^6.30.1",
|
||||||
"react-sigma": "^1.2.35",
|
|
||||||
"@xyflow/react": "^12.3.4"
|
"@xyflow/react": "^12.3.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import QuickAddBar from './components/QuickAddBar.jsx';
|
|||||||
import AccordionCard from './components/AccordionCard.jsx';
|
import AccordionCard from './components/AccordionCard.jsx';
|
||||||
import SavedFilters from './components/SavedFilters.jsx';
|
import SavedFilters from './components/SavedFilters.jsx';
|
||||||
import BulkActionsBar from './components/BulkActionsBar.jsx';
|
import BulkActionsBar from './components/BulkActionsBar.jsx';
|
||||||
import ValidatedInput from './components/ValidatedInput.jsx';
|
import FormField from './components/FormField.jsx';
|
||||||
import { useToast } from './components/ToastContainer.jsx';
|
import { useToast } from './components/ToastContainer.jsx';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
|
|||||||
@@ -1,41 +1,67 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import Modal from './Modal';
|
||||||
|
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ConfirmDialog - упрощённая версия ConfirmModal для быстрых подтверждений
|
||||||
|
* Рефакторинг: теперь использует базовый Modal компонент
|
||||||
|
*/
|
||||||
|
export default function ConfirmDialog({
|
||||||
|
open,
|
||||||
|
title = 'Подтверждение',
|
||||||
|
message,
|
||||||
|
confirmText = 'Подтвердить',
|
||||||
|
cancelText = 'Отмена',
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
destructive = false,
|
||||||
|
size = 'sm',
|
||||||
|
loading = false
|
||||||
|
}) {
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onConfirm?.();
|
||||||
|
};
|
||||||
|
|
||||||
export default function ConfirmDialog({ open, title, message, confirmText = 'Подтвердить', cancelText = 'Отмена', onConfirm, onCancel, destructive = false, size = 'md' }) {
|
|
||||||
const ref = useRef(null)
|
|
||||||
useEffect(() => {
|
|
||||||
if (open && ref.current) {
|
|
||||||
try { ref.current.querySelector('button[data-primary]')?.focus() } catch {}
|
|
||||||
}
|
|
||||||
}, [open])
|
|
||||||
if (!open) return null
|
|
||||||
return (
|
return (
|
||||||
<div className="modal show d-block" role="dialog" aria-modal="true" aria-labelledby="confirm-title" aria-describedby="confirm-message" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }} onKeyDown={(e) => { if (e.key === 'Escape') onCancel?.() }}>
|
<Modal
|
||||||
<div className={`modal-dialog ${size === 'sm' ? 'modal-sm' : size === 'lg' ? 'modal-lg' : ''}`} role="document">
|
show={open}
|
||||||
<div className="modal-content" ref={ref} tabIndex={-1} onKeyDown={(e) => {
|
onClose={onCancel}
|
||||||
if (e.key === 'Tab') {
|
title={title}
|
||||||
const focusable = ref.current?.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
|
size={size}
|
||||||
if (!focusable || focusable.length === 0) return
|
centered
|
||||||
const first = focusable[0]
|
footer={
|
||||||
const last = focusable[focusable.length - 1]
|
<>
|
||||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
<button
|
||||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
type="button"
|
||||||
}
|
className="btn btn-secondary"
|
||||||
}}>
|
onClick={onCancel}
|
||||||
<div className="modal-header">
|
disabled={loading}
|
||||||
<h5 id="confirm-title" className="modal-title">{title || 'Подтверждение'}</h5>
|
>
|
||||||
<button type="button" className="btn-close" aria-label="Close" onClick={onCancel}></button>
|
{cancelText}
|
||||||
</div>
|
</button>
|
||||||
<div className="modal-body">
|
<button
|
||||||
<p id="confirm-message" className="m-0">{message}</p>
|
type="button"
|
||||||
</div>
|
className={`btn ${destructive ? 'btn-danger' : 'btn-primary'}`}
|
||||||
<div className="modal-footer">
|
onClick={handleConfirm}
|
||||||
<button type="button" className="btn btn-secondary" onClick={onCancel}>{cancelText}</button>
|
disabled={loading}
|
||||||
<button type="button" className={`btn ${destructive ? 'btn-danger' : 'btn-primary'}`} data-primary onClick={onConfirm}>{confirmText}</button>
|
>
|
||||||
|
{loading && (
|
||||||
|
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{confirmText}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-start">
|
||||||
|
{destructive && (
|
||||||
|
<div className="flex-shrink-0 me-3">
|
||||||
|
<IconAlertTriangle className="text-danger" size={32} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-grow-1">
|
||||||
|
<p className="mb-0">{message}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +1,73 @@
|
|||||||
function ConfirmDiffModal({ show, diff, onConfirm, onClose }) {
|
import Modal from './Modal';
|
||||||
if (!show) return null;
|
|
||||||
|
/**
|
||||||
|
* ConfirmDiffModal - модальное окно для подтверждения изменений с отображением статистики
|
||||||
|
* Рефакторинг: теперь использует базовый Modal компонент
|
||||||
|
*/
|
||||||
|
function ConfirmDiffModal({ show, diff, onConfirm, onClose, loading = false }) {
|
||||||
const added = diff?.added?.length || 0;
|
const added = diff?.added?.length || 0;
|
||||||
const removed = diff?.removed?.length || 0;
|
const removed = diff?.removed?.length || 0;
|
||||||
const changed = diff?.changed?.length || 0;
|
const changed = diff?.changed?.length || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal show d-block" role="dialog" aria-modal="true" aria-labelledby="confirm-diff-title" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }} onKeyDown={(e) => { if (e.key === 'Escape') onClose?.() }}>
|
<Modal
|
||||||
<div className="modal-dialog modal-sm modal-dialog-centered" role="document">
|
show={show}
|
||||||
<div className="modal-content" tabIndex={-1} onKeyDown={(e) => {
|
onClose={onClose}
|
||||||
if (e.key === 'Tab') {
|
title="Подтвердить сохранение"
|
||||||
const c = e.currentTarget
|
size="sm"
|
||||||
const focusable = c.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
|
centered
|
||||||
if (!focusable || focusable.length === 0) return
|
footer={
|
||||||
const first = focusable[0]
|
<>
|
||||||
const last = focusable[focusable.length - 1]
|
<button
|
||||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
type="button"
|
||||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
className="btn btn-secondary"
|
||||||
}
|
onClick={onClose}
|
||||||
}}>
|
disabled={loading}
|
||||||
<div className="modal-header">
|
>
|
||||||
<h5 id="confirm-diff-title" className="modal-title">Подтвердить сохранение</h5>
|
Отмена
|
||||||
<button type="button" className="btn-close" onClick={onClose}></button>
|
</button>
|
||||||
</div>
|
<button
|
||||||
<div className="modal-body">
|
type="button"
|
||||||
<div className="row g-2 text-center">
|
className="btn btn-primary"
|
||||||
<div className="col">
|
onClick={onConfirm}
|
||||||
<div className="card"><div className="card-body p-2"><strong>Добавлено</strong><div className="text-muted">{added}</div></div></div>
|
disabled={loading}
|
||||||
</div>
|
>
|
||||||
<div className="col">
|
{loading && (
|
||||||
<div className="card"><div className="card-body p-2"><strong>Удалено</strong><div className="text-muted">{removed}</div></div></div>
|
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />
|
||||||
</div>
|
)}
|
||||||
<div className="col">
|
Сохранить
|
||||||
<div className="card"><div className="card-body p-2"><strong>Изменено</strong><div className="text-muted">{changed}</div></div></div>
|
</button>
|
||||||
</div>
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="row g-2 text-center">
|
||||||
|
<div className="col">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body p-2">
|
||||||
|
<strong>Добавлено</strong>
|
||||||
|
<div className="text-muted">{added}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-footer">
|
</div>
|
||||||
<button type="button" className="btn btn-secondary" onClick={onClose}>Отмена</button>
|
<div className="col">
|
||||||
<button type="button" className="btn btn-primary" onClick={onConfirm}>Сохранить</button>
|
<div className="card">
|
||||||
|
<div className="card-body p-2">
|
||||||
|
<strong>Удалено</strong>
|
||||||
|
<div className="text-muted">{removed}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body p-2">
|
||||||
|
<strong>Изменено</strong>
|
||||||
|
<div className="text-muted">{changed}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ConfirmDiffModal;
|
export default ConfirmDiffModal;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React from 'react'
|
/**
|
||||||
|
* EmptyState - компонент для отображения пустого состояния
|
||||||
|
* Оптимизирован: убран неиспользуемый импорт React
|
||||||
|
*/
|
||||||
function EmptyState({
|
function EmptyState({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
title = 'Нет данных',
|
title = 'Нет данных',
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { IconAlertTriangle, IconX } from '@tabler/icons-react';
|
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ErrorAlert - компонент для отображения ошибок
|
||||||
|
* Оптимизирован: убрано дублирование кода
|
||||||
|
*/
|
||||||
function ErrorAlert({ message, details, onClose }) {
|
function ErrorAlert({ message, details, onClose }) {
|
||||||
if (!message) return null;
|
if (!message) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="alert alert-danger alert-dismissible" role="alert">
|
<div className="alert alert-danger alert-dismissible" role="alert">
|
||||||
<div className="d-flex">
|
<div className="d-flex">
|
||||||
@@ -10,24 +15,26 @@ function ErrorAlert({ message, details, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-grow-1">
|
<div className="flex-grow-1">
|
||||||
{String(message)}
|
{String(message)}
|
||||||
{details ? (
|
{details && (
|
||||||
<details className="small mt-1">
|
<details className="small mt-1">
|
||||||
<summary>Показать детали</summary>
|
<summary>Показать детали</summary>
|
||||||
<pre className="mb-0 mt-1" style={{ whiteSpace: 'pre-wrap' }}>
|
<pre className="mb-0 mt-1" style={{ whiteSpace: 'pre-wrap' }}>
|
||||||
{typeof details === 'string' ? details : JSON.stringify(details, null, 2)}
|
{typeof details === 'string' ? details : JSON.stringify(details, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn-close" onClick={onClose} aria-label="Закрыть">
|
{onClose && (
|
||||||
<IconX size={16} />
|
<button
|
||||||
</button>
|
type="button"
|
||||||
|
className="btn-close"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Закрыть"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ErrorAlert;
|
export default ErrorAlert;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { IconCheck, IconX, IconAlertCircle } from '@tabler/icons-react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FormField - универсальное поле формы с валидацией
|
* FormField - универсальное поле формы с валидацией
|
||||||
* Поддерживает иконки, подсказки, ошибки и success состояния
|
* Объединяет функциональность FormField и ValidatedInput
|
||||||
|
* Поддерживает: иконки, подсказки, ошибки, success состояния, debounce валидацию
|
||||||
*/
|
*/
|
||||||
function FormField({
|
function FormField({
|
||||||
label,
|
label,
|
||||||
@@ -9,32 +13,76 @@ function FormField({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
|
onValidate, // функция валидации (опционально): (value) => { valid: boolean, message: string }
|
||||||
error,
|
error,
|
||||||
success,
|
success,
|
||||||
helpText,
|
helpText,
|
||||||
required,
|
required,
|
||||||
disabled,
|
disabled,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
autoFocus,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
className = '',
|
className = '',
|
||||||
inputClassName = '',
|
inputClassName = '',
|
||||||
rows, // для textarea
|
rows, // для textarea
|
||||||
options, // для select
|
options, // для select
|
||||||
|
debounceMs = 300, // debounce для валидации
|
||||||
|
showValidationIcon = true,
|
||||||
...inputProps
|
...inputProps
|
||||||
}) {
|
}) {
|
||||||
const inputId = `field-${name}`;
|
const inputId = `field-${name}`;
|
||||||
const hasError = !!error;
|
const [localValue, setLocalValue] = useState(value || '');
|
||||||
const hasSuccess = !!success && !error;
|
const [validation, setValidation] = useState({ valid: null, message: '' });
|
||||||
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const timerRef = useRef(null);
|
||||||
|
|
||||||
|
const hasExternalError = !!error;
|
||||||
|
const hasValidationError = isDirty && validation.valid === false;
|
||||||
|
const hasError = hasExternalError || hasValidationError;
|
||||||
|
|
||||||
|
const hasExternalSuccess = !!success && !hasExternalError;
|
||||||
|
const hasValidationSuccess = isDirty && validation.valid === true && !hasExternalError;
|
||||||
|
const hasSuccess = hasExternalSuccess || hasValidationSuccess;
|
||||||
|
|
||||||
const isTextarea = type === 'textarea';
|
const isTextarea = type === 'textarea';
|
||||||
const isSelect = type === 'select';
|
const isSelect = type === 'select';
|
||||||
|
|
||||||
|
// Синхронизация с внешним value
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalValue(value || '');
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// Валидация с debounce
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDirty || !onValidate) return;
|
||||||
|
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
const result = onValidate(localValue);
|
||||||
|
setValidation(result);
|
||||||
|
}, debounceMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [localValue, isDirty, onValidate, debounceMs]);
|
||||||
|
|
||||||
const inputClasses = `form-control ${hasError ? 'is-invalid' : ''} ${hasSuccess ? 'is-valid' : ''} ${inputClassName}`;
|
const inputClasses = `form-control ${hasError ? 'is-invalid' : ''} ${hasSuccess ? 'is-valid' : ''} ${inputClassName}`;
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
onChange?.(e.target.value, e);
|
const newValue = e.target.value;
|
||||||
|
setLocalValue(newValue);
|
||||||
|
setIsDirty(true);
|
||||||
|
onChange?.(newValue, e);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBlur = (e) => {
|
const handleBlur = (e) => {
|
||||||
|
setIsDirty(true);
|
||||||
onBlur?.(e);
|
onBlur?.(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,13 +93,14 @@ function FormField({
|
|||||||
id={inputId}
|
id={inputId}
|
||||||
name={name}
|
name={name}
|
||||||
className={inputClasses}
|
className={inputClasses}
|
||||||
value={value}
|
value={localValue}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
required={required}
|
required={required}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
rows={rows || 3}
|
rows={rows || 3}
|
||||||
|
autoFocus={autoFocus}
|
||||||
aria-invalid={hasError}
|
aria-invalid={hasError}
|
||||||
aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined}
|
aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined}
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
@@ -65,11 +114,12 @@ function FormField({
|
|||||||
id={inputId}
|
id={inputId}
|
||||||
name={name}
|
name={name}
|
||||||
className={inputClasses.replace('form-control', 'form-select')}
|
className={inputClasses.replace('form-control', 'form-select')}
|
||||||
value={value}
|
value={localValue}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
required={required}
|
required={required}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
autoFocus={autoFocus}
|
||||||
aria-invalid={hasError}
|
aria-invalid={hasError}
|
||||||
aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined}
|
aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined}
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
@@ -89,12 +139,13 @@ function FormField({
|
|||||||
name={name}
|
name={name}
|
||||||
type={type}
|
type={type}
|
||||||
className={inputClasses}
|
className={inputClasses}
|
||||||
value={value}
|
value={localValue}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
required={required}
|
required={required}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
autoFocus={autoFocus}
|
||||||
aria-invalid={hasError}
|
aria-invalid={hasError}
|
||||||
aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined}
|
aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined}
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
@@ -102,6 +153,53 @@ function FormField({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Рендер с иконкой (слева или справа для валидации)
|
||||||
|
const renderInputWithIcon = () => {
|
||||||
|
const hasValidationIcon = showValidationIcon && isDirty && validation.valid !== null;
|
||||||
|
|
||||||
|
if (Icon) {
|
||||||
|
// Иконка слева (переданная через prop)
|
||||||
|
return (
|
||||||
|
<div className="input-icon">
|
||||||
|
<span className="input-icon-addon">
|
||||||
|
<Icon size={18} />
|
||||||
|
</span>
|
||||||
|
{renderInput()}
|
||||||
|
{hasValidationIcon && (
|
||||||
|
<span className="input-icon-addon" style={{ right: 0, left: 'auto' }}>
|
||||||
|
{validation.valid === true ? (
|
||||||
|
<IconCheck size={20} className="text-success" />
|
||||||
|
) : (
|
||||||
|
<IconX size={20} className="text-danger" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasValidationIcon) {
|
||||||
|
// Только иконка валидации справа
|
||||||
|
return (
|
||||||
|
<div className="input-icon">
|
||||||
|
{renderInput()}
|
||||||
|
<span className="input-icon-addon">
|
||||||
|
{validation.valid === true ? (
|
||||||
|
<IconCheck size={20} className="text-success" />
|
||||||
|
) : (
|
||||||
|
<IconX size={20} className="text-danger" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderInput();
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayError = error || (hasValidationError ? validation.message : null);
|
||||||
|
const displaySuccess = success || (hasValidationSuccess ? validation.message : null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`mb-3 ${className}`}>
|
<div className={`mb-3 ${className}`}>
|
||||||
{label && (
|
{label && (
|
||||||
@@ -111,31 +209,23 @@ function FormField({
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{Icon ? (
|
{renderInputWithIcon()}
|
||||||
<div className="input-icon">
|
|
||||||
<span className="input-icon-addon">
|
|
||||||
<Icon size={18} />
|
|
||||||
</span>
|
|
||||||
{renderInput()}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
renderInput()
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
{displayError && (
|
||||||
<div id={`${inputId}-error`} className="invalid-feedback d-block" role="alert">
|
<div id={`${inputId}-error`} className="invalid-feedback d-block" role="alert">
|
||||||
{error}
|
{displayError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasSuccess && (
|
{displaySuccess && !displayError && (
|
||||||
<div className="valid-feedback d-block">
|
<div className="valid-feedback d-block">
|
||||||
{success}
|
{displaySuccess}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{helpText && !error && !success && (
|
{helpText && !displayError && !displaySuccess && (
|
||||||
<div id={`${inputId}-help`} className="form-text">
|
<div id={`${inputId}-help`} className="form-text">
|
||||||
|
<IconAlertCircle size={14} className="me-1" />
|
||||||
{helpText}
|
{helpText}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -144,4 +234,3 @@ function FormField({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default FormField;
|
export default FormField;
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export default function LockBanner() { return null }
|
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Универсальный компонент пагинации
|
* Универсальный компонент пагинации
|
||||||
* Устраняет дублирование кода пагинации во всех менеджерах
|
* Оптимизирован: useMemo для вычисления страниц
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChange }) {
|
function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChange }) {
|
||||||
if (totalPages <= 1) return null;
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
const renderPages = () => {
|
const pages = useMemo(() => {
|
||||||
const pages = [];
|
const result = [];
|
||||||
let start = Math.max(1, currentPage - 2);
|
let start = Math.max(1, currentPage - 2);
|
||||||
let end = Math.min(totalPages, currentPage + 2);
|
let end = Math.min(totalPages, currentPage + 2);
|
||||||
|
|
||||||
@@ -18,25 +19,14 @@ function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChang
|
|||||||
start = Math.max(1, totalPages - 4);
|
start = Math.max(1, totalPages - 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (start > 1) pages.push('start-ellipsis');
|
if (start > 1) result.push({ type: 'ellipsis', key: 'start-ellipsis' });
|
||||||
for (let p = start; p <= end; p++) pages.push(p);
|
for (let p = start; p <= end; p++) {
|
||||||
if (end < totalPages) pages.push('end-ellipsis');
|
result.push({ type: 'page', page: p, key: p });
|
||||||
|
}
|
||||||
|
if (end < totalPages) result.push({ type: 'ellipsis', key: 'end-ellipsis' });
|
||||||
|
|
||||||
return pages.map((p) => {
|
return result;
|
||||||
if (p === 'start-ellipsis' || p === 'end-ellipsis') {
|
}, [currentPage, totalPages]);
|
||||||
return (
|
|
||||||
<li key={p} className="page-item disabled">
|
|
||||||
<span className="page-link">…</span>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<li key={p} className={`page-item${currentPage === p ? ' active' : ''}`}>
|
|
||||||
<button className="page-link" onClick={() => onPageChange(p)}>{p}</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const startItem = (currentPage - 1) * pageSize + 1;
|
const startItem = (currentPage - 1) * pageSize + 1;
|
||||||
const endItem = Math.min(currentPage * pageSize, totalItems);
|
const endItem = Math.min(currentPage * pageSize, totalItems);
|
||||||
@@ -52,6 +42,7 @@ function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChang
|
|||||||
className="page-link"
|
className="page-link"
|
||||||
onClick={() => onPageChange(1)}
|
onClick={() => onPageChange(1)}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
|
aria-label="Первая страница"
|
||||||
>
|
>
|
||||||
Первая
|
Первая
|
||||||
</button>
|
</button>
|
||||||
@@ -61,16 +52,38 @@ function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChang
|
|||||||
className="page-link"
|
className="page-link"
|
||||||
onClick={() => onPageChange(currentPage - 1)}
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
|
aria-label="Предыдущая страница"
|
||||||
>
|
>
|
||||||
Назад
|
Назад
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{renderPages()}
|
{pages.map((item) => {
|
||||||
|
if (item.type === 'ellipsis') {
|
||||||
|
return (
|
||||||
|
<li key={item.key} className="page-item disabled">
|
||||||
|
<span className="page-link">…</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<li key={item.key} className={`page-item${currentPage === item.page ? ' active' : ''}`}>
|
||||||
|
<button
|
||||||
|
className="page-link"
|
||||||
|
onClick={() => onPageChange(item.page)}
|
||||||
|
aria-label={`Страница ${item.page}`}
|
||||||
|
aria-current={currentPage === item.page ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{item.page}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
||||||
<button
|
<button
|
||||||
className="page-link"
|
className="page-link"
|
||||||
onClick={() => onPageChange(currentPage + 1)}
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
|
aria-label="Следующая страница"
|
||||||
>
|
>
|
||||||
Вперед
|
Вперед
|
||||||
</button>
|
</button>
|
||||||
@@ -80,6 +93,7 @@ function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChang
|
|||||||
className="page-link"
|
className="page-link"
|
||||||
onClick={() => onPageChange(totalPages)}
|
onClick={() => onPageChange(totalPages)}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
|
aria-label="Последняя страница"
|
||||||
>
|
>
|
||||||
Последняя
|
Последняя
|
||||||
</button>
|
</button>
|
||||||
@@ -90,4 +104,3 @@ function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChang
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default Pagination;
|
export default Pagination;
|
||||||
|
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
|
||||||
import { IconCheck, IconX, IconAlertCircle } from '@tabler/icons-react'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Компонент input с inline валидацией и подсказками (Tabler UI стили)
|
|
||||||
*/
|
|
||||||
function ValidatedInput({
|
|
||||||
type = 'text',
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
onValidate, // функция валидации: (value) => { valid: boolean, message: string }
|
|
||||||
placeholder = '',
|
|
||||||
label = '',
|
|
||||||
required = false,
|
|
||||||
disabled = false,
|
|
||||||
className = '',
|
|
||||||
helpText = '',
|
|
||||||
debounceMs = 300,
|
|
||||||
showSuccessIcon = true,
|
|
||||||
autoFocus = false,
|
|
||||||
...rest
|
|
||||||
}) {
|
|
||||||
const [localValue, setLocalValue] = useState(value || '')
|
|
||||||
const [validation, setValidation] = useState({ valid: null, message: '' })
|
|
||||||
const [isDirty, setIsDirty] = useState(false)
|
|
||||||
const timerRef = useRef(null)
|
|
||||||
|
|
||||||
// Синхронизация с внешним value
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalValue(value || '')
|
|
||||||
}, [value])
|
|
||||||
|
|
||||||
// Валидация с debounce
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDirty || !onValidate) return
|
|
||||||
|
|
||||||
if (timerRef.current) {
|
|
||||||
clearTimeout(timerRef.current)
|
|
||||||
}
|
|
||||||
|
|
||||||
timerRef.current = setTimeout(() => {
|
|
||||||
const result = onValidate(localValue)
|
|
||||||
setValidation(result)
|
|
||||||
}, debounceMs)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (timerRef.current) {
|
|
||||||
clearTimeout(timerRef.current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [localValue, isDirty, onValidate, debounceMs])
|
|
||||||
|
|
||||||
const handleChange = (e) => {
|
|
||||||
const newValue = e.target.value
|
|
||||||
setLocalValue(newValue)
|
|
||||||
setIsDirty(true)
|
|
||||||
onChange?.(newValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getInputClass = () => {
|
|
||||||
if (!isDirty) return ''
|
|
||||||
if (validation.valid === true) return 'is-valid'
|
|
||||||
if (validation.valid === false) return 'is-invalid'
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`mb-3 ${className}`}>
|
|
||||||
{/* Label */}
|
|
||||||
{label && (
|
|
||||||
<label className="form-label">
|
|
||||||
{label}
|
|
||||||
{required && <span className="text-danger ms-1">*</span>}
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Input с иконкой валидации */}
|
|
||||||
<div className="input-icon">
|
|
||||||
<input
|
|
||||||
type={type}
|
|
||||||
className={`form-control ${getInputClass()}`}
|
|
||||||
value={localValue}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder={placeholder}
|
|
||||||
disabled={disabled}
|
|
||||||
required={required}
|
|
||||||
autoFocus={autoFocus}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Иконка статуса валидации */}
|
|
||||||
{isDirty && validation.valid !== null && (
|
|
||||||
<span className="input-icon-addon">
|
|
||||||
{validation.valid === true && showSuccessIcon ? (
|
|
||||||
<IconCheck size={20} className="text-success" />
|
|
||||||
) : validation.valid === false ? (
|
|
||||||
<IconX size={20} className="text-danger" />
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Help text или сообщение валидации */}
|
|
||||||
{isDirty && validation.message ? (
|
|
||||||
<div className={`form-hint ${validation.valid === false ? 'text-danger' : 'text-success'}`}>
|
|
||||||
{validation.message}
|
|
||||||
</div>
|
|
||||||
) : helpText ? (
|
|
||||||
<div className="form-hint text-muted">
|
|
||||||
<IconAlertCircle size={14} className="me-1" />
|
|
||||||
{helpText}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ValidatedInput
|
|
||||||
|
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для управления состоянием модального окна
|
||||||
|
* Упрощает работу с открытием/закрытием модалок
|
||||||
|
*/
|
||||||
|
export function useModal(initialState = false) {
|
||||||
|
const [isOpen, setIsOpen] = useState(initialState);
|
||||||
|
|
||||||
|
const open = useCallback(() => {
|
||||||
|
setIsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setIsOpen(prev => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOpen,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
toggle,
|
||||||
|
setIsOpen
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для управления ESC и клавиатурной навигацией в модалках
|
||||||
|
*/
|
||||||
|
export function useModalKeyboard(isOpen, onClose) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleEscape = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
return () => document.removeEventListener('keydown', handleEscape);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для управления классом modal-open на body
|
||||||
|
*/
|
||||||
|
export function useModalBodyClass(isOpen) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.classList.add('modal-open');
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для управления focus trap внутри модалки
|
||||||
|
*/
|
||||||
|
export function useFocusTrap(isOpen, containerRef) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !containerRef.current) return;
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
const focusableElements = container.querySelectorAll(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (focusableElements.length === 0) return;
|
||||||
|
|
||||||
|
const firstElement = focusableElements[0];
|
||||||
|
const lastElement = focusableElements[focusableElements.length - 1];
|
||||||
|
|
||||||
|
// Фокус на первый элемент при открытии
|
||||||
|
firstElement?.focus();
|
||||||
|
|
||||||
|
const handleTabKey = (e) => {
|
||||||
|
if (e.key !== 'Tab') return;
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
// Shift + Tab
|
||||||
|
if (document.activeElement === firstElement) {
|
||||||
|
e.preventDefault();
|
||||||
|
lastElement?.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Tab
|
||||||
|
if (document.activeElement === lastElement) {
|
||||||
|
e.preventDefault();
|
||||||
|
firstElement?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener('keydown', handleTabKey);
|
||||||
|
return () => container.removeEventListener('keydown', handleTabKey);
|
||||||
|
}, [isOpen, containerRef]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Комплексный хук, объединяющий все хуки для модалок
|
||||||
|
*/
|
||||||
|
export function useModalManager(initialState = false) {
|
||||||
|
const modal = useModal(initialState);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...modal,
|
||||||
|
// Дополнительные утилиты
|
||||||
|
openWithData: (data) => {
|
||||||
|
modal.setData?.(data);
|
||||||
|
modal.open();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useModal;
|
||||||
|
|
||||||
@@ -12,8 +12,5 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.2.0"
|
"concurrently": "^9.2.0"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@tabler/core": "^1.3.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user