Add exact application files

This commit is contained in:
2026-01-21 02:10:12 +03:00
parent d25a8abc80
commit df33488ad7
7 changed files with 1586 additions and 0 deletions

13
composer.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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';
}
}