Files
turborfq/public/app.js
Michael Chus 23c9c04a87 perf: оптимизировано массовое удаление строк
- Добавлен batch delete метод на бэкенде (удаление множества строк за один запрос)
- Использование WHERE IN для удаления нескольких строк одним SQL запросом
- Добавлен прогресс-бар при удалении большого количества строк
- Удаление 1000 строк теперь занимает секунды вместо минут
- Добавлена поддержка транзакций для атомарности операций
- Оптимизирован размер батчей для баланса производительности и надежности
2026-01-21 04:17:26 +03:00

852 lines
26 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';
}
}
// Логин
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);
}
});
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ✅ УДАЛИТЬ (с реальным прогрессом для больших объемов)
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 rowsArray = Array.from(selectedRowsData.values());
// Для очень больших удалений - показываем прогресс
const BATCH_SIZE = 1000; // Удаляем по 1000 строк за раз
const batches = [];
for (let i = 0; i < rowsArray.length; i += BATCH_SIZE) {
batches.push(rowsArray.slice(i, i + BATCH_SIZE));
}
if (batches.length > 1) {
// Множество батчей - показываем реальный прогресс
const modal = createProgressModal(`Удаление ${count} записей...`);
document.body.appendChild(modal);
const progressBar = modal.querySelector('.progress-bar');
const progressText = modal.querySelector('.spinner').parentElement;
let totalDeleted = 0;
let totalErrors = 0;
let allErrorMessages = [];
try {
for (let i = 0; i < batches.length; i++) {
const batch = batches[i];
progressText.innerHTML = `<span class="spinner" style="display: inline-block; animation: spin 1s linear infinite;">⏳</span> Обработка ${i + 1} из ${batches.length} батчей...`;
const result = await api('/api/table/delete-batch', 'POST', {
schema: currentSchema,
table: currentTable,
rows: batch
});
totalDeleted += result.deleted;
totalErrors += result.errors;
if (result.errorMessages) {
allErrorMessages.push(...result.errorMessages);
}
// Обновляем прогресс
const progress = ((i + 1) / batches.length) * 100;
progressBar.style.width = progress + '%';
}
document.body.removeChild(modal);
selectedRowsData.clear();
updateSelectionCounter();
await table.replaceData();
if (totalErrors > 0) {
const errorsToShow = allErrorMessages.slice(0, 10);
const moreErrors = allErrorMessages.length > 10
? `\n... и еще ${allErrorMessages.length - 10} ошибок`
: '';
alert(`Удалено строк: ${totalDeleted}\nОшибок: ${totalErrors}\n\nПервые ошибки:\n${errorsToShow.join('\n')}${moreErrors}`);
} else {
alert(`✓ Успешно удалено строк: ${totalDeleted}`);
}
} catch (e) {
document.body.removeChild(modal);
console.error(e);
alert('Ошибка удаления: ' + e.message);
}
} else {
// Один батч - используем простое модальное окно
const modal = createProgressModal('Удаление записей...');
document.body.appendChild(modal);
try {
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);
}
});