Загрузить файлы в «/»

This commit is contained in:
svsptech 2026-05-18 15:54:16 +05:00
parent af7277f366
commit e7313870c5

911
crud_d.js Normal file
View File

@ -0,0 +1,911 @@
(function() {
'use strict';
// Хранилище зарегистрированных диалогов
const dialogs = new Map();
// Хранилище активных экземпляров диалогов
const activeDialogs = new Map();
// Типы полей по умолчанию
const FIELD_TYPES = {
text: (name, params, defaultValue = '') => {
const container = dom.div().cls('mb-3');
const label = dom.label().cls('form-label').atr('for', `field_${name}`).text(params.label || name);
const input = dom.input().cls('form-control').atr('type', 'text')
.atr('id', `field_${name}`).atr('name', name)
.atr('placeholder', params.placeholder || '');
if (params.required) input.atr('required', '');
if (params.readonly) input.atr('readonly', '');
if (params.maxlength) input.atr('maxlength', params.maxlength);
container.child(label);
container.child(input);
return {
element: container,
fieldName: name,
getValue: () => input.element.value,
setValue: (val) => { input.element.value = val || defaultValue; },
validate: () => {
if (params.required && !input.element.value.trim()) {
return `${params.label || name} обязательно для заполнения`;
}
return null;
}
};
},
number: (name, params, defaultValue = null) => {
const container = dom.div().cls('mb-3');
const label = dom.label().cls('form-label').atr('for', `field_${name}`).text(params.label || name);
const input = dom.input().cls('form-control').atr('type', 'number')
.atr('id', `field_${name}`).atr('name', name);
if (params.required) input.atr('required', '');
if (params.min !== undefined) input.atr('min', params.min);
if (params.max !== undefined) input.atr('max', params.max);
if (params.step) input.atr('step', params.step);
container.child(label);
container.child(input);
return {
element: container,
fieldName: name,
getValue: () => {
const val = input.element.value;
return val ? Number(val) : null;
},
setValue: (val) => { input.element.value = val !== null && val !== undefined ? val : ''; },
validate: () => {
if (params.required && input.element.value === '') {
return `${params.label || name} обязательно для заполнения`;
}
return null;
}
};
},
email: (name, params, defaultValue = '') => {
const container = dom.div().cls('mb-3');
const label = dom.label().cls('form-label').atr('for', `field_${name}`).text(params.label || name);
const input = dom.input().cls('form-control').atr('type', 'email')
.atr('id', `field_${name}`).atr('name', name)
.atr('placeholder', params.placeholder || '');
if (params.required) input.atr('required', '');
container.child(label);
container.child(input);
return {
element: container,
fieldName: name,
getValue: () => input.element.value,
setValue: (val) => { input.element.value = val || defaultValue; },
validate: () => {
if (params.required && !input.element.value.trim()) {
return `${params.label || name} обязательно для заполнения`;
}
if (input.element.value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.element.value)) {
return 'Неверный формат email';
}
return null;
}
};
},
textarea: (name, params, defaultValue = '') => {
const container = dom.div().cls('mb-3');
const label = dom.label().cls('form-label').atr('for', `field_${name}`).text(params.label || name);
const textarea = dom.textarea().cls('form-control')
.atr('id', `field_${name}`).atr('name', name)
.atr('rows', params.rows || 3);
if (params.required) textarea.atr('required', '');
container.child(label);
container.child(textarea);
return {
element: container,
fieldName: name,
getValue: () => textarea.element.value,
setValue: (val) => { textarea.element.value = val || defaultValue; },
validate: () => {
if (params.required && !textarea.element.value.trim()) {
return `${params.label || name} обязательно для заполнения`;
}
return null;
}
};
},
select: (name, params, defaultValue = null) => {
const container = dom.div().cls('mb-3');
const label = dom.label().cls('form-label').atr('for', `field_${name}`).text(params.label || name);
const select = dom.select().cls('form-select')
.atr('id', `field_${name}`).atr('name', name);
if (params.required) select.atr('required', '');
if (params.options) {
params.options.forEach(option => {
const opt = dom.option()
.atr('value', option.value)
.text(option.label);
select.child(opt);
});
}
container.child(label);
container.child(select);
return {
element: container,
fieldName: name,
getValue: () => select.element.value,
setValue: (val) => {
select.element.value = val || defaultValue || '';
},
validate: () => {
if (params.required && !select.element.value) {
return `${params.label || name} обязательно для заполнения`;
}
return null;
}
};
},
checkbox: (name, params, defaultValue = false) => {
const container = dom.div().cls('mb-3 form-check');
const input = dom.input().cls('form-check-input').atr('type', 'checkbox')
.atr('id', `field_${name}`).atr('name', name);
if (params.required) input.atr('required', '');
const label = dom.label().cls('form-check-label').atr('for', `field_${name}`).text(params.label || name);
container.child(input);
container.child(label);
return {
element: container,
fieldName: name,
getValue: () => input.element.checked,
setValue: (val) => { input.element.checked = val || defaultValue; },
validate: () => null
};
},
date: (name, params, defaultValue = '') => {
const container = dom.div().cls('mb-3');
const label = dom.label().cls('form-label').atr('for', `field_${name}`).text(params.label || name);
const input = dom.input().cls('form-control').atr('type', 'date')
.atr('id', `field_${name}`).atr('name', name);
if (params.required) input.atr('required', '');
container.child(label);
container.child(input);
return {
element: container,
fieldName: name,
getValue: () => input.element.value,
setValue: (val) => { input.element.value = val || defaultValue; },
validate: () => {
if (params.required && !input.element.value) {
return `${params.label || name} обязательно для заполнения`;
}
return null;
}
};
},
password: (name, params, defaultValue = '') => {
const container = dom.div().cls('mb-3');
const label = dom.label().cls('form-label').atr('for', `field_${name}`).text(params.label || name);
const input = dom.input().cls('form-control').atr('type', 'password')
.atr('id', `field_${name}`).atr('name', name)
.atr('placeholder', params.placeholder || '');
if (params.required) input.atr('required', '');
container.child(label);
container.child(input);
return {
element: container,
fieldName: name,
getValue: () => input.element.value,
setValue: (val) => { input.element.value = val || defaultValue; },
validate: () => {
if (params.required && !input.element.value) {
return `${params.label || name} обязательно для заполнения`;
}
return null;
}
};
}
};
// Фабрика полей
function createField(name, fieldConfig) {
const [type, params, defaultValue] = fieldConfig;
if (FIELD_TYPES[type]) {
return FIELD_TYPES[type](name, params, defaultValue);
}
if (type === 'custom' && params instanceof HTMLElement) {
return {
element: params,
fieldName: name,
getValue: () => {
const input = params.querySelector('input, select, textarea');
return input ? input.value : null;
},
setValue: (val) => {
const input = params.querySelector('input, select, textarea');
if (input) input.value = val;
},
validate: () => null
};
}
console.warn(`Неизвестный тип поля: ${type}`);
return FIELD_TYPES.text(name, params, defaultValue);
}
// Функция для расчета Bootstrap col класса
function getColClass(fieldsInRow) {
if (fieldsInRow === 1) return 'col-12';
if (fieldsInRow === 2) return 'col-md-6';
if (fieldsInRow === 3) return 'col-md-4';
if (fieldsInRow === 4) return 'col-md-3';
const colSize = Math.floor(12 / fieldsInRow);
return `col-md-${colSize}`;
}
// Функция для рендеринга полей с учетом layout
function renderFieldsWithLayout(container, fields, layout, fieldInstances) {
const fieldNames = Object.keys(fields);
const usedFields = new Set();
if (layout && layout.length > 0) {
layout.forEach(rowFields => {
const row = dom.div().cls('row');
const validFields = rowFields.filter(name => fields[name]);
const colClass = getColClass(validFields.length);
validFields.forEach(fieldName => {
const field = createField(fieldName, fields[fieldName]);
fieldInstances[fieldName] = field;
usedFields.add(fieldName);
const col = dom.div().cls(colClass);
const fieldElement = field.element;
if (fieldElement.element) {
fieldElement.element.classList.remove('mb-3');
}
col.child(fieldElement);
row.child(col);
});
container.child(row);
});
}
const remainingFields = fieldNames.filter(name => !usedFields.has(name));
remainingFields.forEach(fieldName => {
const row = dom.div().cls('row');
const col = dom.div().cls('col-12');
const field = createField(fieldName, fields[fieldName]);
fieldInstances[fieldName] = field;
const fieldElement = field.element;
if (fieldElement.element) {
fieldElement.element.classList.remove('mb-3');
}
col.child(fieldElement);
row.child(col);
container.child(row);
});
}
// HTTP запросы
async function apiRequest(method, url, data = null) {
const options = {
method,
headers: {
'Content-Type': 'application/json',
}
};
if (data && method !== 'GET') {
options.body = JSON.stringify(data);
}
const response = await fetch(url, options);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `Ошибка HTTP: ${response.status}`);
}
return await response.json();
}
// Создание модального окна
function createModal(dialogId, title, fields, options = {}, mode = 'create', itemId = null) {
const modalId = `modal_${dialogId}_${Date.now()}`;
const { width = '', categories = null } = options;
const config = dialogs.get(dialogId);
// Создаем структуру модального окна
const modal = dom.div().cls('modal fade').atr('id', modalId)
.atr('tabindex', '-1').atr('aria-hidden', 'true');
// Настройка ширины диалога
const dialog = dom.div().cls('modal-dialog');
if (width) {
if (['sm', 'lg', 'xl'].includes(width)) {
dialog.cls(`modal-${width}`);
} else {
dialog.css(`max-width: ${width}`);
}
}
const content = dom.div().cls('modal-content');
// Заголовок
const header = dom.div().cls('modal-header');
const titleText = mode === 'create' ? title : `Редактирование: ${title}`;
const titleEl = dom.h5().cls('modal-title').text(titleText);
const closeBtn = dom.button().cls('btn-close').atr('data-bs-dismiss', 'modal').atr('aria-label', 'Close');
header.child(titleEl);
header.child(closeBtn);
// Тело
const body = dom.div().cls('modal-body');
const alertContainer = dom.div().cls('alert alert-danger').atr('role', 'alert')
.css('display: none');
body.child(alertContainer);
// Индикатор загрузки
const loadingOverlay = dom.div().css('text-align: center; padding: 50px;');
const spinner = dom.div().cls('spinner-border').atr('role', 'status');
const loadingText = dom.span().cls('visually-hidden').text('Загрузка...');
spinner.child(loadingText);
loadingOverlay.child(spinner);
body.child(loadingOverlay);
// Создаем поля
const fieldInstances = {};
if (categories && Object.keys(categories).length > 0) {
const fieldsInCategories = new Set();
Object.values(categories).forEach(layout => {
layout.forEach(row => {
row.forEach(fieldName => fieldsInCategories.add(fieldName));
});
});
const remainingFields = {};
Object.keys(fields).forEach(fieldName => {
if (!fieldsInCategories.has(fieldName)) {
remainingFields[fieldName] = fields[fieldName];
}
});
let finalCategories = { ...categories };
if (Object.keys(categories).length === 1 && Object.keys(remainingFields).length > 0) {
finalCategories['Дополнительно'] = Object.keys(remainingFields).map(name => [name]);
} else if (Object.keys(remainingFields).length > 0) {
finalCategories['Дополнительно'] = Object.keys(remainingFields).map(name => [name]);
}
const categoryNames = Object.keys(finalCategories);
const tabNav = dom.ul().cls('nav nav-tabs').atr('role', 'tablist');
const tabContent = dom.div().cls('tab-content');
categoryNames.forEach((catName, index) => {
const tabId = `tab_${dialogId}_${catName.replace(/[^a-zA-Z0-9]/g, '_')}`;
const isActive = index === 0;
const navItem = dom.li().cls('nav-item').atr('role', 'presentation');
const navLink = dom.button()
.cls(`nav-link ${isActive ? 'active' : ''}`)
.atr('id', `${tabId}-tab`)
.atr('data-bs-toggle', 'tab')
.atr('data-bs-target', `#${tabId}`)
.atr('type', 'button')
.atr('role', 'tab')
.atr('aria-selected', isActive ? 'true' : 'false')
.text(catName);
navItem.child(navLink);
tabNav.child(navItem);
const tabPane = dom.div()
.cls(`tab-pane fade ${isActive ? 'show active' : ''}`)
.atr('id', tabId)
.atr('role', 'tabpanel')
.atr('aria-labelledby', `${tabId}-tab`);
const catLayout = finalCategories[catName];
const catFields = {};
const catFieldNames = new Set();
catLayout.forEach(row => {
row.forEach(fieldName => {
if (fields[fieldName]) {
catFieldNames.add(fieldName);
catFields[fieldName] = fields[fieldName];
}
});
});
renderFieldsWithLayout(tabPane, catFields, catLayout, fieldInstances);
tabContent.child(tabPane);
});
body.child(tabNav);
body.child(tabContent);
} else {
renderFieldsWithLayout(body, fields, null, fieldInstances);
}
// Скрываем лоадер
loadingOverlay.element.style.display = 'none';
// Футер
const footer = dom.div().cls('modal-footer');
const cancelBtn = dom.button().cls('btn btn-secondary').atr('data-bs-dismiss', 'modal').text('Отмена');
// Кнопка сохранения/создания
const submitBtn = dom.button().cls('btn btn-primary');
const submitSpinner = dom.span().cls('spinner-border spinner-border-sm').atr('role', 'status')
.atr('aria-hidden', 'true').css('display: none');
if (mode === 'create') {
submitBtn.text('Создать');
submitBtn.child(submitSpinner);
footer.child(cancelBtn);
footer.child(submitBtn);
} else {
submitBtn.text('Сохранить');
submitBtn.child(submitSpinner);
// Кнопка удаления
const deleteBtn = dom.button().cls('btn btn-danger').text('Удалить');
const deleteSpinner = dom.span().cls('spinner-border spinner-border-sm').atr('role', 'status')
.atr('aria-hidden', 'true').css('display: none');
deleteBtn.child(deleteSpinner);
footer.child(cancelBtn);
footer.child(deleteBtn);
footer.child(submitBtn);
}
content.child(header);
content.child(body);
content.child(footer);
dialog.child(content);
modal.child(dialog);
document.body.appendChild(modal.done());
const modalElement = document.getElementById(modalId);
let bootstrapModal = null;
if (typeof bootstrap !== 'undefined') {
bootstrapModal = new bootstrap.Modal(modalElement);
}
const modalInstance = {
modalElement,
bootstrapModal,
fieldInstances,
alertContainer: alertContainer.element,
submitBtn: submitBtn.element,
submitSpinner: submitSpinner.element,
loadingOverlay: loadingOverlay.element,
getFormData: () => {
const data = {};
Object.entries(fieldInstances).forEach(([name, field]) => {
data[name] = field.getValue();
});
return data;
},
validate: () => {
const errors = [];
Object.entries(fieldInstances).forEach(([name, field]) => {
if (field.validate) {
const error = field.validate();
if (error) errors.push(error);
}
});
return errors;
},
showAlert: (message) => {
alertContainer.element.textContent = message;
alertContainer.element.style.display = 'block';
},
hideAlert: () => {
alertContainer.element.style.display = 'none';
},
setLoading: (loading) => {
submitBtn.element.disabled = loading;
submitSpinner.element.style.display = loading ? 'inline-block' : 'none';
},
setValues: (values) => {
Object.entries(values).forEach(([name, value]) => {
if (fieldInstances[name]) {
fieldInstances[name].setValue(value);
}
});
},
showLoadingOverlay: () => {
loadingOverlay.element.style.display = 'block';
// Скрываем все поля
body.element.querySelectorAll('.row, .nav-tabs, .tab-content').forEach(el => {
el.style.display = 'none';
});
},
hideLoadingOverlay: () => {
loadingOverlay.element.style.display = 'none';
body.element.querySelectorAll('.row, .nav-tabs, .tab-content').forEach(el => {
el.style.display = '';
});
},
destroy: () => {
if (bootstrapModal) {
bootstrapModal.hide();
bootstrapModal.dispose();
}
modalElement.remove();
}
};
return modalInstance;
}
// Основная функция API
window.crud_d = {
// Создание диалога
makeDialog: function(dialogId, fields, options = {}) {
if (typeof dialogId !== 'string') {
throw new Error('dialogId должен быть строкой');
}
if (!fields || typeof fields !== 'object') {
throw new Error('fields должен быть объектом');
}
// Сохраняем конфигурацию диалога
dialogs.set(dialogId, {
id: dialogId,
fields: fields,
options: options,
endpoints: {
create: null,
update: null,
get: null,
delete: null
}
});
const dialogApi = {
// Установка эндпоинтов
setCreateEndpoint: function(endpoint) {
dialogs.get(dialogId).endpoints.create = endpoint;
return this;
},
setUpdateEndpoint: function(endpoint) {
dialogs.get(dialogId).endpoints.update = endpoint;
return this;
},
setGetEndpoint: function(endpoint) {
dialogs.get(dialogId).endpoints.get = endpoint;
return this;
},
setDeleteEndpoint: function(endpoint) {
dialogs.get(dialogId).endpoints.delete = endpoint;
return this;
},
// Открытие в режиме создания
openCreate: function(title = 'Создание') {
return this._open(title, 'create');
},
// Открытие в режиме обновления
openUpdate: function(itemId, title = 'Редактирование') {
return this._open(title, 'update', itemId);
},
// Внутренний метод открытия
_open: function(title, mode, itemId = null) {
const config = dialogs.get(dialogId);
// Закрываем предыдущий экземпляр если есть
if (activeDialogs.has(dialogId)) {
activeDialogs.get(dialogId).destroy();
}
const modal = createModal(dialogId, title, config.fields, config.options, mode, itemId);
activeDialogs.set(dialogId, modal);
const api = {
onSuccess: function(callback) {
modal.onSuccess = callback;
return this;
},
onError: function(callback) {
modal.onError = callback;
return this;
},
onDelete: function(callback) {
modal.onDelete = callback;
return this;
},
setValues: function(values) {
modal.setValues(values);
return this;
},
getFormData: function() {
return modal.getFormData();
},
close: function() {
modal.destroy();
activeDialogs.delete(dialogId);
return this;
},
show: function() {
if (modal.bootstrapModal) {
modal.bootstrapModal.show();
}
return this;
}
};
// Загрузка данных для режима update
if (mode === 'update' && itemId) {
modal.showLoadingOverlay();
const getEndpoint = config.endpoints.get;
if (!getEndpoint) {
console.error('GET endpoint не установлен для диалога ' + dialogId);
modal.hideLoadingOverlay();
modal.showAlert('Ошибка: не указан endpoint для загрузки данных');
return api;
}
const url = getEndpoint.replace('{id}', itemId);
apiRequest('GET', url)
.then(data => {
modal.setValues(data);
modal.hideLoadingOverlay();
})
.catch(error => {
modal.hideLoadingOverlay();
modal.showAlert('Ошибка загрузки данных: ' + error.message);
});
}
// Обработчик сохранения/создания
modal.submitBtn.addEventListener('click', async () => {
const errors = modal.validate();
if (errors.length > 0) {
modal.showAlert(errors.join('<br>'));
return;
}
modal.hideAlert();
modal.setLoading(true);
try {
const formData = modal.getFormData();
let result;
if (mode === 'create') {
const createEndpoint = config.endpoints.create;
if (!createEndpoint) {
throw new Error('Create endpoint не установлен');
}
result = await apiRequest('POST', createEndpoint, formData);
} else {
const updateEndpoint = config.endpoints.update;
if (!updateEndpoint) {
throw new Error('Update endpoint не установлен');
}
const url = updateEndpoint.replace('{id}', itemId);
result = await apiRequest('PUT', url, formData);
}
if (typeof modal.onSuccess === 'function') {
modal.onSuccess(result);
}
modal.destroy();
activeDialogs.delete(dialogId);
} catch (error) {
modal.showAlert(error.message || 'Произошла ошибка');
modal.setLoading(false);
if (typeof modal.onError === 'function') {
modal.onError(error);
}
}
});
// Обработчик удаления (только для update)
if (mode === 'update') {
const deleteBtn = modal.modalElement.querySelector('.btn-danger');
const deleteSpinner = deleteBtn.querySelector('.spinner-border');
deleteBtn.addEventListener('click', async () => {
if (!confirm('Вы уверены, что хотите удалить этот элемент?')) {
return;
}
modal.hideAlert();
deleteBtn.disabled = true;
deleteSpinner.style.display = 'inline-block';
try {
const deleteEndpoint = config.endpoints.delete;
if (!deleteEndpoint) {
throw new Error('Delete endpoint не установлен');
}
const url = deleteEndpoint.replace('{id}', itemId);
await apiRequest('DELETE', url);
if (typeof modal.onDelete === 'function') {
modal.onDelete(itemId);
}
modal.destroy();
activeDialogs.delete(dialogId);
} catch (error) {
modal.showAlert(error.message || 'Ошибка при удалении');
deleteBtn.disabled = false;
deleteSpinner.style.display = 'none';
if (typeof modal.onError === 'function') {
modal.onError(error);
}
}
});
}
// Обработчик закрытия
modal.modalElement.addEventListener('hidden.bs.modal', () => {
modal.destroy();
activeDialogs.delete(dialogId);
});
// Показываем модальное окно
if (modal.bootstrapModal) {
modal.bootstrapModal.show();
}
return api;
},
// Получить конфигурацию диалога
getConfig: function() {
return dialogs.get(dialogId);
},
// Удалить регистрацию
destroy: function() {
if (activeDialogs.has(dialogId)) {
activeDialogs.get(dialogId).destroy();
activeDialogs.delete(dialogId);
}
dialogs.delete(dialogId);
}
};
return dialogApi;
},
// Глобальные методы для работы по имени диалога
// Установка эндпоинтов по имени диалога
setCreateEndpoint: function(dialogId, endpoint) {
const dialog = dialogs.get(dialogId);
if (dialog) {
dialog.endpoints.create = endpoint;
} else {
console.warn(`Диалог "${dialogId}" не найден`);
}
},
setUpdateEndpoint: function(dialogId, endpoint) {
const dialog = dialogs.get(dialogId);
if (dialog) {
dialog.endpoints.update = endpoint;
} else {
console.warn(`Диалог "${dialogId}" не найден`);
}
},
setGetEndpoint: function(dialogId, endpoint) {
const dialog = dialogs.get(dialogId);
if (dialog) {
dialog.endpoints.get = endpoint;
} else {
console.warn(`Диалог "${dialogId}" не найден`);
}
},
setDeleteEndpoint: function(dialogId, endpoint) {
const dialog = dialogs.get(dialogId);
if (dialog) {
dialog.endpoints.delete = endpoint;
} else {
console.warn(`Диалог "${dialogId}" не найден`);
}
},
// Открытие/закрытие по имени диалога
openCreate: function(dialogId, title = 'Создание') {
const dialog = dialogs.get(dialogId);
if (dialog) {
const api = this.makeDialog(dialogId, dialog.fields, dialog.options);
// Копируем эндпоинты
api.getConfig().endpoints = dialog.endpoints;
return api.openCreate(title);
} else {
console.error(`Диалог "${dialogId}" не найден`);
return null;
}
},
openUpdate: function(dialogId, itemId, title = 'Редактирование') {
const dialog = dialogs.get(dialogId);
if (dialog) {
const api = this.makeDialog(dialogId, dialog.fields, dialog.options);
// Копируем эндпоинты
api.getConfig().endpoints = dialog.endpoints;
return api.openUpdate(itemId, title);
} else {
console.error(`Диалог "${dialogId}" не найден`);
return null;
}
},
closeDialog: function(dialogId) {
if (activeDialogs.has(dialogId)) {
activeDialogs.get(dialogId).destroy();
activeDialogs.delete(dialogId);
}
},
// Получить список всех зарегистрированных диалогов
getRegisteredDialogs: function() {
return Array.from(dialogs.keys());
},
// Закрыть все активные диалоги
closeAll: function() {
activeDialogs.forEach(modal => modal.destroy());
activeDialogs.clear();
}
};
})();