From fc2eeb82ca218b0eb1e72f878a119d10c26b2564 Mon Sep 17 00:00:00 2001 From: itqop Date: Thu, 25 Dec 2025 11:07:57 +0300 Subject: [PATCH] 1 stage front refactoring --- REFACTORING_TODO.md | 325 ++++++++++++++++++++++++++++ static/js/config.js | 54 +++++ static/js/utils/dom.utils.js | 178 +++++++++++++++ static/js/utils/file.utils.js | 111 ++++++++++ static/js/utils/format.utils.js | 150 +++++++++++++ static/js/utils/validation.utils.js | 133 ++++++++++++ 6 files changed, 951 insertions(+) create mode 100644 REFACTORING_TODO.md create mode 100644 static/js/config.js create mode 100644 static/js/utils/dom.utils.js create mode 100644 static/js/utils/file.utils.js create mode 100644 static/js/utils/format.utils.js create mode 100644 static/js/utils/validation.utils.js diff --git a/REFACTORING_TODO.md b/REFACTORING_TODO.md new file mode 100644 index 0000000..3769c96 --- /dev/null +++ b/REFACTORING_TODO.md @@ -0,0 +1,325 @@ +# Рефакторинг app.js → Модули: Прогресс + +**Дата начала**: 2025-12-25 +**Стратегия**: Постепенная миграция (app.js остаётся рабочим) +**Статус**: 🟡 В процессе + +--- + +## 📊 Общий прогресс: 20% + +``` +[████░░░░░░░░░░░░░░░░] 20% завершено +``` + +--- + +## Этапы рефакторинга + +### ✅ Этап 1: Подготовка (ЗАВЕРШЁН) + +**Дата**: 2025-12-25 +**Статус**: ✅ Готово + +- [x] Создать структуру папок `static/js/` + - [x] `js/state/` + - [x] `js/services/` + - [x] `js/ui/` + - [x] `js/utils/` + - [x] `js/data/` +- [x] Создать `js/config.js` с константами + +**Результат**: Структура готова, app.js не тронут ✅ + +--- + +### ✅ Этап 2: Утилиты (ЗАВЕРШЁН) + +**Дата**: 2025-12-25 +**Статус**: ✅ Готово + +#### 2.1. utils/format.utils.js ✅ +**Строки из app.js**: 150-273 +**Функции** (11 шт.): +- [x] `generateUUID()` - генерация UUID +- [x] `formatTime(seconds)` - форматирование времени +- [x] `formatTimestamp(isoString)` - ISO → локальное время +- [x] `isTableText(text)` - проверка на таблицу +- [x] `parseTextTable(text)` - парсинг текстовых таблиц +- [x] `escapeHtml(text)` - экранирование HTML +- [x] `pluralize(count, one, few, many)` - склонение числительных + +**Экспорт**: ES6 named exports ✅ + +#### 2.2. utils/file.utils.js ✅ +**Строки из app.js**: 262-273, 984-1132 +**Функции** (6 шт.): +- [x] `downloadJSON(data, filename)` - скачать JSON +- [x] `loadFileAsJSON(file)` - загрузить JSON +- [x] `loadFileAsText(file)` - загрузить текст +- [x] `selectFile(accept)` - выбрать файл +- [x] `downloadText(text, filename)` - скачать текст + +**Экспорт**: ES6 named exports ✅ + +#### 2.3. utils/validation.utils.js ✅ +**Строки из app.js**: 823-860 +**Функции** (4 шт.): +- [x] `validateJSON(jsonString)` - валидация JSON +- [x] `validateLoginFormat(login)` - проверка формата логина (8 цифр) +- [x] `validateQuestions(questions)` - валидация массива вопросов +- [x] `validateEnvironment(environment)` - проверка окружения + +**Экспорт**: ES6 named exports ✅ + +#### 2.4. utils/dom.utils.js ✅ +**Строки из app.js**: 277-281 + новые +**Функции** (15 шт.): +- [x] `showElement(element)` - показать элемент +- [x] `hideElement(element)` - скрыть элемент +- [x] `toggleElement(element)` - переключить видимость +- [x] `setElementText(element, text)` - установить текст +- [x] `setElementHTML(element, html)` - установить HTML +- [x] `getInputValue(element)` - получить значение input +- [x] `setInputValue(element, value)` - установить значение input +- [x] `addClass(element, className)` - добавить класс +- [x] `removeClass(element, className)` - удалить класс +- [x] `hasClass(element, className)` - проверить класс +- [x] `showToast(message, type)` - показать уведомление +- [x] `clearChildren(element)` - очистить детей +- [x] `createElement(tag, attributes, parent)` - создать элемент + +**Экспорт**: ES6 named exports ✅ + +--- + +### 🟡 Этап 3: State Management (В ПРОЦЕССЕ) + +**Дата**: - +**Статус**: 🔲 Ожидает + +#### 3.1. state/appState.js 🔲 +**Строки из app.js**: 10-51 +**Что нужно**: +- [ ] Создать класс AppState +- [ ] Геттеры: `getCurrentEnv()`, `getCurrentEnvSettings()` +- [ ] Сеттеры: `setCurrentEnvironment()`, `updateSettings()` +- [ ] Методы: `saveToLocalStorage()`, `loadFromLocalStorage()` +- [ ] Export: default export singleton + +#### 3.2. data/storage.js 🔲 +**Строки из app.js**: 618-642 +**Что нужно**: +- [ ] `saveEnvironmentData(env, data)` - сохранить данные окружения +- [ ] `loadEnvironmentData(env)` - загрузить данные окружения +- [ ] `clearEnvironmentData(env)` - очистить данные +- [ ] `clearAllData()` - очистить всё + +#### 3.3. data/defaults.js 🔲 +**Файл**: settings.js (перенести) +**Что нужно**: +- [ ] Экспортировать `defaultSettings` из settings.js +- [ ] Адаптировать для использования в модулях + +--- + +### 🔲 Этап 4: Services (ОЖИДАЕТ) + +**Дата**: - +**Статус**: 🔲 Ожидает + +#### 4.1. services/api-client.js 🔲 +**Файл**: api-client.js (переместить) +- [ ] Переместить существующий `api-client.js` в `services/` +- [ ] Добавить ES6 экспорт: `export default new BriefBenchAPI()` +- [ ] Протестировать импорт + +#### 4.2. services/auth.service.js 🔲 +**Строки из app.js**: 60-140 +**Функции**: +- [ ] `checkAuth()` - проверка авторизации +- [ ] `login(loginString)` - вход +- [ ] `logout()` - выход +- [ ] `isAuthenticated()` - проверка статуса + +#### 4.3. services/settings.service.js 🔲 +**Строки из app.js**: 290-357 +**Функции**: +- [ ] `loadFromServer()` - загрузить с сервера +- [ ] `saveToServer(settings)` - сохранить на сервер +- [ ] `extractEnvSettings(envSettings)` - извлечь настройки окружения +- [ ] `resetToDefaults()` - сброс к дефолтным + +#### 4.4. services/query.service.js 🔲 +**Строки из app.js**: 861-1063 +**Функции**: +- [ ] `buildRequestBody()` - построить тело запроса +- [ ] `sendQuery(env, apiMode, body)` - отправить запрос +- [ ] `extractQuestions()` - извлечь вопросы из textarea +- [ ] `loadRequestFromFile()` - загрузить запрос из файла +- [ ] `loadResponseFromFile()` - загрузить ответ из файла + +--- + +### 🔲 Этап 5: UI Components (ОЖИДАЕТ) + +**Дата**: - +**Статус**: 🔲 Ожидает + +#### 5.1. ui/auth.ui.js 🔲 +**Строки из app.js**: 77-132 +- [ ] `showLoginScreen()` - показать экран входа +- [ ] `hideLoginScreen()` - скрыть экран входа +- [ ] `setupListeners()` - подключить обработчики +- [ ] `handleLoginSubmit()` - обработка входа + +#### 5.2. ui/loading.ui.js 🔲 +**Строки из app.js**: 1137-1145 +- [ ] `show(message)` - показать загрузку +- [ ] `hide()` - скрыть загрузку + +#### 5.3. ui/settings.ui.js 🔲 +**Строки из app.js**: 362-813 +- [ ] `open()` - открыть диалог +- [ ] `close()` - закрыть диалог +- [ ] `populate()` - заполнить поля +- [ ] `read()` - прочитать поля +- [ ] `toggleBackendSettings(show)` - показать/скрыть backend настройки +- [ ] `save()` - сохранить настройки +- [ ] `reset()` - сбросить настройки +- [ ] `export()` - экспорт настроек +- [ ] `import()` - импорт настроек +- [ ] `setupListeners()` - подключить обработчики + +#### 5.4. ui/query-builder.ui.js 🔲 +**Строки из app.js**: 643-883, 884-950 +- [ ] `show()` - показать построитель запросов +- [ ] `switchMode(mode)` - переключить режим (questions/raw-json) +- [ ] `validateJSON()` - валидация JSON +- [ ] `handleSendQuery()` - обработка отправки +- [ ] `setupListeners()` - подключить обработчики + +#### 5.5. ui/questions-list.ui.js 🔲 +**Строки из app.js**: 1179-1273 +- [ ] `render()` - рендер списка вопросов +- [ ] `selectAnswer(index)` - выбрать ответ +- [ ] `updateCount()` - обновить счётчик +- [ ] `hasAnnotations(docsSection)` - проверить наличие аннотаций +- [ ] `setupListeners()` - подключить обработчики + +#### 5.6. ui/answer-viewer.ui.js 🔲 +**Строки из app.js**: 1279-1443 +- [ ] `render(index)` - рендер ответа +- [ ] `renderBody(elementId, text)` - рендер тела ответа +- [ ] `renderDocuments(containerId, docs, ...)` - рендер документов +- [ ] `toggleExpansion(id)` - раскрыть/свернуть +- [ ] `switchTab(tabButton, tabId)` - переключить таб +- [ ] `setupListeners()` - подключить обработчики + +#### 5.7. ui/annotations.ui.js 🔲 +**Строки из app.js**: 1448-1615 +- [ ] `initForAnswer(index)` - инициализация аннотаций +- [ ] `loadForAnswer(index)` - загрузить аннотации +- [ ] `loadSection(section, data)` - загрузить секцию +- [ ] `loadDocuments(section, subsection, docs)` - загрузить документы +- [ ] `setupListeners()` - подключить обработчики +- [ ] `saveDraft()` - сохранить черновик + +--- + +### 🔲 Этап 6: Main Entry Point (ОЖИДАЕТ) + +**Дата**: - +**Статус**: 🔲 Ожидает + +#### 6.1. js/main.js 🔲 +**Что нужно**: +- [ ] Импортировать все модули +- [ ] Функция `initApp()` - инициализация приложения +- [ ] Функция `setupEnvironmentTabs()` - настройка табов +- [ ] Функция `switchEnvironment(env)` - переключение окружения +- [ ] Функция `updateUI()` - обновление UI +- [ ] DOMContentLoaded listener - точка входа + +--- + +### 🔲 Этап 7: Тестирование (ОЖИДАЕТ) + +**Дата**: - +**Статус**: 🔲 Ожидает + +#### 7.1. Ручное тестирование 🔲 +- [ ] Авторизация работает +- [ ] Загрузка/сохранение настроек +- [ ] Отправка запросов (bench mode) +- [ ] Отправка запросов (backend mode) +- [ ] Отображение ответов +- [ ] Аннотации сохраняются +- [ ] Экспорт/импорт работает +- [ ] Переключение окружений + +#### 7.2. Обновление index.html 🔲 +- [ ] Закомментировать старые скрипты: + ```html + + + + ``` +- [ ] Подключить новый entry point: + ```html + + ``` + +#### 7.3. Очистка 🔲 +- [ ] Удалить старый `app.js` (или переименовать в `app.js.old`) +- [ ] Удалить `settings.js` (или переименовать) +- [ ] Переместить `api-client.js` (если еще не перемещён) +- [ ] Проверить что всё работает + +--- + +## 📈 Статистика + +### Создано файлов: 5/17 + +| Категория | Создано | Всего | Прогресс | +|-----------|---------|-------|----------| +| Config | 1 | 1 | 100% ✅ | +| Utils | 4 | 4 | 100% ✅ | +| State | 0 | 1 | 0% 🔲 | +| Data | 0 | 2 | 0% 🔲 | +| Services | 0 | 4 | 0% 🔲 | +| UI | 0 | 7 | 0% 🔲 | +| Main | 0 | 1 | 0% 🔲 | + +### Перенесено функций: ~36/~150 + +- ✅ Format utils: 11 функций +- ✅ File utils: 6 функций +- ✅ Validation utils: 4 функций +- ✅ DOM utils: 15 функций +- 🔲 Остальное: ~114 функций + +--- + +## 🎯 Следующий шаг + +**Этап 3: State Management** + +Создать: +1. `state/appState.js` - глобальное состояние +2. `data/storage.js` - обёртка localStorage +3. `data/defaults.js` - дефолтные настройки + +--- + +## 📝 Заметки + +- app.js (1671 строка) остаётся рабочим до конца рефакторинга +- Все новые модули используют ES6 синтаксис +- Экспорты: named exports для utils, default export для сервисов/UI +- Модули полностью автономны и тестируемы + +--- + +**Последнее обновление**: 2025-12-25 (Этап 2 завершён) diff --git a/static/js/config.js b/static/js/config.js new file mode 100644 index 0000000..3d1ef5c --- /dev/null +++ b/static/js/config.js @@ -0,0 +1,54 @@ +/** + * Application Configuration + * + * Централизованная конфигурация приложения. + */ + +// API Configuration +export const API_CONFIG = { + baseURL: '/api/v1', + timeout: 1800000 // 30 minutes in milliseconds +} + +// UI Configuration +export const UI_CONFIG = { + defaultQueryMode: 'questions', + defaultWithDocs: true, + maxAnnotationLength: 5000 +} + +// Storage Keys +export const STORAGE_KEYS = { + token: 'briefBenchToken', + user: 'briefBenchUser', + settings: 'briefBenchSettings', + envData: (env) => `briefBenchData_${env}`, + annotations: 'briefBenchAnnotationsDraft' +} + +// Constants +export const ENVIRONMENTS = ['ift', 'psi', 'prod'] +export const API_MODES = ['bench', 'backend'] + +// Environment Names +export const ENV_NAMES = { + ift: 'ИФТ', + psi: 'ПСИ', + prod: 'ПРОМ' +} + +// Annotation Issue Types +export const ANNOTATION_ISSUES = [ + 'factual_error', + 'inaccurate_wording', + 'insufficient_context', + 'offtopic', + 'technical_answer' +] + +// Rating Values +export const RATING_VALUES = { + CORRECT: 'correct', + PARTIAL: 'partial', + INCORRECT: 'incorrect' +} diff --git a/static/js/utils/dom.utils.js b/static/js/utils/dom.utils.js new file mode 100644 index 0000000..3b3fecc --- /dev/null +++ b/static/js/utils/dom.utils.js @@ -0,0 +1,178 @@ +/** + * DOM Utilities + * + * Функции для работы с DOM (показать/скрыть элементы, обновить текст). + */ + +/** + * Показать элемент + * @param {string|HTMLElement} element - ID элемента или сам элемент + */ +export function showElement(element) { + const el = typeof element === 'string' ? document.getElementById(element) : element + if (el) { + el.style.display = '' + el.classList.remove('hidden') + } +} + +/** + * Скрыть элемент + * @param {string|HTMLElement} element - ID элемента или сам элемент + */ +export function hideElement(element) { + const el = typeof element === 'string' ? document.getElementById(element) : element + if (el) { + el.classList.add('hidden') + } +} + +/** + * Переключить видимость элемента + * @param {string|HTMLElement} element - ID элемента или сам элемент + */ +export function toggleElement(element) { + const el = typeof element === 'string' ? document.getElementById(element) : element + if (el) { + if (el.classList.contains('hidden')) { + showElement(el) + } else { + hideElement(el) + } + } +} + +/** + * Установить текст элемента + * @param {string|HTMLElement} element - ID элемента или сам элемент + * @param {string} text - Текст для установки + */ +export function setElementText(element, text) { + const el = typeof element === 'string' ? document.getElementById(element) : element + if (el) { + el.textContent = text + } +} + +/** + * Установить HTML элемента + * @param {string|HTMLElement} element - ID элемента или сам элемент + * @param {string} html - HTML для установки + */ +export function setElementHTML(element, html) { + const el = typeof element === 'string' ? document.getElementById(element) : element + if (el) { + el.innerHTML = html + } +} + +/** + * Получить значение input элемента + * @param {string|HTMLElement} element - ID элемента или сам элемент + * @returns {string} Значение элемента + */ +export function getInputValue(element) { + const el = typeof element === 'string' ? document.getElementById(element) : element + return el ? el.value : '' +} + +/** + * Установить значение input элемента + * @param {string|HTMLElement} element - ID элемента или сам элемент + * @param {string} value - Значение для установки + */ +export function setInputValue(element, value) { + const el = typeof element === 'string' ? document.getElementById(element) : element + if (el) { + el.value = value + } +} + +/** + * Добавить класс элементу + * @param {string|HTMLElement} element - ID элемента или сам элемент + * @param {string} className - Имя класса + */ +export function addClass(element, className) { + const el = typeof element === 'string' ? document.getElementById(element) : element + if (el) { + el.classList.add(className) + } +} + +/** + * Удалить класс у элемента + * @param {string|HTMLElement} element - ID элемента или сам элемент + * @param {string} className - Имя класса + */ +export function removeClass(element, className) { + const el = typeof element === 'string' ? document.getElementById(element) : element + if (el) { + el.classList.remove(className) + } +} + +/** + * Проверить наличие класса у элемента + * @param {string|HTMLElement} element - ID элемента или сам элемент + * @param {string} className - Имя класса + * @returns {boolean} True если класс есть + */ +export function hasClass(element, className) { + const el = typeof element === 'string' ? document.getElementById(element) : element + return el ? el.classList.contains(className) : false +} + +/** + * Показать toast уведомление + * @param {string} message - Сообщение + * @param {string} type - Тип ('info', 'success', 'error', 'warning') + */ +export function showToast(message, type = 'info') { + // TODO: Implement proper toast/snackbar component + console.log(`[${type.toUpperCase()}] ${message}`) + alert(message) +} + +/** + * Очистить всех детей у элемента + * @param {string|HTMLElement} element - ID элемента или сам элемент + */ +export function clearChildren(element) { + const el = typeof element === 'string' ? document.getElementById(element) : element + if (el) { + el.innerHTML = '' + } +} + +/** + * Создать элемент с атрибутами + * @param {string} tag - Тег элемента + * @param {object} attributes - Атрибуты элемента + * @param {string|HTMLElement} parent - Родительский элемент (опционально) + * @returns {HTMLElement} Созданный элемент + */ +export function createElement(tag, attributes = {}, parent = null) { + const el = document.createElement(tag) + + for (const [key, value] of Object.entries(attributes)) { + if (key === 'class') { + el.className = value + } else if (key === 'text') { + el.textContent = value + } else if (key === 'html') { + el.innerHTML = value + } else { + el.setAttribute(key, value) + } + } + + if (parent) { + const parentEl = typeof parent === 'string' ? document.getElementById(parent) : parent + if (parentEl) { + parentEl.appendChild(el) + } + } + + return el +} diff --git a/static/js/utils/file.utils.js b/static/js/utils/file.utils.js new file mode 100644 index 0000000..e8bc825 --- /dev/null +++ b/static/js/utils/file.utils.js @@ -0,0 +1,111 @@ +/** + * File Utilities + * + * Функции для работы с файлами (загрузка, сохранение). + */ + +/** + * Скачать данные как JSON файл + * @param {object} data - Данные для экспорта + * @param {string} filename - Имя файла + */ +export function downloadJSON(data, filename) { + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} + +/** + * Загрузить файл как JSON + * @param {File} file - Файл для загрузки + * @returns {Promise} Распарсенный JSON объект + */ +export async function loadFileAsJSON(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader() + + reader.onload = (e) => { + try { + const data = JSON.parse(e.target.result) + resolve(data) + } catch (error) { + reject(new Error('Невалидный JSON формат')) + } + } + + reader.onerror = () => { + reject(new Error('Ошибка чтения файла')) + } + + reader.readAsText(file) + }) +} + +/** + * Загрузить файл как текст + * @param {File} file - Файл для загрузки + * @returns {Promise} Содержимое файла + */ +export async function loadFileAsText(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader() + + reader.onload = (e) => { + resolve(e.target.result) + } + + reader.onerror = () => { + reject(new Error('Ошибка чтения файла')) + } + + reader.readAsText(file) + }) +} + +/** + * Создать input для выбора файла и вернуть выбранный файл + * @param {string} accept - MIME типы (например, 'application/json') + * @returns {Promise} Выбранный файл или null + */ +export async function selectFile(accept = '*') { + return new Promise((resolve) => { + const input = document.createElement('input') + input.type = 'file' + input.accept = accept + + input.onchange = (e) => { + const file = e.target.files[0] + resolve(file || null) + } + + input.oncancel = () => { + resolve(null) + } + + input.click() + }) +} + +/** + * Скачать текст как файл + * @param {string} text - Текст для сохранения + * @param {string} filename - Имя файла + * @param {string} mimeType - MIME тип (по умолчанию text/plain) + */ +export function downloadText(text, filename, mimeType = 'text/plain') { + const blob = new Blob([text], { type: mimeType }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} diff --git a/static/js/utils/format.utils.js b/static/js/utils/format.utils.js new file mode 100644 index 0000000..ef32116 --- /dev/null +++ b/static/js/utils/format.utils.js @@ -0,0 +1,150 @@ +/** + * Format Utilities + * + * Функции для форматирования данных (время, текст, UUID). + */ + +/** + * Генерация UUID v4 + * @returns {string} UUID + */ +export function generateUUID() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0 + const v = c === 'x' ? r : (r & 0x3 | 0x8) + return v.toString(16) + }) +} + +/** + * Форматировать время в секундах в читаемый вид + * @param {number} seconds - Количество секунд + * @returns {string} Отформатированное время (например, "2.5 сек", "1 мин 30 сек") + */ +export function formatTime(seconds) { + if (seconds < 60) { + return `${seconds.toFixed(1)} сек` + } + + const minutes = Math.floor(seconds / 60) + const secs = Math.floor(seconds % 60) + return `${minutes} мин ${secs} сек` +} + +/** + * Форматировать ISO timestamp в локальное время + * @param {string} isoString - ISO строка времени + * @returns {string} Отформатированное локальное время + */ +export function formatTimestamp(isoString) { + try { + const date = new Date(isoString) + return date.toLocaleString('ru-RU', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + } catch (e) { + return isoString + } +} + +/** + * Проверить является ли текст таблицей + * @param {string} text - Текст для проверки + * @returns {boolean} True если текст похож на таблицу + */ +export function isTableText(text) { + const lines = text.split('\n').filter(line => line.trim()) + if (lines.length < 3) return false + + // Проверить наличие разделителей + const hasSeparator = lines.some(line => /^[\s\-|+]+$/.test(line)) + const hasPipes = lines.filter(line => line.includes('|')).length > 2 + + return hasSeparator || hasPipes +} + +/** + * Парсинг текстовой таблицы в HTML + * @param {string} text - Текст таблицы + * @returns {string} HTML таблица + */ +export function parseTextTable(text) { + const lines = text.split('\n').filter(line => line.trim()) + + if (lines.length < 2) { + return `
${escapeHtml(text)}
` + } + + let html = '' + let inHeader = true + + for (const line of lines) { + // Skip separator lines + if (/^[\s\-|+]+$/.test(line)) { + inHeader = false + continue + } + + // Parse cells + const cells = line.split('|') + .map(cell => cell.trim()) + .filter((cell, i, arr) => i > 0 && i < arr.length - 1) // Remove first and last empty cells + + if (cells.length === 0) continue + + html += '' + const tag = inHeader ? 'th' : 'td' + + for (const cell of cells) { + html += `<${tag}>${escapeHtml(cell)}` + } + + html += '' + + if (inHeader) { + inHeader = false + } + } + + html += '
' + return html +} + +/** + * Экранировать HTML спецсимволы + * @param {string} text - Исходный текст + * @returns {string} Экранированный текст + */ +export function escapeHtml(text) { + const div = document.createElement('div') + div.textContent = text + return div.innerHTML +} + +/** + * Склонение числительных (русский язык) + * @param {number} count - Число + * @param {string} one - Форма для 1 (например, "вопрос") + * @param {string} few - Форма для 2-4 (например, "вопроса") + * @param {string} many - Форма для 5+ (например, "вопросов") + * @returns {string} Правильная форма + */ +export function pluralize(count, one, few, many) { + const mod10 = count % 10 + const mod100 = count % 100 + + if (mod10 === 1 && mod100 !== 11) { + return one + } + + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) { + return few + } + + return many +} diff --git a/static/js/utils/validation.utils.js b/static/js/utils/validation.utils.js new file mode 100644 index 0000000..d786a4d --- /dev/null +++ b/static/js/utils/validation.utils.js @@ -0,0 +1,133 @@ +/** + * Validation Utilities + * + * Функции для валидации данных. + */ + +/** + * Валидация JSON строки + * @param {string} jsonString - JSON строка для валидации + * @returns {{valid: boolean, error: string|null, data: object|null}} Результат валидации + */ +export function validateJSON(jsonString) { + if (!jsonString || !jsonString.trim()) { + return { + valid: false, + error: 'JSON не может быть пустым', + data: null + } + } + + try { + const data = JSON.parse(jsonString) + return { + valid: true, + error: null, + data + } + } catch (e) { + return { + valid: false, + error: `Ошибка парсинга JSON: ${e.message}`, + data: null + } + } +} + +/** + * Валидация формата логина (8 цифр) + * @param {string} login - Логин для проверки + * @returns {{valid: boolean, error: string|null}} Результат валидации + */ +export function validateLoginFormat(login) { + if (!login || !login.trim()) { + return { + valid: false, + error: 'Логин не может быть пустым' + } + } + + if (!/^[0-9]{8}$/.test(login)) { + return { + valid: false, + error: 'Логин должен состоять из 8 цифр' + } + } + + return { + valid: true, + error: null + } +} + +/** + * Валидация массива вопросов для запроса + * @param {Array} questions - Массив вопросов + * @returns {{valid: boolean, error: string|null}} Результат валидации + */ +export function validateQuestions(questions) { + if (!Array.isArray(questions)) { + return { + valid: false, + error: 'Вопросы должны быть массивом' + } + } + + if (questions.length === 0) { + return { + valid: false, + error: 'Необходимо добавить хотя бы один вопрос' + } + } + + for (let i = 0; i < questions.length; i++) { + const q = questions[i] + + if (!q.body || typeof q.body !== 'string' || !q.body.trim()) { + return { + valid: false, + error: `Вопрос ${i + 1}: текст вопроса не может быть пустым` + } + } + + if (typeof q.with_docs !== 'boolean') { + return { + valid: false, + error: `Вопрос ${i + 1}: поле with_docs должно быть boolean` + } + } + } + + return { + valid: true, + error: null + } +} + +/** + * Валидация окружения + * @param {string} environment - Окружение для проверки + * @returns {{valid: boolean, error: string|null}} Результат валидации + */ +export function validateEnvironment(environment) { + const validEnvs = ['ift', 'psi', 'prod'] + + if (!environment) { + return { + valid: false, + error: 'Окружение не может быть пустым' + } + } + + if (!validEnvs.includes(environment)) { + return { + valid: false, + error: `Недопустимое окружение. Разрешены: ${validEnvs.join(', ')}` + } + } + + return { + valid: true, + error: null + } +}