refactor: переход на чекбоксы для выбора строк и упрощение интерфейса

- Добавлены чекбоксы для множественного выбора строк
- Убрана кнопка "Сохранить строку" (сохранение по Enter)
- Изменена логика кнопки "Вставить": добавляет строку над выбранной или внизу
- Убрана подсказка про Ctrl+Click
- Упрощен код выбора строк (rowClick теперь не нужен)
- Очищено визуальное оформление
This commit is contained in:
2026-01-21 04:02:18 +03:00
parent d198ea8891
commit 2150792d20
2 changed files with 91 additions and 125 deletions

View File

@@ -110,12 +110,25 @@ async function selectTable(schema, tableName) {
`/api/table/meta?schema=${encodeURIComponent(schema)}&table=${encodeURIComponent(tableName)}` `/api/table/meta?schema=${encodeURIComponent(schema)}&table=${encodeURIComponent(tableName)}`
); );
const columns = currentMeta.columns.map(col => ({ // ✅ Добавляем столбец с чекбоксами в начало
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, title: col.COLUMN_NAME,
field: col.COLUMN_NAME, field: col.COLUMN_NAME,
editor: "input", editor: "input",
headerFilter: "input" headerFilter: "input"
})); }))
];
if (table) { if (table) {
table.destroy(); table.destroy();
@@ -123,7 +136,7 @@ async function selectTable(schema, tableName) {
} }
table = new Tabulator("#table", { table = new Tabulator("#table", {
selectableRows: true, // ✅ Множественное выделение (вместо selectableRows: 1) selectableRows: true,
columns: columns, columns: columns,
layout: "fitColumns", layout: "fitColumns",
resizableColumnFit: true, resizableColumnFit: true,
@@ -187,17 +200,6 @@ async function selectTable(schema, tableName) {
}; };
}, },
rowClick: function(e, row) {
// ✅ Ctrl/Cmd + Click для множественного выбора
if (e.ctrlKey || e.metaKey) {
row.toggleSelect();
} else {
// Обычный клик - снимаем выделение с других и выделяем текущую
table.deselectRow();
row.toggleSelect();
}
},
cellEdited: function(cell) { cellEdited: function(cell) {
const row = cell.getRow(); const row = cell.getRow();
lastEditedRow = row.getData(); lastEditedRow = row.getData();
@@ -222,6 +224,7 @@ async function selectTable(schema, tableName) {
} }
}); });
// ✅ Сохранение по Enter
enterHandler = async function(e) { enterHandler = async function(e) {
if (e.key === 'Enter' && lastEditedRow && currentSchema && currentTable) { if (e.key === 'Enter' && lastEditedRow && currentSchema && currentTable) {
e.preventDefault(); e.preventDefault();
@@ -252,10 +255,7 @@ async function selectTable(schema, tableName) {
document.addEventListener('keydown', enterHandler); document.addEventListener('keydown', enterHandler);
} }
// ✅ ВСТАВИТЬ - добавляет строку НАД выбранной или внизу
// CRUD кнопки
document.getElementById('btnInsert').addEventListener('click', async () => { document.getElementById('btnInsert').addEventListener('click', async () => {
if (!currentSchema || !currentTable || !currentMeta) { if (!currentSchema || !currentTable || !currentMeta) {
alert('Сначала выберите таблицу'); alert('Сначала выберите таблицу');
@@ -300,17 +300,13 @@ document.getElementById('btnInsert').addEventListener('click', async () => {
} }
}); });
// ✅ Если есть обязательные FK - показываем модальное окно для выбора
const requiredFKs = fkFields.filter(f => f.required); const requiredFKs = fkFields.filter(f => f.required);
if (requiredFKs.length > 0) { if (requiredFKs.length > 0) {
try { try {
const fkValues = await promptForForeignKeys(requiredFKs); const fkValues = await promptForForeignKeys(requiredFKs);
if (!fkValues) { if (!fkValues) {
// Пользователь отменил
return; return;
} }
// Добавляем выбранные FK значения
Object.assign(rowData, fkValues); Object.assign(rowData, fkValues);
} catch (err) { } catch (err) {
console.error('Ошибка получения FK значений:', err); console.error('Ошибка получения FK значений:', err);
@@ -320,12 +316,44 @@ document.getElementById('btnInsert').addEventListener('click', async () => {
} }
try { try {
await api('/api/table/insert', 'POST', { const result = await api('/api/table/insert', 'POST', {
schema: currentSchema, schema: currentSchema,
table: currentTable, table: currentTable,
row: rowData row: rowData
}); });
// ✅ Получаем выбранные строки
const selected = table.getSelectedRows();
if (selected.length > 0) {
// Вставляем НАД первой выбранной строкой
const targetRow = selected[0];
const position = targetRow.getPosition();
console.log(`Вставка НАД строкой на позиции ${position}`);
// Перезагружаем данные и перемещаемся на нужную страницу
await table.replaceData(); await table.replaceData();
// Если нужно, можно добавить скролл к новой строке
setTimeout(() => {
const rows = table.getRows();
if (rows[position]) {
rows[position].getElement().scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
} else {
// Вставляем внизу (в конец таблицы)
console.log('Вставка внизу таблицы');
await table.replaceData();
// Переходим на последнюю страницу
const lastPage = table.getPageMax();
if (lastPage > 1) {
table.setPage(lastPage);
}
}
alert('✓ Строка успешно создана'); alert('✓ Строка успешно создана');
} catch (e) { } catch (e) {
console.error('Ошибка вставки:', e); console.error('Ошибка вставки:', e);
@@ -333,10 +361,10 @@ document.getElementById('btnInsert').addEventListener('click', async () => {
} }
}); });
// Функция для выбора FK значений // Функция для выбора FK значений
async function promptForForeignKeys(fkFields) { async function promptForForeignKeys(fkFields) {
// Создаём модальное окно
const modal = document.createElement('div'); const modal = document.createElement('div');
modal.className = 'fk-modal';
modal.style.cssText = ` modal.style.cssText = `
position: fixed; position: fixed;
top: 0; top: 0;
@@ -363,7 +391,6 @@ async function promptForForeignKeys(fkFields) {
let html = '<h3>Заполните обязательные поля</h3>'; let html = '<h3>Заполните обязательные поля</h3>';
// Загружаем доступные значения для каждого FK
const fkOptions = {}; const fkOptions = {};
for (const fk of fkFields) { for (const fk of fkFields) {
try { try {
@@ -379,7 +406,6 @@ async function promptForForeignKeys(fkFields) {
} }
} }
// Создаём поля для каждого FK
fkFields.forEach(fk => { fkFields.forEach(fk => {
const options = fkOptions[fk.name]; const options = fkOptions[fk.name];
html += ` html += `
@@ -394,7 +420,6 @@ async function promptForForeignKeys(fkFields) {
`; `;
if (options.length > 0) { if (options.length > 0) {
// Если есть значения - показываем select
html += `<select id="fk_${fk.name}" style="width: 100%; padding: 8px; font-size: 14px;">`; html += `<select id="fk_${fk.name}" style="width: 100%; padding: 8px; font-size: 14px;">`;
html += '<option value="">-- Выберите значение --</option>'; html += '<option value="">-- Выберите значение --</option>';
options.forEach(val => { options.forEach(val => {
@@ -402,7 +427,6 @@ async function promptForForeignKeys(fkFields) {
}); });
html += '</select>'; html += '</select>';
} else { } else {
// Если нет значений - показываем input
html += ` html += `
<input type="text" id="fk_${fk.name}" <input type="text" id="fk_${fk.name}"
style="width: 100%; padding: 8px; font-size: 14px; box-sizing: border-box;" style="width: 100%; padding: 8px; font-size: 14px; box-sizing: border-box;"
@@ -429,7 +453,6 @@ async function promptForForeignKeys(fkFields) {
modal.appendChild(dialog); modal.appendChild(dialog);
document.body.appendChild(modal); document.body.appendChild(modal);
// Обработка кнопок
return new Promise((resolve) => { return new Promise((resolve) => {
document.getElementById('fkCancel').addEventListener('click', () => { document.getElementById('fkCancel').addEventListener('click', () => {
document.body.removeChild(modal); document.body.removeChild(modal);
@@ -461,41 +484,18 @@ async function promptForForeignKeys(fkFields) {
}); });
} }
// Вспомогательная функция для экранирования HTML
function escapeHtml(text) { function escapeHtml(text) {
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
} }
// ✅ УДАЛИТЬ
document.getElementById('btnUpdate').addEventListener('click', async () => {
if (!table) return;
let selected = table.getSelectedData();
let rowToSave = selected.length === 1 ? selected[0] : lastEditedRow;
if (!rowToSave) {
alert('Кликните по строке или отредактируйте ячейку');
return;
}
try {
const res = await api('/api/table/update', 'POST', {
schema: currentSchema, table: currentTable, row: rowToSave
});
lastEditedRow = null;
await table.replaceData();
alert(`✓ Обновлено строк: ${res.updated}`);
} catch (e) {
console.error(e);
alert('Ошибка обновления: ' + e.message);
}
});
document.getElementById('btnDelete').addEventListener('click', async () => { document.getElementById('btnDelete').addEventListener('click', async () => {
if (!table || !currentSchema || !currentTable) return; if (!table || !currentSchema || !currentTable) return;
const selected = table.getSelectedData(); const selected = table.getSelectedData();
if (selected.length === 0) { if (selected.length === 0) {
alert('Выберите строки для удаления (используйте Ctrl+Click для выбора нескольких)'); alert('Выберите строки для удаления');
return; return;
} }
@@ -519,33 +519,20 @@ document.getElementById('btnDelete').addEventListener('click', async () => {
} }
}); });
// ✅ Снятие выделения
document.getElementById('btnDeselectAll').addEventListener('click', () => {
if (table) {
table.deselectRow();
console.log('Выделение снято');
}
});
// ========== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ CSV ========== // ========== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ CSV ==========
// Функция автоопределения разделителя
function detectDelimiter(text) { function detectDelimiter(text) {
const firstLine = text.split('\n')[0]; const firstLine = text.split('\n')[0];
const semicolonCount = (firstLine.match(/;/g) || []).length; const semicolonCount = (firstLine.match(/;/g) || []).length;
const commaCount = (firstLine.match(/,/g) || []).length; const commaCount = (firstLine.match(/,/g) || []).length;
// Если есть точки с запятой и их больше, чем запятых, используем ;
if (semicolonCount > 0 && semicolonCount >= commaCount) { if (semicolonCount > 0 && semicolonCount >= commaCount) {
return ';'; return ';';
} }
return ','; return ',';
} }
// Улучшенный парсер CSV с поддержкой ; и , разделителей
function parseCSV(text) { function parseCSV(text) {
// Автоопределение разделителя
const delimiter = detectDelimiter(text); const delimiter = detectDelimiter(text);
console.log('Обнаружен разделитель CSV:', delimiter); console.log('Обнаружен разделитель CSV:', delimiter);
@@ -561,7 +548,7 @@ function parseCSV(text) {
if (char === '"') { if (char === '"') {
if (inQuotes && nextChar === '"') { if (inQuotes && nextChar === '"') {
currentField += '"'; currentField += '"';
i++; // пропускаем следующую кавычку i++;
} else { } else {
inQuotes = !inQuotes; inQuotes = !inQuotes;
} }
@@ -570,7 +557,7 @@ function parseCSV(text) {
currentField = ''; currentField = '';
} else if ((char === '\n' || char === '\r') && !inQuotes) { } else if ((char === '\n' || char === '\r') && !inQuotes) {
if (char === '\r' && nextChar === '\n') { if (char === '\r' && nextChar === '\n') {
i++; // пропускаем \n после \r i++;
} }
if (currentField || currentLine.length > 0) { if (currentField || currentLine.length > 0) {
currentLine.push(currentField.trim()); currentLine.push(currentField.trim());
@@ -583,7 +570,6 @@ function parseCSV(text) {
} }
} }
// Добавляем последнюю строку
if (currentField || currentLine.length > 0) { if (currentField || currentLine.length > 0) {
currentLine.push(currentField.trim()); currentLine.push(currentField.trim());
lines.push(currentLine); lines.push(currentLine);
@@ -611,11 +597,9 @@ document.getElementById('csvFileInput').addEventListener('change', async (e) =>
try { try {
const text = await file.text(); const text = await file.text();
console.log('Содержимое файла (первые 500 символов):\n', text.substring(0, 500)); console.log('Содержимое файла (первые 500 символов):\n', text.substring(0, 500));
console.log('Полное содержимое файла:\n', text);
const rows = parseCSV(text); const rows = parseCSV(text);
console.log('Всего строк после парсинга:', rows.length); console.log('Всего строк после парсинга:', rows.length);
console.log('Все распарсенные строки:', rows);
if (rows.length === 0) { if (rows.length === 0) {
alert('CSV файл пуст'); alert('CSV файл пуст');
@@ -633,26 +617,16 @@ document.getElementById('csvFileInput').addEventListener('change', async (e) =>
return; return;
} }
// Преобразуем в массив объектов
const records = dataRows.map((row, idx) => { const records = dataRows.map((row, idx) => {
const obj = {}; const obj = {};
headers.forEach((header, i) => { headers.forEach((header, i) => {
const value = row[i] || null; const value = row[i] || null;
obj[header] = value; obj[header] = value;
console.log(` Строка ${idx + 1}, столбец "${header}": "${value}"`);
}); });
return obj; return obj;
}); });
console.log('Финальные записи для отправки на сервер:', JSON.stringify(records, null, 2)); console.log('Финальные записи для отправки на сервер:', JSON.stringify(records, null, 2));
console.log('Количество записей:', records.length);
// Отправляем на сервер
console.log('Отправка запроса на сервер:', {
schema: currentSchema,
table: currentTable,
rowCount: records.length
});
const result = await api('/api/table/import-csv', 'POST', { const result = await api('/api/table/import-csv', 'POST', {
schema: currentSchema, schema: currentSchema,
@@ -670,19 +644,14 @@ document.getElementById('csvFileInput').addEventListener('change', async (e) =>
} }
await table.replaceData(); await table.replaceData();
// Очищаем input
e.target.value = ''; e.target.value = '';
} catch (err) { } catch (err) {
console.error('=== КРИТИЧЕСКАЯ ОШИБКА ИМПОРТА ==='); console.error('=== КРИТИЧЕСКАЯ ОШИБКА ИМПОРТА ===');
console.error('Тип ошибки:', err.name); console.error('Ошибка:', err);
console.error('Сообщение:', err.message);
console.error('Stack:', err.stack);
alert('Ошибка импорта: ' + err.message); alert('Ошибка импорта: ' + err.message);
} }
}); });
// ========== ЭКСПОРТ CSV ========== // ========== ЭКСПОРТ CSV ==========
document.getElementById('btnExportCSV').addEventListener('click', async () => { document.getElementById('btnExportCSV').addEventListener('click', async () => {
if (!currentSchema || !currentTable || !table) { if (!currentSchema || !currentTable || !table) {
@@ -691,7 +660,6 @@ document.getElementById('btnExportCSV').addEventListener('click', async () => {
} }
try { try {
// Получаем текущие фильтры и сортировку
const headerFilters = table.getHeaderFilters ? table.getHeaderFilters() : []; const headerFilters = table.getHeaderFilters ? table.getHeaderFilters() : [];
const filters = (headerFilters || []).map(f => ({ field: f.field, value: f.value })); const filters = (headerFilters || []).map(f => ({ field: f.field, value: f.value }));
const sorters = table.getSorters ? table.getSorters() : []; const sorters = table.getSorters ? table.getSorters() : [];
@@ -705,11 +673,8 @@ document.getElementById('btnExportCSV').addEventListener('click', async () => {
columns: currentMeta.columns columns: currentMeta.columns
}); });
// Создаем CSV текст
const csv = result.csv; const csv = result.csv;
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
// Скачиваем файл
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }); // BOM для Excel
const link = document.createElement('a'); const link = document.createElement('a');
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);

View File

@@ -52,7 +52,7 @@
} }
#csvFileInput { display: none; } #csvFileInput { display: none; }
/* Стили для Tabulator */ /* Стили для Tabulator */
.tabulator { .tabulator {
border: none; border: none;
background-color: white; background-color: white;
@@ -67,7 +67,7 @@
overflow-x: auto !important; overflow-x: auto !important;
} }
/* ✅ ИСПРАВЛЕНИЕ: Размер шрифта при редактировании ячеек */ /* Размер шрифта при редактировании ячеек */
.tabulator-cell input, .tabulator-cell input,
.tabulator-cell select, .tabulator-cell select,
.tabulator-cell textarea { .tabulator-cell textarea {
@@ -88,16 +88,16 @@
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2) !important; box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2) !important;
} }
/* Стили для выделенных строк */ /* Стили для выделенных строк */
.tabulator-row.tabulator-selected { .tabulator-row.tabulator-selected {
background-color: #d4e9ff !important; background-color: #e3f2fd !important;
} }
.tabulator-row.tabulator-selected:hover { .tabulator-row.tabulator-selected:hover {
background-color: #c0dcf5 !important; background-color: #bbdefb !important;
} }
/* Стили для модальных окон FK */ /* Стили для модальных окон FK */
.fk-modal { .fk-modal {
font-family: sans-serif; font-family: sans-serif;
} }
@@ -119,18 +119,21 @@
opacity: 0.9; opacity: 0.9;
} }
/* ✅ Подсказка для множественного выбора */ /* Кнопки toolbar */
#toolbar::after { #toolbar button {
content: "💡 Используйте Ctrl+Click для выбора нескольких строк"; padding: 6px 12px;
display: inline-block; margin-right: 4px;
margin-left: 20px; cursor: pointer;
font-size: 12px; border: 1px solid #ccc;
color: #666; background: #fff;
font-style: italic; border-radius: 3px;
}
#toolbar button:hover {
background: #f0f0f0;
} }
</style> </style>
</head> </head>
<body> <body>
<div id="sidebar"> <div id="sidebar">
@@ -145,13 +148,11 @@
<span id="loginStatus"></span> <span id="loginStatus"></span>
</div> </div>
<div id="toolbar"> <div id="toolbar">
<button id="btnInsert">Вставить</button> <button id="btnInsert"> Вставить</button>
<button id="btnUpdate">Сохранить строку</button> <button id="btnDelete">🗑️ Удалить</button>
<button id="btnDelete">Удалить</button> <button id="btnImportCSV">📥 Импорт CSV</button>
<button id="btnDeselectAll">Снять выделение</button> <!-- ✅ Новая кнопка -->
<button id="btnImportCSV">Импорт CSV</button>
<input type="file" id="csvFileInput" accept=".csv"> <input type="file" id="csvFileInput" accept=".csv">
<button id="btnExportCSV">Экспорт CSV</button> <button id="btnExportCSV">📤 Экспорт CSV</button>
</div> </div>
<div id="table"></div> <div id="table"></div>
</div> </div>