- Добавлен batch delete метод на бэкенде (удаление множества строк за один запрос) - Использование WHERE IN для удаления нескольких строк одним SQL запросом - Добавлен прогресс-бар при удалении большого количества строк - Удаление 1000 строк теперь занимает секунды вместо минут - Добавлена поддержка транзакций для атомарности операций - Оптимизирован размер батчей для баланса производительности и надежности
852 lines
26 KiB
JavaScript
852 lines
26 KiB
JavaScript
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);
|
||
}
|
||
}); |