Files
turborfq/public/app.js
Michael Chus 722fe6b5ea fix: исправлено выделение строк через header checkbox и их удаление
- Упрощен обработчик rowSelectionChanged (используется только параметр rows)
- Добавлена отладочная информация для диагностики проблем
- Исправлена логика синхронизации selectedRowsData
- Добавлен console.log для отслеживания процесса выделения
- Проверка работы getRowKey для корректной идентификации строк
2026-01-21 04:35:23 +03:00

930 lines
28 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, используем все поля
const key = JSON.stringify(rowData);
console.log(' 🔑 getRowKey (no PK):', key.substring(0, 100));
return key;
}
const pkValues = currentMeta.primaryKey.map(pk => {
const value = rowData[pk];
if (value === undefined || value === null) {
console.warn(` ⚠️ PK field '${pk}' is undefined/null in rowData:`, rowData);
}
return value;
}).join('|');
console.log(' 🔑 getRowKey (PK):', pkValues, 'from fields:', currentMeta.primaryKey);
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) {
console.log('🔔 rowSelectionChanged:', {
dataLength: data.length,
rowsLength: rows.length,
selectedRowsDataSizeBefore: selectedRowsData.size
});
// Обрабатываем только изменившиеся строки
rows.forEach(row => {
const rowData = row.getData();
const key = getRowKey(rowData);
const isSelected = row.isSelected();
console.log(` Row key: ${key}, isSelected: ${isSelected}`);
if (isSelected) {
selectedRowsData.set(key, rowData);
} else {
selectedRowsData.delete(key);
}
});
console.log(' ✅ selectedRowsData size after:', selectedRowsData.size);
updateSelectionCounter();
},
// После загрузки данных восстанавливаем выделение
dataLoaded: function(data) {
console.log('📄 dataLoaded, восстанавливаем выделение для', selectedRowsData.size, 'строк');
if (selectedRowsData.size > 0) {
const rows = this.getRows();
let restoredCount = 0;
rows.forEach(row => {
const rowData = row.getData();
const key = getRowKey(rowData);
if (selectedRowsData.has(key)) {
row.select();
restoredCount++;
}
});
console.log(' ✅ Восстановлено выделение для', restoredCount, 'строк на странице');
}
},
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('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) {
alert('Сначала выберите таблицу');
return;
}
const count = selectedRowsData.size;
console.log('🗑️ Удаление:', {
selectedRowsDataSize: count,
selectedRowsDataKeys: Array.from(selectedRowsData.keys())
});
if (count === 0) {
// Дополнительная проверка - может быть строки выделены в Tabulator, но не в selectedRowsData?
const tabulatorSelected = table.getSelectedData();
console.log('Tabulator selected rows:', tabulatorSelected.length);
if (tabulatorSelected.length > 0) {
console.error('❌ Несоответствие: Tabulator имеет выделенные строки, но selectedRowsData пуст!');
console.log('Попытка синхронизации...');
// Синхронизируем
selectedRowsData.clear();
tabulatorSelected.forEach(rowData => {
const key = getRowKey(rowData);
selectedRowsData.set(key, rowData);
});
updateSelectionCounter();
if (selectedRowsData.size > 0) {
alert(`Обнаружено ${selectedRowsData.size} выделенных строк. Попробуйте удалить снова.`);
return;
}
}
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());
console.log('Отправка на удаление:', rowsArray.length, 'строк');
// Используем batch delete для оптимизации
const result = await api('/api/table/delete-batch', 'POST', {
schema: currentSchema,
table: currentTable,
rows: rowsArray
});
console.log('Результат удаления:', result);
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) {
// Показываем только первые 10 ошибок
const errorsToShow = result.errorMessages.slice(0, 10);
const moreErrors = result.errorMessages.length > 10
? `\n... и еще ${result.errorMessages.length - 10} ошибок`
: '';
// Создаем детальное сообщение
let errorDetails = `Импортировано строк: ${result.inserted}\nОшибок: ${result.errors}\n\n`;
errorDetails += 'ПЕРВЫЕ ОШИБКИ:\n';
errorDetails += '═══════════════════════════════════════\n';
errorDetails += errorsToShow.join('\n\n');
errorDetails += moreErrors;
// Выводим в alert (для больших сообщений можно использовать модальное окно)
alert(errorDetails);
// Также выводим в консоль для детального анализа
console.group('📋 Детали импорта CSV');
console.log(`✅ Импортировано: ${result.inserted}`);
console.log(`❌ Ошибок: ${result.errors}`);
console.log('\nВсе ошибки:');
result.errorMessages.forEach((msg, idx) => {
console.log(`${idx + 1}. ${msg}`);
});
console.groupEnd();
} 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);
}
});