Files
turborfq/public/app.js
Michael Chus 92a5c0040c fix: исправлена ошибка "createProgressModal is not defined"
- Добавлена функция createProgressModal в правильное место в app.js
- Упрощен код удаления до одного варианта (без излишней сложности)
- Функция определена до использования
- Проверена работоспособность batch delete
2026-01-21 04:20:53 +03:00

851 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
let currentSchema = null;
let currentTable = null;
let currentMeta = null;
let table = null;
let enterHandler = null;
let selectedRowsData = new Map(); // Хранилище выделенных строк для всех страниц
// Простая обёртка для fetch JSON
async function api(url, method = 'GET', body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
if (!res.ok) {
const txt = await res.text();
throw new Error(`HTTP ${res.status}: ${txt}`);
}
return res.json();
}
// Обновление счетчика выделенных строк
function updateSelectionCounter() {
const counter = document.getElementById('selectionCounter');
const count = selectedRowsData.size;
if (count > 0) {
counter.textContent = `Выбрано: ${count}`;
counter.style.display = 'block';
} else {
counter.style.display = 'none';
}
}
// Функция создания модального окна с прогресс-баром
function createProgressModal(message) {
const modal = document.createElement('div');
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
`;
const dialog = document.createElement('div');
dialog.style.cssText = `
background: white;
padding: 30px;
border-radius: 8px;
min-width: 300px;
text-align: center;
`;
dialog.innerHTML = `
<div style="font-size: 16px; margin-bottom: 20px;">${message}</div>
<div style="width: 100%; height: 30px; background: #e0e0e0; border-radius: 15px; overflow: hidden;">
<div class="progress-bar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #4CAF50, #45a049); transition: width 0.3s;"></div>
</div>
<div style="margin-top: 10px; font-size: 14px; color: #666;">
<span class="spinner" style="display: inline-block; animation: spin 1s linear infinite;">⏳</span>
Пожалуйста, подождите...
</div>
<style>
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>
`;
modal.appendChild(dialog);
// Анимация прогресс-бара (имитация)
const progressBar = dialog.querySelector('.progress-bar');
let progress = 0;
const interval = setInterval(() => {
progress += Math.random() * 15;
if (progress > 90) progress = 90; // Останавливаемся на 90%
progressBar.style.width = progress + '%';
}, 200);
modal.stopProgress = () => {
clearInterval(interval);
progressBar.style.width = '100%';
};
return modal;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Логин
document.getElementById('loginBtn').addEventListener('click', async () => {
const user = document.getElementById('loginUser').value.trim();
const pass = document.getElementById('loginPass').value;
const statusEl = document.getElementById('loginStatus');
if (!user || !pass) {
statusEl.textContent = 'Введите логин и пароль';
statusEl.style.color = 'red';
return;
}
statusEl.textContent = 'Проверяем подключение...';
statusEl.style.color = '';
try {
const res = await api('/api/login', 'POST', { user, pass });
if (res.ok) {
statusEl.textContent = '✓ Авторизация успешна';
statusEl.style.color = 'green';
await loadTree();
} else {
statusEl.textContent = 'Ошибка: ' + (res.error || 'Неизвестная ошибка');
statusEl.style.color = 'red';
}
} catch (e) {
console.error('Login ошибка:', e);
statusEl.textContent = 'Ошибка подключения: ' + e.message;
statusEl.style.color = 'red';
}
});
// loadTree
async function loadTree() {
const treeEl = document.getElementById('tree');
treeEl.style.color = '';
treeEl.innerHTML = 'Загрузка...';
try {
const tree = await api('/api/tree');
treeEl.innerHTML = '';
tree.forEach(schema => {
const schemaEl = document.createElement('div');
schemaEl.className = 'schema';
schemaEl.textContent = schema.name;
schemaEl.style.cursor = 'pointer';
schemaEl.style.padding = '4px';
schemaEl.style.fontWeight = 'bold';
treeEl.appendChild(schemaEl);
(schema.tables || []).forEach(tbl => {
const tableEl = document.createElement('div');
tableEl.className = 'table';
tableEl.textContent = ` ${tbl}`;
tableEl.style.cursor = 'pointer';
tableEl.style.paddingLeft = '12px';
tableEl.style.padding = '2px';
tableEl.addEventListener('click', () => selectTable(schema.name, tbl));
treeEl.appendChild(tableEl);
});
});
} catch (e) {
console.error('loadTree ошибка:', e);
treeEl.innerHTML = 'Ошибка загрузки: ' + e.message;
treeEl.style.color = 'red';
}
}
let lastEditedRow = null;
// Получение уникального ключа строки на основе PK
function getRowKey(rowData) {
if (!currentMeta || !currentMeta.primaryKey || currentMeta.primaryKey.length === 0) {
// Если нет PK, используем все поля
return JSON.stringify(rowData);
}
const pkValues = currentMeta.primaryKey.map(pk => rowData[pk]).join('|');
return pkValues;
}
async function selectTable(schema, tableName) {
currentSchema = schema;
currentTable = tableName;
lastEditedRow = null;
selectedRowsData.clear(); // Очищаем выделение при смене таблицы
updateSelectionCounter();
if (enterHandler) {
document.removeEventListener('keydown', enterHandler);
enterHandler = null;
}
currentMeta = await api(
`/api/table/meta?schema=${encodeURIComponent(schema)}&table=${encodeURIComponent(tableName)}`
);
// Добавляем столбец с чекбоксами в начало
const columns = [
{
formatter: "rowSelection",
titleFormatter: "rowSelection",
hozAlign: "center",
headerSort: false,
width: 40,
cellClick: function(e, cell) {
cell.getRow().toggleSelect();
}
},
...currentMeta.columns.map(col => ({
title: col.COLUMN_NAME,
field: col.COLUMN_NAME,
editor: "input",
headerFilter: "input"
}))
];
if (table) {
table.destroy();
table = null;
}
table = new Tabulator("#table", {
selectableRows: true,
columns: columns,
layout: "fitColumns",
resizableColumnFit: true,
pagination: true,
paginationMode: "remote",
paginationSize: 50,
paginationSizeSelector: [25, 50, 100, 200],
filterMode: "remote",
sortMode: "remote",
ajaxURL: "/api/table/data",
ajaxConfig: "POST",
ajaxContentType: "json",
ajaxParams: function () {
const headerFilters = this.getHeaderFilters ? this.getHeaderFilters() : [];
const filters = (headerFilters || []).map(f => ({
field: f.field,
value: f.value
})).filter(f => f.value !== null && f.value !== '');
const sorters = this.getSorters ? this.getSorters() : [];
const sort = (sorters && sorters.length > 0) ? {
field: sorters[0].field,
dir: sorters[0].dir
} : null;
return {
schema: currentSchema,
table: currentTable,
filters: filters,
sort: sort,
columns: currentMeta.columns,
page: this.getPage ? this.getPage() : 1,
pageSize: this.getPageSize ? this.getPageSize() : 50
};
},
ajaxResponse: function (url, params, response) {
return {
last_page: response.last_page || 1,
data: response.data || []
};
},
// Обработка выделения строк
rowSelectionChanged: function(data, rows) {
// Добавляем/удаляем строки из глобального хранилища
rows.forEach(row => {
const rowData = row.getData();
const key = getRowKey(rowData);
if (row.isSelected()) {
selectedRowsData.set(key, rowData);
} else {
selectedRowsData.delete(key);
}
});
updateSelectionCounter();
},
// После загрузки данных восстанавливаем выделение
dataLoaded: function(data) {
if (selectedRowsData.size > 0) {
const rows = this.getRows();
rows.forEach(row => {
const rowData = row.getData();
const key = getRowKey(rowData);
if (selectedRowsData.has(key)) {
row.select();
}
});
}
},
cellEdited: function(cell) {
const row = cell.getRow();
lastEditedRow = row.getData();
row.getElement().style.backgroundColor = '#fffae6';
},
headerFilterLiveFilterDelay: 800
});
// Сохранение по Enter
enterHandler = async function(e) {
if (e.key === 'Enter' && lastEditedRow && currentSchema && currentTable) {
e.preventDefault();
try {
const res = await api('/api/table/update', 'POST', {
schema: currentSchema,
table: currentTable,
row: lastEditedRow
});
lastEditedRow = null;
table.getRows().forEach(r => {
r.getElement().style.backgroundColor = '';
});
await table.replaceData();
} catch (err) {
console.error('Ошибка сохранения:', err);
alert('Ошибка: ' + err.message);
}
}
};
document.addEventListener('keydown', enterHandler);
}
// ✅ ВЫДЕЛИТЬ ВСЕ НА ТЕКУЩЕЙ СТРАНИЦЕ
document.getElementById('btnSelectPage').addEventListener('click', () => {
if (!table) return;
const rows = table.getRows();
rows.forEach(row => row.select());
});
// ✅ ВЫДЕЛИТЬ ВСЕ (НА ВСЕХ СТРАНИЦАХ)
document.getElementById('btnSelectAll').addEventListener('click', async () => {
if (!currentSchema || !currentTable || !table) {
alert('Сначала выберите таблицу');
return;
}
try {
// Получаем текущие фильтры
const headerFilters = table.getHeaderFilters ? table.getHeaderFilters() : [];
const filters = (headerFilters || []).map(f => ({ field: f.field, value: f.value }));
const sorters = table.getSorters ? table.getSorters() : [];
const sort = (sorters && sorters.length > 0) ? { field: sorters[0].field, dir: sorters[0].dir } : null;
// Получаем все данные с сервера (с учетом фильтров, но без пагинации)
const result = await api('/api/table/data', 'POST', {
schema: currentSchema,
table: currentTable,
filters: filters,
sort: sort,
columns: currentMeta.columns,
page: 1,
pageSize: 999999 // Получаем все записи
});
const totalRows = result.total;
// Предупреждение для больших таблиц
if (totalRows > 1000) {
const proceed = confirm(
`Вы собираетесь выделить ${totalRows} записей.\n` +
`Это может занять некоторое время и использовать много памяти.\n\n` +
`Продолжить?`
);
if (!proceed) return;
}
// Добавляем все строки в selectedRowsData
selectedRowsData.clear();
result.data.forEach(rowData => {
const key = getRowKey(rowData);
selectedRowsData.set(key, rowData);
});
// Выделяем видимые строки на текущей странице
const rows = table.getRows();
rows.forEach(row => {
const rowData = row.getData();
const key = getRowKey(rowData);
if (selectedRowsData.has(key)) {
row.select();
}
});
updateSelectionCounter();
alert(`Выделено записей: ${totalRows}`);
} catch (err) {
console.error('Ошибка выделения:', err);
alert('Ошибка выделения: ' + err.message);
}
});
// ✅ СНЯТЬ ВЫДЕЛЕНИЕ
document.getElementById('btnDeselectAll').addEventListener('click', () => {
if (!table) return;
selectedRowsData.clear();
table.deselectRow();
updateSelectionCounter();
});
// ✅ ВСТАВИТЬ
document.getElementById('btnInsert').addEventListener('click', async () => {
if (!currentSchema || !currentTable || !currentMeta) {
alert('Сначала выберите таблицу');
return;
}
const rowData = {};
const fkFields = [];
currentMeta.columns.forEach(col => {
const name = col.COLUMN_NAME;
if (col.IS_AUTO_INCREMENT) return;
if (col.HAS_DEFAULT && col.COLUMN_DEFAULT !== null) {
if (String(col.COLUMN_DEFAULT).toUpperCase().includes('CURRENT_TIMESTAMP')) return;
rowData[name] = col.COLUMN_DEFAULT;
return;
}
if (col.IS_FOREIGN_KEY) {
fkFields.push({
name: name,
ref_schema: col.FOREIGN_KEY.ref_schema,
ref_table: col.FOREIGN_KEY.ref_table,
ref_column: col.FOREIGN_KEY.ref_column,
required: col.IS_REQUIRED
});
if (!col.IS_REQUIRED) {
rowData[name] = null;
}
return;
}
if (col.IS_REQUIRED) {
if (col.EDITOR_TYPE === 'number') rowData[name] = 0;
else if (col.EDITOR_TYPE === 'datetime' || col.EDITOR_TYPE === 'date')
rowData[name] = new Date().toISOString().slice(0, 10);
else if (col.EDITOR_TYPE === 'time') rowData[name] = '00:00:00';
else rowData[name] = '';
}
});
const requiredFKs = fkFields.filter(f => f.required);
if (requiredFKs.length > 0) {
try {
const fkValues = await promptForForeignKeys(requiredFKs);
if (!fkValues) {
return;
}
Object.assign(rowData, fkValues);
} catch (err) {
console.error('Ошибка получения FK значений:', err);
alert('Ошибка: ' + err.message);
return;
}
}
try {
await api('/api/table/insert', 'POST', {
schema: currentSchema,
table: currentTable,
row: rowData
});
const selected = table.getSelectedRows();
await table.replaceData();
if (selected.length === 0) {
const lastPage = table.getPageMax();
if (lastPage > 1) {
table.setPage(lastPage);
}
}
alert('✓ Строка успешно создана');
} catch (e) {
console.error('Ошибка вставки:', e);
alert('Ошибка вставки: ' + e.message);
}
});
// Функция для выбора FK значений
async function promptForForeignKeys(fkFields) {
const modal = document.createElement('div');
modal.className = 'fk-modal';
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
`;
const dialog = document.createElement('div');
dialog.style.cssText = `
background: white;
padding: 20px;
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
`;
let html = '<h3>Заполните обязательные поля</h3>';
const fkOptions = {};
for (const fk of fkFields) {
try {
const result = await api(
`/api/fk-values?schema=${encodeURIComponent(fk.ref_schema)}&` +
`table=${encodeURIComponent(fk.ref_table)}&` +
`column=${encodeURIComponent(fk.ref_column)}`
);
fkOptions[fk.name] = result.values || [];
} catch (err) {
console.error(`Ошибка загрузки FK для ${fk.name}:`, err);
fkOptions[fk.name] = [];
}
}
fkFields.forEach(fk => {
const options = fkOptions[fk.name];
html += `
<div style="margin: 15px 0;">
<label style="display: block; margin-bottom: 5px; font-weight: bold;">
${fk.name}
<span style="color: red;">*</span>
<small style="color: #666; font-weight: normal;">
(→ ${fk.ref_table}.${fk.ref_column})
</small>
</label>
`;
if (options.length > 0) {
html += `<select id="fk_${fk.name}" style="width: 100%; padding: 8px; font-size: 14px;">`;
html += '<option value="">-- Выберите значение --</option>';
options.forEach(val => {
html += `<option value="${escapeHtml(val)}">${escapeHtml(val)}</option>`;
});
html += '</select>';
} else {
html += `
<input type="text" id="fk_${fk.name}"
style="width: 100%; padding: 8px; font-size: 14px; box-sizing: border-box;"
placeholder="Введите значение">
<small style="color: #999;">⚠️ Нет доступных значений в ${fk.ref_table}</small>
`;
}
html += '</div>';
});
html += `
<div style="margin-top: 20px; display: flex; gap: 10px; justify-content: flex-end;">
<button id="fkCancel" style="padding: 10px 20px; cursor: pointer; background: #ddd; border: none; border-radius: 4px;">
Отмена
</button>
<button id="fkSubmit" style="padding: 10px 20px; cursor: pointer; background: #4CAF50; color: white; border: none; border-radius: 4px;">
Создать строку
</button>
</div>
`;
dialog.innerHTML = html;
modal.appendChild(dialog);
document.body.appendChild(modal);
return new Promise((resolve) => {
document.getElementById('fkCancel').addEventListener('click', () => {
document.body.removeChild(modal);
resolve(null);
});
document.getElementById('fkSubmit').addEventListener('click', () => {
const values = {};
let allFilled = true;
for (const fk of fkFields) {
const input = document.getElementById(`fk_${fk.name}`);
const value = input.value.trim();
if (!value) {
alert(`Поле "${fk.name}" обязательно для заполнения!`);
allFilled = false;
break;
}
values[fk.name] = value;
}
if (allFilled) {
document.body.removeChild(modal);
resolve(values);
}
});
});
}
// ✅ УДАЛИТЬ (оптимизированное с batch delete)
document.getElementById('btnDelete').addEventListener('click', async () => {
if (!table || !currentSchema || !currentTable) return;
const count = selectedRowsData.size;
if (count === 0) {
alert('Выберите строки для удаления');
return;
}
const confirmMsg = count === 1
? 'Удалить выбранную строку?'
: `Удалить ${count} выбранных строк?`;
if (!confirm(confirmMsg)) return;
// Создаем модальное окно с прогресс-баром
const modal = createProgressModal('Удаление записей...');
document.body.appendChild(modal);
try {
// Преобразуем Map в массив
const rowsArray = Array.from(selectedRowsData.values());
// Используем batch delete для оптимизации
const result = await api('/api/table/delete-batch', 'POST', {
schema: currentSchema,
table: currentTable,
rows: rowsArray
});
document.body.removeChild(modal);
selectedRowsData.clear();
updateSelectionCounter();
await table.replaceData();
if (result.errors > 0) {
const errorsToShow = result.errorMessages.slice(0, 10);
const moreErrors = result.errorMessages.length > 10
? `\n... и еще ${result.errorMessages.length - 10} ошибок`
: '';
alert(`Удалено строк: ${result.deleted}\nОшибок: ${result.errors}\n\nПервые ошибки:\n${errorsToShow.join('\n')}${moreErrors}`);
} else {
alert(`✓ Удалено строк: ${result.deleted}`);
}
} catch (e) {
document.body.removeChild(modal);
console.error(e);
alert('Ошибка удаления: ' + e.message);
}
});
// ========== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ CSV ==========
function detectDelimiter(text) {
const firstLine = text.split('\n')[0];
const semicolonCount = (firstLine.match(/;/g) || []).length;
const commaCount = (firstLine.match(/,/g) || []).length;
if (semicolonCount > 0 && semicolonCount >= commaCount) {
return ';';
}
return ',';
}
function parseCSV(text) {
const delimiter = detectDelimiter(text);
const lines = [];
let currentLine = [];
let currentField = '';
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const char = text[i];
const nextChar = text[i + 1];
if (char === '"') {
if (inQuotes && nextChar === '"') {
currentField += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (char === delimiter && !inQuotes) {
currentLine.push(currentField.trim());
currentField = '';
} else if ((char === '\n' || char === '\r') && !inQuotes) {
if (char === '\r' && nextChar === '\n') {
i++;
}
if (currentField || currentLine.length > 0) {
currentLine.push(currentField.trim());
lines.push(currentLine);
currentLine = [];
currentField = '';
}
} else {
currentField += char;
}
}
if (currentField || currentLine.length > 0) {
currentLine.push(currentField.trim());
lines.push(currentLine);
}
return lines;
}
// ========== ИМПОРТ CSV ==========
document.getElementById('btnImportCSV').addEventListener('click', () => {
if (!currentSchema || !currentTable) {
alert('Сначала выберите таблицу');
return;
}
document.getElementById('csvFileInput').click();
});
document.getElementById('csvFileInput').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const rows = parseCSV(text);
if (rows.length === 0) {
alert('CSV файл пуст');
return;
}
const headers = rows[0];
const dataRows = rows.slice(1);
if (dataRows.length === 0) {
alert('Нет данных для импорта (только заголовки)');
return;
}
if (dataRows.length > 100) {
const proceed = confirm(`Файл содержит ${dataRows.length} строк. Импорт может занять некоторое время. Продолжить?`);
if (!proceed) {
e.target.value = '';
return;
}
}
const records = dataRows.map(row => {
const obj = {};
headers.forEach((header, i) => {
obj[header] = row[i] || null;
});
return obj;
});
const result = await api('/api/table/import-csv', 'POST', {
schema: currentSchema,
table: currentTable,
rows: records
});
if (result.errorMessages && result.errorMessages.length > 0) {
const errorsToShow = result.errorMessages.slice(0, 10);
const moreErrors = result.errorMessages.length > 10 ? `\n... и еще ${result.errorMessages.length - 10} ошибок` : '';
alert(`Импортировано строк: ${result.inserted}\nОшибок: ${result.errors}\n\nПервые ошибки:\n${errorsToShow.join('\n')}${moreErrors}`);
} else {
alert(`✓ Импортировано строк: ${result.inserted}\nОшибок: ${result.errors}`);
}
await table.replaceData();
e.target.value = '';
} catch (err) {
console.error('Ошибка импорта:', err);
alert('Ошибка импорта: ' + err.message);
e.target.value = '';
}
});
// ========== ЭКСПОРТ CSV ==========
document.getElementById('btnExportCSV').addEventListener('click', async () => {
if (!currentSchema || !currentTable || !table) {
alert('Сначала выберите таблицу');
return;
}
try {
const headerFilters = table.getHeaderFilters ? table.getHeaderFilters() : [];
const filters = (headerFilters || []).map(f => ({ field: f.field, value: f.value }));
const sorters = table.getSorters ? table.getSorters() : [];
const sort = (sorters && sorters.length > 0) ? { field: sorters[0].field, dir: sorters[0].dir } : null;
const result = await api('/api/table/export-csv', 'POST', {
schema: currentSchema,
table: currentTable,
filters: filters,
sort: sort,
columns: currentMeta.columns
});
const csv = result.csv;
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `${currentTable}_${new Date().toISOString().slice(0,10)}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (err) {
console.error('Ошибка экспорта:', err);
alert('Ошибка экспорта: ' + err.message);
}
});