first commit

This commit is contained in:
root
2026-01-10 16:16:39 +03:00
commit 5f5a28e292
154 changed files with 21454 additions and 0 deletions
+539
View File
@@ -0,0 +1,539 @@
/**
* DataTable - Универсальный модуль для интерактивных таблиц
*
* Функциональность:
* - AJAX-загрузка данных
* - Сортировка по столбцам
* - Поиск по столбцам (click-to-search)
* - Пагинация
* - Состояние URL (back/forward навигация)
* - Сохранение состояния поиска между запросами
*/
class DataTable {
/**
* @param {string} containerId - ID контейнера таблицы
* @param {Object} options - Конфигурация таблицы
*/
constructor(containerId, options = {}) {
this.containerId = containerId;
this.options = {
url: options.url || '/api/table',
perPage: options.perPage || 10,
debounceTime: options.debounceTime || 300,
preserveSearchOnSort: options.preserveSearchOnSort !== false,
...options
};
// Состояние таблицы
this.state = {
page: 1,
perPage: this.options.perPage,
sort: '',
order: 'asc',
filters: {}
};
this.searchTimeout = null;
// Инициализация с задержкой для уверенности что DOM готов
this.initWithDOM();
}
/**
* Инициализация с проверкой доступности DOM
*/
initWithDOM() {
// Пробуем найти контейнер
this.container = document.getElementById(this.containerId);
if (!this.container) {
// Контейнер не найден - пробуем через setTimeout
setTimeout(() => {
this.container = document.getElementById(this.containerId);
if (this.container) {
this.init();
} else {
// Контейнер всё ещё не найден, это нормально для AJAX-обновлений
// Не показываем ошибку в консоли
}
}, 0);
return;
}
this.init();
}
/**
* Инициализация таблицы
*/
init() {
// Проверяем, не инициализирован ли уже этот экземпляр
if (this.container.dataset.datatableInitialized) {
return;
}
this.container.dataset.datatableInitialized = 'true';
// Проверяем, есть ли уже данные в таблице (рендер на сервере)
const tbody = this.container.querySelector('tbody');
const existingRows = tbody ? tbody.querySelectorAll('tr') : [];
// Если есть строки в tbody и это не строка загрузки - данные уже есть
if (existingRows.length > 0) {
const hasDataRow = Array.from(existingRows).some(tr => !tr.classList.contains('loading'));
if (hasDataRow) {
// Данные уже есть на странице - не делаем AJAX запрос
this.bindEvents();
return;
}
}
// Если tbody пустой или содержит только строку загрузки - делаем AJAX запрос
this.bindEvents();
this.loadData();
}
/**
* Привязка событий
*/
bindEvents() {
// Делегирование кликов на уровне контейнера
this.container.addEventListener('click', (e) => this.handleClick(e));
// Делегирование ввода в поля поиска
this.container.addEventListener('input', (e) => this.handleInput(e));
// Обработка Enter в полях поиска
this.container.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.target.matches('[data-search-input]')) {
e.preventDefault();
e.target.blur();
}
});
// Обработка изменения количества записей на странице
this.container.addEventListener('change', (e) => {
if (e.target.matches('[id^="per-page-select-"]')) {
const value = e.target.value;
this.setPerPage(value);
}
});
}
/**
* Обработка кликов
*/
handleClick(e) {
// Клик по иконке сортировки
const sortIcon = e.target.closest('[data-sort]');
if (sortIcon) {
e.stopPropagation();
const column = sortIcon.dataset.sort;
this.toggleSort(column);
return;
}
// Клик по иконке поиска
const searchTrigger = e.target.closest('[data-search-trigger]');
if (searchTrigger) {
e.stopPropagation();
const th = searchTrigger.closest('th');
this.openSearch(th);
return;
}
// Клик по тексту заголовка
const headerText = e.target.closest('[data-header-text]');
if (headerText) {
e.stopPropagation();
const th = headerText.closest('th');
this.openSearch(th);
return;
}
// Клик по ссылке пагинации
const pageLink = e.target.closest('[data-page]');
if (pageLink && !e.target.closest('.disabled') && !e.target.closest('.active')) {
e.preventDefault();
const page = parseInt(pageLink.dataset.page);
if (page > 0) {
this.state.page = page;
this.loadData();
}
return;
}
// Клик по кнопке "предыдущая/следующая" страница
const navLink = e.target.closest('[data-nav-page]');
if (navLink && !e.target.closest('.disabled')) {
e.preventDefault();
const direction = navLink.dataset.navPage;
this.state.page = direction === 'prev'
? Math.max(1, this.state.page - 1)
: this.state.page + 1;
this.loadData();
return;
}
// Клик вне заголовков таблицы - закрываем все поисковые поля
if (!e.target.closest('thead')) {
this.closeAllSearches();
}
}
/**
* Обработка ввода в поля поиска
*/
handleInput(e) {
if (e.target.matches('[data-search-input]')) {
const column = e.target.dataset.searchInput;
this.debouncedSearch(column, e.target.value);
}
}
/**
* Debounced поиск
*/
debouncedSearch(column, value) {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.state.filters[column] = value;
this.state.page = 1;
this.loadData();
}, this.options.debounceTime);
}
/**
* Переключение сортировки
*/
toggleSort(column) {
if (this.state.sort === column) {
this.state.order = this.state.order === 'asc' ? 'desc' : 'asc';
} else {
this.state.sort = column;
this.state.order = 'desc';
}
this.loadData();
}
/**
* Открытие поля поиска
*/
openSearch(th) {
const headerText = th.querySelector('[data-header-text]');
const searchInput = th.querySelector('[data-search-input]');
if (headerText && searchInput) {
headerText.style.display = 'none';
searchInput.style.display = 'inline';
searchInput.focus();
}
}
/**
* Закрытие всех полей поиска
*/
closeAllSearches() {
const inputs = this.container.querySelectorAll('[data-search-input]');
inputs.forEach(input => {
const th = input.closest('th');
const headerText = th.querySelector('[data-header-text]');
if (headerText) {
if (input.value.trim()) {
// Если есть значение - показываем заголовок, скрываем input
input.style.display = 'none';
headerText.style.display = 'inline';
} else {
// Если пусто - также скрываем input и показываем заголовок
input.style.display = 'none';
headerText.style.display = 'inline';
}
}
});
}
/**
* Получение CSRF токена из cookie CodeIgniter 4
*/
getCsrfToken() {
// Сначала пробуем из data-атрибутов контейнера (если заданы)
if (this.container.dataset.csrfToken) {
return this.container.dataset.csrfToken;
}
// Пробуем из скрытого input внутри контейнера
const csrfInput = this.container.querySelector('input[name*="csrf"]');
if (csrfInput) {
return csrfInput.value;
}
// Пробуем из скрытого input на всей странице
const globalCsrfInput = document.querySelector('input[name*="csrf"]');
if (globalCsrfInput) {
return globalCsrfInput.value;
}
// Пробуем из cookie - ищем по паттерну
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
const [name, value] = cookie.trim().split('=');
// Ищем cookie, содержащий 'csrf' в имени
if (name.toLowerCase().includes('csrf')) {
return decodeURIComponent(value);
}
}
console.warn('CSRF token not found');
return '';
}
/**
* Получение имени CSRF параметра
*/
getCsrfTokenName() {
// Из data-атрибута
if (this.container.dataset.csrfTokenName) {
return this.container.dataset.csrfTokenName;
}
// Из скрытого input внутри контейнера
const csrfInput = this.container.querySelector('input[name*="csrf"]');
if (csrfInput) {
return csrfInput.name;
}
// Из скрытого input на странице
const globalCsrfInput = document.querySelector('input[name*="csrf"]');
if (globalCsrfInput) {
return globalCsrfInput.name;
}
// Значения по умолчанию
return 'csrf_test_name';
}
/**
* Обновление CSRF токена из ответа сервера
*/
updateCsrfToken(response) {
// CodeIgniter 4 может возвращать новый токен в заголовке
const csrfHeader = response.headers.get('X-CSRF-TOKEN');
if (csrfHeader) {
// Обновляем все найденные CSRF inputs
document.querySelectorAll('input[name*="csrf"]').forEach(input => {
input.value = csrfHeader;
});
// Также обновляем data-атрибут контейнера
this.container.dataset.csrfToken = csrfHeader;
}
}
/**
* Загрузка данных таблицы
*/
async loadData() {
const params = this.buildParams();
const csrfToken = this.getCsrfToken();
const csrfTokenName = this.getCsrfTokenName();
// Добавляем CSRF токен в параметры запроса
const url = `${this.options.url}?${params}&${csrfTokenName}=${encodeURIComponent(csrfToken)}`;
// Показываем лоадер в tbody
const tableBody = this.container.querySelector('tbody');
if (tableBody) {
tableBody.innerHTML = `
<tr>
<td colspan="100" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</td>
</tr>
`;
}
try {
const response = await fetch(url, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': csrfToken
}
});
// Обновляем CSRF токен из ответа
this.updateCsrfToken(response);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const html = await response.text();
this.updateTable(html);
} catch (error) {
console.error('DataTable: Ошибка загрузки данных:', error);
this.showError();
}
}
/**
* Обновление таблицы
* Парсит HTML и заменяет только tbody/tfoot, сохраняя thead
*/
updateTable(html) {
// Сохраняем состояние полей поиска
const searchInputs = this.container.querySelectorAll('[data-search-input]');
const searchStates = {};
searchInputs.forEach(input => {
searchStates[input.dataset.searchInput] = input.value;
});
// Ищем tbody и tfoot в ответе
const tbodyMatch = html.match(/<tbody[^>]*>[\s\S]*?<\/tbody>/i);
const tfootMatch = html.match(/<tfoot[^>]*>[\s\S]*?<\/tfoot>/i);
const oldTableBody = this.container.querySelector('tbody');
const oldTableFooter = this.container.querySelector('tfoot');
const table = this.container.querySelector('table');
if (tbodyMatch && oldTableBody && table) {
// Извлекаем содержимое tbody (без тегов tbody)
const tbodyContent = tbodyMatch[0].replace(/<\/?tbody[^>]*>/gi, '');
// Очищаем старый tbody и заполняем новым содержимым
oldTableBody.innerHTML = tbodyContent;
// Обновляем tfoot
if (tfootMatch) {
const tfootContent = tfootMatch[0].replace(/<\/?tfoot[^>]*>/gi, '');
if (oldTableFooter) {
oldTableFooter.innerHTML = tfootContent;
} else {
const newTfoot = document.createElement('tfoot');
newTfoot.innerHTML = tfootContent;
table.appendChild(newTfoot);
}
} else if (oldTableFooter) {
oldTableFooter.remove();
}
} else {
// Нет tbody или нет table - полная замена контейнера
this.container.innerHTML = html;
}
// Восстанавливаем состояние полей поиска
this.restoreSearchStates(searchStates);
// Обновляем URL
this.updateURL();
}
/**
* Восстановление состояния полей поиска
*/
restoreSearchStates(savedStates = null) {
const inputs = this.container.querySelectorAll('[data-search-input]');
inputs.forEach(input => {
const column = input.dataset.searchInput;
const value = savedStates && savedStates[column] !== undefined
? savedStates[column]
: input.value.trim();
const th = input.closest('th');
const headerText = th.querySelector('[data-header-text]');
if (headerText) {
if (value) {
headerText.style.display = 'none';
input.style.display = 'inline';
input.value = value;
} else {
input.style.display = 'none';
headerText.style.display = 'inline';
}
}
});
}
/**
* Обновление URL
*/
updateURL() {
const params = this.buildParams();
// Убираем существующие параметры из URL и добавляем новые
const baseUrl = this.options.url.split('?')[0];
const url = `${baseUrl}?${params}`;
window.history.pushState(this.state, '', url);
}
/**
* Формирование параметров запроса
*/
buildParams() {
const params = new URLSearchParams();
params.set('page', this.state.page);
params.set('perPage', this.state.perPage);
if (this.state.sort) {
params.set('sort', this.state.sort);
params.set('order', this.state.order);
}
Object.entries(this.state.filters).forEach(([key, value]) => {
if (value && value.trim()) {
params.set(`filters[${key}]`, value);
}
});
return params.toString();
}
/**
* Показать ошибку загрузки
*/
showError() {
const tableBody = this.container.querySelector('tbody');
if (tableBody) {
tableBody.innerHTML = `
<tr>
<td colspan="100" class="alert alert-danger m-3">
Ошибка загрузки данных. Пожалуйста, обновите страницу.
</td>
</tr>
`;
}
}
/**
* Установка параметров фильтрации извне
*/
setFilter(column, value) {
this.state.filters[column] = value;
this.state.page = 1;
this.loadData();
}
/**
* Установка количества записей на странице
*/
setPerPage(value) {
this.state.perPage = parseInt(value);
this.state.page = 1;
this.loadData();
}
/**
* Переход на страницу
*/
goToPage(page) {
this.state.page = Math.max(1, parseInt(page));
this.loadData();
}
}
// Экспорт для глобального использования
window.DataTable = DataTable;