Add exact application files
This commit is contained in:
13
composer.json
Normal file
13
composer.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"require": {
|
||||
"php": "^8.1",
|
||||
"slim/slim": "^4.0",
|
||||
"slim/psr7": "^1.6",
|
||||
"php-di/php-di": "^7.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "src/"
|
||||
}
|
||||
}
|
||||
}
|
||||
675
public/app.js
Normal file
675
public/app.js
Normal file
@@ -0,0 +1,675 @@
|
||||
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: 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) {
|
||||
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('Ничего не выбрано');
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Удалить ${selected.length} строк(и)?`)) return;
|
||||
|
||||
try {
|
||||
for (const row of selected) {
|
||||
await api('/api/table/delete', 'POST', { schema: currentSchema, table: currentTable, row });
|
||||
}
|
||||
await table.replaceData();
|
||||
} catch (e) {
|
||||
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 ',';
|
||||
}
|
||||
|
||||
// Улучшенный парсер 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;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
console.log('Содержимое файла:', text.substring(0, 200)); // первые 200 символов для отладки
|
||||
|
||||
const rows = parseCSV(text);
|
||||
console.log('Распарсенные строки:', rows);
|
||||
|
||||
if (rows.length === 0) {
|
||||
alert('CSV файл пуст');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = rows[0];
|
||||
console.log('Заголовки:', headers);
|
||||
|
||||
const dataRows = rows.slice(1);
|
||||
|
||||
if (dataRows.length === 0) {
|
||||
alert('Нет данных для импорта (только заголовки)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Преобразуем в массив объектов
|
||||
const records = dataRows.map(row => {
|
||||
const obj = {};
|
||||
headers.forEach((header, i) => {
|
||||
obj[header] = row[i] || null;
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
|
||||
console.log('Импортируем записи:', records);
|
||||
|
||||
const result = await api('/api/table/import-csv', 'POST', {
|
||||
schema: currentSchema,
|
||||
table: currentTable,
|
||||
rows: records
|
||||
});
|
||||
|
||||
alert(`✓ Импортировано строк: ${result.inserted}\nОшибок: ${result.errors}`);
|
||||
await table.replaceData();
|
||||
|
||||
// Очищаем input
|
||||
e.target.value = '';
|
||||
} catch (err) {
|
||||
console.error('Ошибка импорта:', err);
|
||||
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);
|
||||
}
|
||||
});
|
||||
118
public/index.html
Normal file
118
public/index.html
Normal file
@@ -0,0 +1,118 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Turbo RFQ</title>
|
||||
|
||||
<!-- Tabulator CSS/JS (CDN) -->
|
||||
<link href="https://unpkg.com/tabulator-tables@6.3.0/dist/css/tabulator.min.css" rel="stylesheet">
|
||||
<script src="https://unpkg.com/tabulator-tables@6.3.0/dist/js/tabulator.min.js"></script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: sans-serif;
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden; /* ✅ Предотвращаем скролл body */
|
||||
}
|
||||
#sidebar {
|
||||
width: 250px;
|
||||
border-right: 1px solid #ccc;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0; /* ✅ Sidebar не сжимается */
|
||||
}
|
||||
#main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0; /* ✅ Позволяет flex-элементу сжиматься */
|
||||
overflow: hidden; /* ✅ Контролируем overflow */
|
||||
}
|
||||
#toolbar {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #ccc;
|
||||
flex-shrink: 0; /* ✅ Toolbar не сжимается */
|
||||
}
|
||||
#table {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: auto; /* ✅ Скролл только внутри таблицы */
|
||||
min-height: 0; /* ✅ Важно для flex-контейнера */
|
||||
}
|
||||
.schema { font-weight: bold; margin-top: 8px; cursor: pointer; }
|
||||
.table { margin-left: 12px; cursor: pointer; }
|
||||
#loginPanel {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #ccc;
|
||||
background: #f7f7f7;
|
||||
flex-shrink: 0; /* ✅ Login panel не сжимается */
|
||||
}
|
||||
#csvFileInput { display: none; }
|
||||
|
||||
/* ✅ Стили для Tabulator */
|
||||
.tabulator {
|
||||
border: none;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.tabulator .tabulator-header {
|
||||
background-color: #f5f5f5;
|
||||
border-bottom: 2px solid #ddd;
|
||||
}
|
||||
|
||||
.tabulator .tabulator-tableholder {
|
||||
overflow-x: auto !important; /* ✅ Горизонтальный скролл при необходимости */
|
||||
}
|
||||
|
||||
.fk-modal {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.fk-modal select,
|
||||
.fk-modal input {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.fk-modal select:focus,
|
||||
.fk-modal input:focus {
|
||||
outline: none;
|
||||
border-color: #4CAF50;
|
||||
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2);
|
||||
}
|
||||
|
||||
.fk-modal button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="sidebar">
|
||||
<h3>Базы / таблицы</h3>
|
||||
<div id="tree"></div>
|
||||
</div>
|
||||
<div id="main">
|
||||
<div id="loginPanel">
|
||||
<label>Login: <input id="loginUser" type="text"></label>
|
||||
<label>Password: <input id="loginPass" type="password"></label>
|
||||
<button id="loginBtn">Войти</button>
|
||||
<span id="loginStatus"></span>
|
||||
</div>
|
||||
<div id="toolbar">
|
||||
<button id="btnInsert">Вставить</button>
|
||||
<button id="btnUpdate">Сохранить строку</button>
|
||||
<button id="btnDelete">Удалить</button>
|
||||
<button id="btnImportCSV">Импорт CSV</button>
|
||||
<input type="file" id="csvFileInput" accept=".csv">
|
||||
<button id="btnExportCSV">Экспорт CSV</button>
|
||||
</div>
|
||||
<div id="table"></div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
230
public/index.php
Normal file
230
public/index.php
Normal file
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Slim\Factory\AppFactory;
|
||||
use DI\Container;
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
session_start();
|
||||
|
||||
// DI‑контейнер
|
||||
$container = new Container();
|
||||
AppFactory::setContainer($container);
|
||||
$app = AppFactory::create();
|
||||
|
||||
// В dev включаем подробные ошибки (можно выключить в проде)
|
||||
$app->addErrorMiddleware(true, true, true);
|
||||
|
||||
$container->set('db', function () {
|
||||
return \App\Db::connectFromSession();
|
||||
});
|
||||
|
||||
// --- ROUTES ---
|
||||
|
||||
// Статическая страница с фронтендом
|
||||
$app->get('/', function (Request $request, Response $response) {
|
||||
// Очень простой view встроен прямо здесь
|
||||
$html = file_get_contents(__DIR__ . '/index.html'); // вынесем в отдельный файл ниже
|
||||
$response->getBody()->write($html);
|
||||
return $response;
|
||||
});
|
||||
|
||||
// Логин: сохраняет логин/пароль MariaDB в сессии после проверки
|
||||
$app->post('/api/login', function (Request $request, Response $response) {
|
||||
$data = json_decode((string)$request->getBody(), true);
|
||||
$user = $data['user'] ?? '';
|
||||
$pass = $data['pass'] ?? '';
|
||||
|
||||
try {
|
||||
\App\Db::testConnection($user, $pass);
|
||||
$_SESSION['db_user'] = $user;
|
||||
$_SESSION['db_pass'] = $pass;
|
||||
|
||||
$payload = ['ok' => true];
|
||||
$status = 200;
|
||||
} catch (\RuntimeException $e) {
|
||||
$payload = ['ok' => false, 'error' => $e->getMessage()];
|
||||
$status = 401;
|
||||
}
|
||||
|
||||
$response->getBody()->write(json_encode($payload));
|
||||
return $response->withHeader('Content-Type', 'application/json')
|
||||
->withStatus($status);
|
||||
});
|
||||
|
||||
// Middleware на /api/*: проверяем, что аутентифицированы
|
||||
$app->add(function (Request $request, $handler) {
|
||||
$path = $request->getUri()->getPath();
|
||||
if (str_starts_with($path, '/api/') && $path !== '/api/login') {
|
||||
if (empty($_SESSION['db_user']) || empty($_SESSION['db_pass'])) {
|
||||
$res = new \Slim\Psr7\Response();
|
||||
$res->getBody()->write(json_encode(['error' => 'Not authenticated']));
|
||||
return $res->withStatus(401)->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
return $handler->handle($request);
|
||||
});
|
||||
|
||||
// API: дерево схем/таблиц
|
||||
$app->get('/api/tree', function (Request $request, Response $response) use ($container) {
|
||||
$pdo = $container->get('db');
|
||||
$meta = new \App\MetaService($pdo);
|
||||
$tree = $meta->getSchemaTree();
|
||||
|
||||
$response->getBody()->write(json_encode($tree));
|
||||
return $response->withHeader('Content-Type', 'application/json');
|
||||
});
|
||||
|
||||
// API: метаданные таблицы
|
||||
$app->get('/api/table/meta', function (Request $request, Response $response) use ($container) {
|
||||
$params = $request->getQueryParams();
|
||||
$schema = $params['schema'] ?? '';
|
||||
$table = $params['table'] ?? '';
|
||||
|
||||
$pdo = $container->get('db');
|
||||
$meta = new \App\MetaService($pdo);
|
||||
$data = $meta->getTableMeta($schema, $table);
|
||||
|
||||
$response->getBody()->write(json_encode($data));
|
||||
return $response->withHeader('Content-Type', 'application/json');
|
||||
});
|
||||
|
||||
// API: данные таблицы
|
||||
$app->post('/api/table/data', function (Request $request, Response $response) use ($container) {
|
||||
$payload = json_decode((string)$request->getBody(), true);
|
||||
|
||||
$schema = $payload['schema'] ?? '';
|
||||
$table = $payload['table'] ?? '';
|
||||
$page = (int)($payload['page'] ?? 1);
|
||||
$pageSize = (int)($payload['pageSize'] ?? 50);
|
||||
$filters = $payload['filters'] ?? [];
|
||||
$sort = $payload['sort'] ?? null;
|
||||
$columns = $payload['columns'] ?? [];
|
||||
|
||||
// ✅ Логирование для отладки
|
||||
error_log("Data request: page=$page, pageSize=$pageSize, filters=" . json_encode($filters));
|
||||
|
||||
$pdo = $container->get('db');
|
||||
$ds = new \App\DataService($pdo);
|
||||
$resData = $ds->fetchData($schema, $table, $columns, $page, $pageSize, $filters, $sort);
|
||||
|
||||
// ✅ Логирование ответа
|
||||
error_log("Data response: " . json_encode([
|
||||
'data_count' => count($resData['data']),
|
||||
'last_page' => $resData['last_page'],
|
||||
'current_page' => $resData['current_page'],
|
||||
'total' => $resData['total']
|
||||
]));
|
||||
|
||||
$response->getBody()->write(json_encode($resData));
|
||||
return $response->withHeader('Content-Type', 'application/json');
|
||||
});
|
||||
|
||||
// API: insert / update / delete
|
||||
$app->post('/api/table/insert', function (Request $request, Response $response) use ($container) {
|
||||
$payload = json_decode((string)$request->getBody(), true);
|
||||
$pdo = $container->get('db');
|
||||
$meta = new \App\MetaService($pdo);
|
||||
$ds = new \App\DataService($pdo);
|
||||
|
||||
$schema = $payload['schema'];
|
||||
$table = $payload['table'];
|
||||
$row = $payload['row'];
|
||||
$metaArr = $meta->getTableMeta($schema, $table);
|
||||
|
||||
$result = $ds->insertRow($schema, $table, $row, $metaArr['columns']);
|
||||
$response->getBody()->write(json_encode($result));
|
||||
return $response->withHeader('Content-Type', 'application/json');
|
||||
});
|
||||
|
||||
$app->post('/api/table/update', function (Request $request, Response $response) use ($container) {
|
||||
$payload = json_decode((string)$request->getBody(), true);
|
||||
$pdo = $container->get('db');
|
||||
$meta = new \App\MetaService($pdo);
|
||||
$ds = new \App\DataService($pdo);
|
||||
|
||||
$schema = $payload['schema'];
|
||||
$table = $payload['table'];
|
||||
$row = $payload['row'];
|
||||
$metaArr = $meta->getTableMeta($schema, $table);
|
||||
|
||||
$result = $ds->updateRow($schema, $table, $row, $metaArr['columns'], $metaArr['primaryKey']);
|
||||
$response->getBody()->write(json_encode($result));
|
||||
return $response->withHeader('Content-Type', 'application/json');
|
||||
});
|
||||
|
||||
$app->post('/api/table/delete', function (Request $request, Response $response) use ($container) {
|
||||
$payload = json_decode((string)$request->getBody(), true);
|
||||
$pdo = $container->get('db');
|
||||
$meta = new \App\MetaService($pdo);
|
||||
$ds = new \App\DataService($pdo);
|
||||
|
||||
$schema = $payload['schema'];
|
||||
$table = $payload['table'];
|
||||
$row = $payload['row'];
|
||||
$metaArr = $meta->getTableMeta($schema, $table);
|
||||
|
||||
$result = $ds->deleteRow($schema, $table, $row, $metaArr['primaryKey']);
|
||||
$response->getBody()->write(json_encode($result));
|
||||
return $response->withHeader('Content-Type', 'application/json');
|
||||
});
|
||||
|
||||
// API: импорт CSV (массовая вставка)
|
||||
$app->post('/api/table/import-csv', function (Request $request, Response $response) use ($container) {
|
||||
$payload = json_decode((string)$request->getBody(), true);
|
||||
$pdo = $container->get('db');
|
||||
$meta = new \App\MetaService($pdo);
|
||||
$ds = new \App\DataService($pdo);
|
||||
|
||||
$schema = $payload['schema'];
|
||||
$table = $payload['table'];
|
||||
$rows = $payload['rows'] ?? [];
|
||||
$metaArr = $meta->getTableMeta($schema, $table);
|
||||
|
||||
$result = $ds->insertMultipleRows($schema, $table, $rows, $metaArr['columns']);
|
||||
|
||||
$response->getBody()->write(json_encode($result));
|
||||
return $response->withHeader('Content-Type', 'application/json');
|
||||
});
|
||||
|
||||
// API: экспорт CSV
|
||||
$app->post('/api/table/export-csv', function (Request $request, Response $response) use ($container) {
|
||||
$payload = json_decode((string)$request->getBody(), true);
|
||||
$pdo = $container->get('db');
|
||||
$ds = new \App\DataService($pdo);
|
||||
|
||||
$schema = $payload['schema'] ?? '';
|
||||
$table = $payload['table'] ?? '';
|
||||
$filters = $payload['filters'] ?? [];
|
||||
$sort = $payload['sort'] ?? null;
|
||||
$columns = $payload['columns'] ?? [];
|
||||
|
||||
$result = $ds->exportCSV($schema, $table, $columns, $filters, $sort);
|
||||
|
||||
$response->getBody()->write(json_encode($result));
|
||||
return $response->withHeader('Content-Type', 'application/json');
|
||||
});
|
||||
// API: получить доступные значения для Foreign Key
|
||||
$app->get('/api/fk-values', function (Request $request, Response $response) use ($container) {
|
||||
$params = $request->getQueryParams();
|
||||
$schema = $params['schema'] ?? '';
|
||||
$table = $params['table'] ?? '';
|
||||
$column = $params['column'] ?? '';
|
||||
|
||||
$pdo = $container->get('db');
|
||||
|
||||
// Получаем до 100 первых значений
|
||||
$sql = "SELECT DISTINCT `{$column}` FROM `{$schema}`.`{$table}`
|
||||
WHERE `{$column}` IS NOT NULL
|
||||
ORDER BY `{$column}`
|
||||
LIMIT 100";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute();
|
||||
$values = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
$response->getBody()->write(json_encode(['values' => $values]));
|
||||
return $response->withHeader('Content-Type', 'application/json');
|
||||
});
|
||||
$app->run();
|
||||
386
src/DataService.php
Normal file
386
src/DataService.php
Normal file
@@ -0,0 +1,386 @@
|
||||
<?php
|
||||
namespace App;
|
||||
|
||||
use PDO;
|
||||
|
||||
class DataService
|
||||
{
|
||||
public function __construct(private PDO $pdo) {}
|
||||
|
||||
public function fetchData(
|
||||
string $schema,
|
||||
string $table,
|
||||
array $columns,
|
||||
int $page,
|
||||
int $pageSize,
|
||||
array $filters,
|
||||
?array $sort
|
||||
): array {
|
||||
$offset = ($page - 1) * $pageSize;
|
||||
|
||||
$colNames = array_map(fn($c) => $c['COLUMN_NAME'], $columns);
|
||||
$quotedColumns = array_map(fn($name) => "`$name`", $colNames);
|
||||
$selectList = implode(', ', $quotedColumns);
|
||||
|
||||
$whereParts = [];
|
||||
$params = [];
|
||||
foreach ($filters as $i => $f) {
|
||||
$field = $f['field'] ?? null;
|
||||
$value = $f['value'] ?? null;
|
||||
if (!$field || !in_array($field, $colNames, true) || $value === null || $value === '') {
|
||||
continue;
|
||||
}
|
||||
$param = ":f{$i}";
|
||||
$whereParts[] = "`$field` LIKE $param";
|
||||
$params[$param] = '%' . $value . '%';
|
||||
}
|
||||
$whereSql = $whereParts ? 'WHERE ' . implode(' AND ', $whereParts) : '';
|
||||
|
||||
$orderSql = '';
|
||||
if ($sort && !empty($sort['field']) && in_array($sort['field'], $colNames, true)) {
|
||||
$dir = strtoupper($sort['dir'] ?? 'ASC');
|
||||
if (!in_array($dir, ['ASC', 'DESC'], true)) {
|
||||
$dir = 'ASC';
|
||||
}
|
||||
$orderSql = "ORDER BY `{$sort['field']}` $dir";
|
||||
}
|
||||
|
||||
$sql = "SELECT $selectList
|
||||
FROM `{$schema}`.`{$table}`
|
||||
$whereSql
|
||||
$orderSql
|
||||
LIMIT :limit OFFSET :offset";
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
foreach ($params as $k => $v) {
|
||||
$stmt->bindValue($k, $v);
|
||||
}
|
||||
$stmt->bindValue(':limit', $pageSize, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
$countSql = "SELECT COUNT(*) AS cnt
|
||||
FROM `{$schema}`.`{$table}` $whereSql";
|
||||
$countStmt = $this->pdo->prepare($countSql);
|
||||
foreach ($params as $k => $v) {
|
||||
$countStmt->bindValue($k, $v);
|
||||
}
|
||||
$countStmt->execute();
|
||||
$total = (int)$countStmt->fetchColumn();
|
||||
|
||||
$lastPage = max(1, (int)ceil($total / $pageSize));
|
||||
|
||||
return [
|
||||
'data' => $rows,
|
||||
'last_page' => $lastPage,
|
||||
'total' => $total,
|
||||
'current_page' => $page
|
||||
];
|
||||
}
|
||||
|
||||
public function insertRow(string $schema, string $table, array $row, array $columns): array
|
||||
{
|
||||
$insertCols = [];
|
||||
$placeholders = [];
|
||||
$params = [];
|
||||
|
||||
foreach ($columns as $c) {
|
||||
$name = $c['COLUMN_NAME'];
|
||||
$extra = $c['EXTRA'] ?? '';
|
||||
|
||||
if (str_contains($extra, 'auto_increment')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (array_key_exists($name, $row)) {
|
||||
$insertCols[] = "`$name`";
|
||||
$placeholders[] = ":$name";
|
||||
$params[":$name"] = $row[$name];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($insertCols)) {
|
||||
$sql = "INSERT INTO `{$schema}`.`{$table}` () VALUES ()";
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
|
||||
try {
|
||||
$stmt->execute();
|
||||
return ['inserted' => true, 'id' => $this->pdo->lastInsertId()];
|
||||
} catch (\PDOException $e) {
|
||||
throw new \RuntimeException($this->formatPDOError($e, $schema, $table));
|
||||
}
|
||||
}
|
||||
|
||||
$sql = sprintf(
|
||||
"INSERT INTO `%s`.`%s` (%s) VALUES (%s)",
|
||||
$schema, $table,
|
||||
implode(',', $insertCols),
|
||||
implode(',', $placeholders)
|
||||
);
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
|
||||
try {
|
||||
$stmt->execute($params);
|
||||
return ['inserted' => true, 'id' => $this->pdo->lastInsertId()];
|
||||
} catch (\PDOException $e) {
|
||||
throw new \RuntimeException($this->formatPDOError($e, $schema, $table, $params));
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Метод для форматирования ошибок
|
||||
private function formatPDOError(\PDOException $e, string $schema, string $table, array $params = []): string
|
||||
{
|
||||
$code = $e->getCode();
|
||||
$message = $e->getMessage();
|
||||
|
||||
// Foreign Key constraint
|
||||
if ($code === '23000' && str_contains($message, 'foreign key constraint')) {
|
||||
if (preg_match('/FOREIGN KEY $$`([^`]+)`$$/', $message, $matches)) {
|
||||
$field = $matches[1];
|
||||
return "Ошибка: поле '{$field}' должно содержать существующее значение из связанной таблицы.";
|
||||
}
|
||||
return "Ошибка: нарушена связь с другой таблицей. Проверьте правильность заполнения полей-ссылок.";
|
||||
}
|
||||
|
||||
// Duplicate key
|
||||
if ($code === '23000' && str_contains($message, 'Duplicate entry')) {
|
||||
if (preg_match('/Duplicate entry \'([^\']+)\' for key \'([^\']+)\'/', $message, $matches)) {
|
||||
$value = $matches[1];
|
||||
$key = $matches[2];
|
||||
return "Ошибка: значение '{$value}' уже существует (ключ '{$key}').";
|
||||
}
|
||||
return "Ошибка: запись с таким значением уже существует.";
|
||||
}
|
||||
|
||||
// NOT NULL constraint
|
||||
if (str_contains($message, "cannot be null") || str_contains($message, "doesn't have a default value")) {
|
||||
if (preg_match('/Column \'([^\']+)\'/', $message, $matches)) {
|
||||
$field = $matches[1];
|
||||
return "Ошибка: поле '{$field}' обязательно для заполнения.";
|
||||
}
|
||||
return "Ошибка: не заполнены обязательные поля.";
|
||||
}
|
||||
|
||||
return "Ошибка БД: " . $message;
|
||||
}
|
||||
|
||||
public function updateRow(string $schema, string $table, array $row, array $columns, array $pk): array
|
||||
{
|
||||
if (empty($pk)) {
|
||||
throw new \RuntimeException('No primary key — update disabled');
|
||||
}
|
||||
|
||||
$sets = [];
|
||||
$params = [];
|
||||
|
||||
foreach ($columns as $c) {
|
||||
$name = $c['COLUMN_NAME'];
|
||||
if (in_array($name, $pk, true)) {
|
||||
continue;
|
||||
}
|
||||
if (array_key_exists($name, $row)) {
|
||||
$sets[] = "`$name` = :v_$name";
|
||||
$params[":v_$name"] = $row[$name];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($sets)) {
|
||||
return ['updated' => 0, 'message' => 'No changes'];
|
||||
}
|
||||
|
||||
$whereParts = [];
|
||||
foreach ($pk as $name) {
|
||||
if (!array_key_exists($name, $row)) {
|
||||
throw new \RuntimeException("Missing PK value: $name");
|
||||
}
|
||||
$whereParts[] = "`$name` = :pk_$name";
|
||||
$params[":pk_$name"] = $row[$name];
|
||||
}
|
||||
|
||||
$sql = sprintf(
|
||||
"UPDATE `%s`.`%s` SET %s WHERE %s",
|
||||
$schema, $table,
|
||||
implode(', ', $sets),
|
||||
implode(' AND ', $whereParts)
|
||||
);
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return ['updated' => $stmt->rowCount()];
|
||||
}
|
||||
|
||||
public function deleteRow(string $schema, string $table, array $row, array $pk): array
|
||||
{
|
||||
if (empty($pk)) {
|
||||
throw new \RuntimeException('No primary key — delete disabled');
|
||||
}
|
||||
|
||||
$whereParts = [];
|
||||
$params = [];
|
||||
|
||||
foreach ($pk as $name) {
|
||||
if (!array_key_exists($name, $row)) {
|
||||
throw new \RuntimeException("Missing PK value: $name");
|
||||
}
|
||||
$whereParts[] = "`$name` = :pk_$name";
|
||||
$params[":pk_$name"] = $row[$name];
|
||||
}
|
||||
|
||||
$sql = sprintf(
|
||||
"DELETE FROM `%s`.`%s` WHERE %s",
|
||||
$schema, $table,
|
||||
implode(' AND ', $whereParts)
|
||||
);
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return ['deleted' => $stmt->rowCount()];
|
||||
}
|
||||
|
||||
public function insertMultipleRows(string $schema, string $table, array $rows, array $columns): array
|
||||
{
|
||||
if (empty($rows)) {
|
||||
return ['inserted' => 0, 'errors' => 0, 'message' => 'No rows provided'];
|
||||
}
|
||||
|
||||
$inserted = 0;
|
||||
$errors = 0;
|
||||
$errorMessages = [];
|
||||
|
||||
$validColumns = [];
|
||||
foreach ($columns as $c) {
|
||||
$name = $c['COLUMN_NAME'];
|
||||
$extra = $c['EXTRA'] ?? '';
|
||||
|
||||
if (!str_contains($extra, 'auto_increment')) {
|
||||
$validColumns[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
foreach ($rows as $index => $row) {
|
||||
try {
|
||||
$insertCols = [];
|
||||
$placeholders = [];
|
||||
$params = [];
|
||||
|
||||
foreach ($validColumns as $name) {
|
||||
if (array_key_exists($name, $row)) {
|
||||
$insertCols[] = "`$name`";
|
||||
$placeholders[] = ":$name";
|
||||
$params[":$name"] = $row[$name];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($insertCols)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sql = sprintf(
|
||||
"INSERT INTO `%s`.`%s` (%s) VALUES (%s)",
|
||||
$schema, $table,
|
||||
implode(',', $insertCols),
|
||||
implode(',', $placeholders)
|
||||
);
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$inserted++;
|
||||
} catch (\PDOException $e) {
|
||||
$errors++;
|
||||
$errorMessages[] = "Строка " . ($index + 1) . ": " . $this->formatPDOError($e, $schema, $table);
|
||||
}
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (\Exception $e) {
|
||||
$this->pdo->rollBack();
|
||||
throw new \RuntimeException('Import failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return [
|
||||
'inserted' => $inserted,
|
||||
'errors' => $errors,
|
||||
'errorMessages' => $errorMessages
|
||||
];
|
||||
}
|
||||
|
||||
public function exportCSV(
|
||||
string $schema,
|
||||
string $table,
|
||||
array $columns,
|
||||
array $filters,
|
||||
?array $sort
|
||||
): array {
|
||||
$colNames = array_map(fn($c) => $c['COLUMN_NAME'], $columns);
|
||||
$quotedColumns = array_map(fn($name) => "`$name`", $colNames);
|
||||
$selectList = implode(', ', $quotedColumns);
|
||||
|
||||
$whereParts = [];
|
||||
$params = [];
|
||||
foreach ($filters as $i => $f) {
|
||||
$field = $f['field'] ?? null;
|
||||
$value = $f['value'] ?? null;
|
||||
if (!$field || !in_array($field, $colNames, true) || $value === null) {
|
||||
continue;
|
||||
}
|
||||
$param = ":f{$i}";
|
||||
$whereParts[] = "`$field` LIKE $param";
|
||||
$params[$param] = '%' . $value . '%';
|
||||
}
|
||||
$whereSql = $whereParts ? 'WHERE ' . implode(' AND ', $whereParts) : '';
|
||||
|
||||
$orderSql = '';
|
||||
if ($sort && !empty($sort['field']) && in_array($sort['field'], $colNames, true)) {
|
||||
$dir = strtoupper($sort['dir'] ?? 'ASC');
|
||||
if (!in_array($dir, ['ASC', 'DESC'], true)) {
|
||||
$dir = 'ASC';
|
||||
}
|
||||
$orderSql = "ORDER BY `{$sort['field']}` $dir";
|
||||
}
|
||||
|
||||
$sql = "SELECT $selectList
|
||||
FROM `{$schema}`.`{$table}`
|
||||
$whereSql
|
||||
$orderSql";
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
foreach ($params as $k => $v) {
|
||||
$stmt->bindValue($k, $v);
|
||||
}
|
||||
$stmt->execute();
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
$csv = $this->arrayToCSV($colNames, $rows);
|
||||
|
||||
return [
|
||||
'csv' => $csv,
|
||||
'rowCount' => count($rows)
|
||||
];
|
||||
}
|
||||
|
||||
private function arrayToCSV(array $headers, array $rows): string
|
||||
{
|
||||
$output = fopen('php://temp', 'r+');
|
||||
|
||||
fputcsv($output, $headers, ';');
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$rowData = [];
|
||||
foreach ($headers as $header) {
|
||||
$rowData[] = $row[$header] ?? '';
|
||||
}
|
||||
fputcsv($output, $rowData, ';');
|
||||
}
|
||||
|
||||
rewind($output);
|
||||
$csv = stream_get_contents($output);
|
||||
fclose($output);
|
||||
|
||||
return $csv;
|
||||
}
|
||||
}
|
||||
43
src/Db.php
Normal file
43
src/Db.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
namespace App;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
|
||||
class Db
|
||||
{
|
||||
// Простая фабрика PDO по логину/паролю MariaDB из сессии
|
||||
public static function connectFromSession(): PDO
|
||||
{
|
||||
if (empty($_SESSION['db_user']) || empty($_SESSION['db_pass'])) {
|
||||
throw new \RuntimeException('Not authenticated');
|
||||
}
|
||||
|
||||
$user = $_SESSION['db_user'];
|
||||
$pass = $_SESSION['db_pass'];
|
||||
|
||||
// TODO: вынести host/port/charset в конфиг .env
|
||||
$dsn = 'mysql:host=localhost;port=3306;charset=utf8mb4';
|
||||
|
||||
$pdo = new PDO($dsn, $user, $pass, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_EMULATE_PREPARES => false, // важная настройка [web:28][web:25]
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]);
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
public static function testConnection(string $user, string $pass): void
|
||||
{
|
||||
$dsn = 'mysql:host=localhost;port=3306;charset=utf8mb4'; // MariaDB совместим [web:22][web:28]
|
||||
try {
|
||||
new PDO($dsn, $user, $pass, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
throw new \RuntimeException('Connection failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
121
src/MetaService.php
Normal file
121
src/MetaService.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
namespace App;
|
||||
|
||||
use PDO;
|
||||
|
||||
class MetaService
|
||||
{
|
||||
public function __construct(private PDO $pdo) {}
|
||||
|
||||
public function getSchemaTree(): array
|
||||
{
|
||||
$sql = "
|
||||
SELECT TABLE_SCHEMA, TABLE_NAME
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_TYPE = 'BASE TABLE'
|
||||
AND TABLE_SCHEMA NOT IN ('information_schema','mysql','performance_schema','sys')
|
||||
ORDER BY TABLE_SCHEMA, TABLE_NAME
|
||||
";
|
||||
$rows = $this->pdo->query($sql)->fetchAll();
|
||||
|
||||
$tree = [];
|
||||
foreach ($rows as $row) {
|
||||
$schema = $row['TABLE_SCHEMA'];
|
||||
$table = $row['TABLE_NAME'];
|
||||
$tree[$schema]['name'] = $schema;
|
||||
$tree[$schema]['tables'][] = $table;
|
||||
}
|
||||
return array_values($tree);
|
||||
}
|
||||
|
||||
public function getTableMeta(string $schema, string $table): array
|
||||
{
|
||||
$sql = "
|
||||
SELECT
|
||||
COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, COLUMN_KEY,
|
||||
IS_NULLABLE, COLUMN_DEFAULT, EXTRA, ORDINAL_POSITION
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table
|
||||
ORDER BY ORDINAL_POSITION
|
||||
";
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute([':schema' => $schema, ':table' => $table]);
|
||||
$cols = $stmt->fetchAll();
|
||||
|
||||
// ✅ Получаем информацию о Foreign Keys
|
||||
$fkSql = "
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
REFERENCED_TABLE_SCHEMA,
|
||||
REFERENCED_TABLE_NAME,
|
||||
REFERENCED_COLUMN_NAME
|
||||
FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = :schema
|
||||
AND TABLE_NAME = :table
|
||||
AND REFERENCED_TABLE_NAME IS NOT NULL
|
||||
";
|
||||
$fkStmt = $this->pdo->prepare($fkSql);
|
||||
$fkStmt->execute([':schema' => $schema, ':table' => $table]);
|
||||
$foreignKeys = $fkStmt->fetchAll();
|
||||
|
||||
// Создаём map FK для быстрого доступа
|
||||
$fkMap = [];
|
||||
foreach ($foreignKeys as $fk) {
|
||||
$fkMap[$fk['COLUMN_NAME']] = [
|
||||
'ref_schema' => $fk['REFERENCED_TABLE_SCHEMA'],
|
||||
'ref_table' => $fk['REFERENCED_TABLE_NAME'],
|
||||
'ref_column' => $fk['REFERENCED_COLUMN_NAME']
|
||||
];
|
||||
}
|
||||
|
||||
// Primary keys
|
||||
$pk = [];
|
||||
foreach ($cols as $c) {
|
||||
if ($c['COLUMN_KEY'] === 'PRI') {
|
||||
$pk[] = $c['COLUMN_NAME'];
|
||||
}
|
||||
}
|
||||
|
||||
$enrichedCols = array_map(function($c) use ($fkMap) {
|
||||
$name = $c['COLUMN_NAME'];
|
||||
|
||||
return [
|
||||
'COLUMN_NAME' => $name,
|
||||
'DATA_TYPE' => $c['DATA_TYPE'],
|
||||
'COLUMN_TYPE' => $c['COLUMN_TYPE'],
|
||||
'COLUMN_KEY' => $c['COLUMN_KEY'],
|
||||
'IS_NULLABLE' => $c['IS_NULLABLE'] === 'YES',
|
||||
'COLUMN_DEFAULT' => $c['COLUMN_DEFAULT'],
|
||||
'HAS_DEFAULT' => !empty($c['COLUMN_DEFAULT']),
|
||||
'EXTRA' => $c['EXTRA'],
|
||||
'IS_AUTO_INCREMENT' => str_contains($c['EXTRA'] ?? '', 'auto_increment'),
|
||||
'ORDINAL_POSITION' => (int)$c['ORDINAL_POSITION'],
|
||||
'IS_REQUIRED' => $c['IS_NULLABLE'] === 'NO' && empty($c['COLUMN_DEFAULT']),
|
||||
'EDITOR_TYPE' => $this->getEditorType($c),
|
||||
// ✅ Добавляем информацию о FK
|
||||
'IS_FOREIGN_KEY' => isset($fkMap[$name]),
|
||||
'FOREIGN_KEY' => $fkMap[$name] ?? null
|
||||
];
|
||||
}, $cols);
|
||||
|
||||
return [
|
||||
'columns' => $enrichedCols,
|
||||
'primaryKey' => $pk,
|
||||
'totalColumns' => count($enrichedCols),
|
||||
'foreignKeys' => $foreignKeys
|
||||
];
|
||||
}
|
||||
|
||||
private function getEditorType(array $col): string
|
||||
{
|
||||
$type = strtolower($col['DATA_TYPE']);
|
||||
$fullType = strtolower($col['COLUMN_TYPE']);
|
||||
|
||||
if (str_contains($fullType, 'int') || str_contains($type, 'int')) return 'number';
|
||||
if (str_contains($fullType, 'float') || str_contains($fullType, 'decimal') || str_contains($fullType, 'double')) return 'number';
|
||||
if (str_contains($type, 'date')) return 'datetime';
|
||||
if (str_contains($type, 'time')) return 'time';
|
||||
if (str_contains($type, 'bool') || str_contains($type, 'boolean')) return 'tickCross';
|
||||
return 'input';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user