Files
turborfq/public/app.js
Michael Chus d198ea8891 feat: добавлено множественное выделение строк и исправлен размер шрифта при редактировании
- Включено множественное выделение строк (selectableRows: true)
- Исправлен размер шрифта в ячейках при редактировании (добавлены CSS стили для tabulator-cell input)
- Улучшено визуальное отображение редактируемых ячеек
- Обновлена логика удаления для работы с множественным выбором
2026-01-21 03:54:03 +03:00

729 lines
23 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;
// Простая обёртка для 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();
}
// Логин
document.getElementById('loginBtn').addEventListener('click', async () => {
console.log('=== LOGIN BUTTON CLICKED ===');
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 {
console.log('Отправляем /api/login:', { user, pass: '***' });
const res = await api('/api/login', 'POST', { user, pass });
console.log('Login ответ:', res);
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() {
console.log('=== LOAD TREE ВЫЗВАН ===');
const treeEl = document.getElementById('tree');
treeEl.style.color = '';
treeEl.innerHTML = 'Загрузка...';
try {
const tree = await api('/api/tree');
console.log('Дерево получено:', 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;
async function selectTable(schema, tableName) {
currentSchema = schema;
currentTable = tableName;
lastEditedRow = null;
console.log('=== SELECT TABLE ===', { schema, tableName });
if (enterHandler) {
document.removeEventListener('keydown', enterHandler);
enterHandler = null;
}
currentMeta = await api(
`/api/table/meta?schema=${encodeURIComponent(schema)}&table=${encodeURIComponent(tableName)}`
);
const columns = 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, // ✅ Множественное выделение (вместо selectableRows: 1)
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;
const params = {
schema: currentSchema,
table: currentTable,
filters: filters,
sort: sort,
columns: currentMeta.columns,
page: this.getPage ? this.getPage() : 1,
pageSize: this.getPageSize ? this.getPageSize() : 50
};
console.log('📤 Запрос к серверу:', {
page: params.page,
pageSize: params.pageSize,
filters: params.filters,
sort: params.sort
});
return params;
},
ajaxResponse: function (url, params, response) {
console.log('📥 Ответ от сервера:', {
page: response.current_page,
last_page: response.last_page,
total: response.total,
rows: response.data ? response.data.length : 0
});
return {
last_page: response.last_page || 1,
data: response.data || []
};
},
rowClick: function(e, row) {
// ✅ Ctrl/Cmd + Click для множественного выбора
if (e.ctrlKey || e.metaKey) {
row.toggleSelect();
} else {
// Обычный клик - снимаем выделение с других и выделяем текущую
table.deselectRow();
row.toggleSelect();
}
},
cellEdited: function(cell) {
const row = cell.getRow();
lastEditedRow = row.getData();
row.getElement().style.backgroundColor = '#fffae6';
console.log('Ячейка отредактирована:', cell.getField(), '→', cell.getValue());
},
headerFilterLiveFilterDelay: 800,
dataFiltering: function(filters) {
const activeFilters = filters.filter(f => f.value);
console.log('🔍 Активные фильтры:', activeFilters.map(f => `${f.field}=${f.value}`));
},
dataFiltered: function(filters, rows) {
console.log('✅ Фильтрация применена, записей на странице:', rows.length);
},
pageLoaded: function(pageno) {
console.log('📄 Загружена страница:', pageno);
}
});
enterHandler = async function(e) {
if (e.key === 'Enter' && lastEditedRow && currentSchema && currentTable) {
e.preventDefault();
console.log('Enter нажат, сохраняем строку:', lastEditedRow);
try {
const res = await api('/api/table/update', 'POST', {
schema: currentSchema,
table: currentTable,
row: lastEditedRow
});
console.log('Сохранено:', res);
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);
}
// CRUD кнопки
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] = '';
}
});
// ✅ Если есть обязательные FK - показываем модальное окно для выбора
const requiredFKs = fkFields.filter(f => f.required);
if (requiredFKs.length > 0) {
try {
const fkValues = await promptForForeignKeys(requiredFKs);
if (!fkValues) {
// Пользователь отменил
return;
}
// Добавляем выбранные FK значения
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
});
await table.replaceData();
alert('✓ Строка успешно создана');
} catch (e) {
console.error('Ошибка вставки:', e);
alert('Ошибка вставки: ' + e.message);
}
});
// ✅ Функция для выбора FK значений
async function promptForForeignKeys(fkFields) {
// Создаём модальное окно
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: 20px;
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
`;
let html = '<h3>Заполните обязательные поля</h3>';
// Загружаем доступные значения для каждого FK
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] = [];
}
}
// Создаём поля для каждого FK
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) {
// Если есть значения - показываем select
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 {
// Если нет значений - показываем input
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);
}
});
});
}
// Вспомогательная функция для экранирования HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
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 () => {
if (!table || !currentSchema || !currentTable) return;
const selected = table.getSelectedData();
if (selected.length === 0) {
alert('Выберите строки для удаления (используйте Ctrl+Click для выбора нескольких)');
return;
}
const confirmMsg = selected.length === 1
? 'Удалить выбранную строку?'
: `Удалить ${selected.length} выбранных строк?`;
if (!confirm(confirmMsg)) return;
try {
let deleted = 0;
for (const row of selected) {
await api('/api/table/delete', 'POST', { schema: currentSchema, table: currentTable, row });
deleted++;
}
await table.replaceData();
alert(`✓ Удалено строк: ${deleted}`);
} catch (e) {
console.error(e);
alert('Ошибка удаления: ' + e.message);
}
});
// ✅ Снятие выделения
document.getElementById('btnDeselectAll').addEventListener('click', () => {
if (table) {
table.deselectRow();
console.log('Выделение снято');
}
});
// ========== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ 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 ',';
}
// Улучшенный парсер CSV с поддержкой ; и , разделителей
function parseCSV(text) {
// Автоопределение разделителя
const delimiter = detectDelimiter(text);
console.log('Обнаружен разделитель CSV:', delimiter);
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++; // пропускаем \n после \r
}
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;
console.log('=== НАЧАЛО ИМПОРТА CSV ===');
console.log('Файл:', file.name, 'Размер:', file.size, 'байт');
try {
const text = await file.text();
console.log('Содержимое файла (первые 500 символов):\n', text.substring(0, 500));
console.log('Полное содержимое файла:\n', text);
const rows = parseCSV(text);
console.log('Всего строк после парсинга:', rows.length);
console.log('Все распарсенные строки:', rows);
if (rows.length === 0) {
alert('CSV файл пуст');
return;
}
const headers = rows[0];
console.log('Заголовки CSV:', headers);
const dataRows = rows.slice(1);
console.log('Строк данных (без заголовка):', dataRows.length);
if (dataRows.length === 0) {
alert('Нет данных для импорта (только заголовки)');
return;
}
// Преобразуем в массив объектов
const records = dataRows.map((row, idx) => {
const obj = {};
headers.forEach((header, i) => {
const value = row[i] || null;
obj[header] = value;
console.log(` Строка ${idx + 1}, столбец "${header}": "${value}"`);
});
return obj;
});
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', {
schema: currentSchema,
table: currentTable,
rows: records
});
console.log('Ответ от сервера:', result);
if (result.errorMessages && result.errorMessages.length > 0) {
console.error('Ошибки импорта:', result.errorMessages);
alert(`Импортировано строк: ${result.inserted}\nОшибок: ${result.errors}\n\nОшибки:\n${result.errorMessages.join('\n')}`);
} else {
alert(`✓ Импортировано строк: ${result.inserted}\nОшибок: ${result.errors}`);
}
await table.replaceData();
// Очищаем input
e.target.value = '';
} catch (err) {
console.error('=== КРИТИЧЕСКАЯ ОШИБКА ИМПОРТА ===');
console.error('Тип ошибки:', err.name);
console.error('Сообщение:', err.message);
console.error('Stack:', err.stack);
alert('Ошибка импорта: ' + err.message);
}
});
// ========== ЭКСПОРТ 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
});
// Создаем CSV текст
const csv = result.csv;
// Скачиваем файл
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }); // BOM для Excel
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);
console.log(`Экспортировано ${result.rowCount} строк`);
} catch (err) {
console.error('Ошибка экспорта:', err);
alert('Ошибка экспорта: ' + err.message);
}
});