24124124
This commit is contained in:
14
favicon.svg
Normal file
14
favicon.svg
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<!-- Background -->
|
||||||
|
<rect width="64" height="64" rx="12" fill="#0A84FF"/>
|
||||||
|
|
||||||
|
<!-- Database cylinder -->
|
||||||
|
<ellipse cx="32" cy="18" rx="18" ry="8" fill="white"/>
|
||||||
|
<rect x="14" y="18" width="36" height="20" fill="white"/>
|
||||||
|
<ellipse cx="32" cy="38" rx="18" ry="8" fill="white"/>
|
||||||
|
|
||||||
|
<!-- Lines (data layers) -->
|
||||||
|
<ellipse cx="32" cy="26" rx="14" ry="5" fill="#0A84FF"/>
|
||||||
|
<ellipse cx="32" cy="34" rx="14" ry="5" fill="#0A84FF"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 542 B |
113
index.html
113
index.html
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>PostgreSQL Admin Panel</title>
|
<title>PostgreSQL Admin Panel</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script src="https://unpkg.com/lucide@latest"></script>
|
<script src="https://unpkg.com/lucide@latest"></script>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
@@ -162,7 +163,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3" id="recordControls">
|
<div class="flex items-center gap-3" id="recordControls">
|
||||||
<input id="recordSearch" type="text" placeholder="Поиск по записям..." class="w-56 border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
<input id="recordSearch" type="text" placeholder="Поиск по записям..." class="w-56 border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
<button onclick="app.showFiltersModal()" class="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm">
|
<button onclick="app.toggleFilters()" class="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm">
|
||||||
<i data-lucide="filter" class="w-4 h-4"></i>
|
<i data-lucide="filter" class="w-4 h-4"></i>
|
||||||
Фильтры
|
Фильтры
|
||||||
</button>
|
</button>
|
||||||
@@ -186,6 +187,9 @@
|
|||||||
<thead class="bg-slate-50 text-slate-600 font-medium border-b border-slate-200">
|
<thead class="bg-slate-50 text-slate-600 font-medium border-b border-slate-200">
|
||||||
<tr id="tableHeaders"></tr>
|
<tr id="tableHeaders"></tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
<tbody id="filterRow" class="hidden bg-blue-50 border-b border-slate-200">
|
||||||
|
<tr id="filterInputs"></tr>
|
||||||
|
</tbody>
|
||||||
<tbody id="tableBody" class="divide-y divide-slate-200"></tbody>
|
<tbody id="tableBody" class="divide-y divide-slate-200"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -393,36 +397,6 @@ SELECT * FROM users LIMIT 10;"></textarea>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters Modal -->
|
|
||||||
<div id="filtersModal" class="hidden fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
|
|
||||||
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col fade-in">
|
|
||||||
<div class="p-6 border-b border-slate-200 flex items-center justify-between">
|
|
||||||
<h3 class="text-xl font-bold text-slate-800">Фильтры</h3>
|
|
||||||
<button onclick="app.closeModal('filtersModal')" class="p-2 hover:bg-slate-100 rounded-lg transition-colors">
|
|
||||||
<i data-lucide="x" class="w-5 h-5 text-slate-500"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="p-6 overflow-y-auto flex-1">
|
|
||||||
<div id="filtersContainer" class="space-y-4">
|
|
||||||
<!-- Filters will be added here -->
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 flex justify-end">
|
|
||||||
<button onclick="app.addFilter()" class="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm">
|
|
||||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
|
||||||
Добавить фильтр
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="p-6 border-t border-slate-200 bg-slate-50 flex justify-between">
|
|
||||||
<button onclick="app.clearFilters()" class="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg">Очистить</button>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button onclick="app.closeModal('filtersModal')" class="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg">Отмена</button>
|
|
||||||
<button onclick="app.applyFilters()" class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">Применить</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Toast Notifications -->
|
<!-- Toast Notifications -->
|
||||||
<div id="toastContainer" class="fixed bottom-6 right-6 z-50 flex flex-col gap-2"></div>
|
<div id="toastContainer" class="fixed bottom-6 right-6 z-50 flex flex-col gap-2"></div>
|
||||||
|
|
||||||
@@ -439,7 +413,7 @@ SELECT * FROM users LIMIT 10;"></textarea>
|
|||||||
this.editingColumn = null;
|
this.editingColumn = null;
|
||||||
this.primaryKey = null;
|
this.primaryKey = null;
|
||||||
this.tableStructure = [];
|
this.tableStructure = [];
|
||||||
this.filters = [];
|
this.filters = {};
|
||||||
this.sortColumn = '';
|
this.sortColumn = '';
|
||||||
this.sortDirection = 'ASC';
|
this.sortDirection = 'ASC';
|
||||||
this.init();
|
this.init();
|
||||||
@@ -606,10 +580,12 @@ SELECT * FROM users LIMIT 10;"></textarea>
|
|||||||
|
|
||||||
renderTableData(records) {
|
renderTableData(records) {
|
||||||
const headers = document.getElementById('tableHeaders');
|
const headers = document.getElementById('tableHeaders');
|
||||||
|
const filterInputs = document.getElementById('filterInputs');
|
||||||
const body = document.getElementById('tableBody');
|
const body = document.getElementById('tableBody');
|
||||||
|
|
||||||
if (records.length === 0) {
|
if (records.length === 0) {
|
||||||
headers.innerHTML = '';
|
headers.innerHTML = '';
|
||||||
|
filterInputs.innerHTML = '';
|
||||||
body.innerHTML = '<tr><td colspan="100%" class="text-center py-8 text-slate-500">Нет данных</td></tr>';
|
body.innerHTML = '<tr><td colspan="100%" class="text-center py-8 text-slate-500">Нет данных</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -622,6 +598,11 @@ SELECT * FROM users LIMIT 10;"></textarea>
|
|||||||
return `<th class="text-left p-3 cursor-pointer hover:bg-slate-100 select-none" onclick="app.sortBy('${col}')">${col} ${arrow}</th>`;
|
return `<th class="text-left p-3 cursor-pointer hover:bg-slate-100 select-none" onclick="app.sortBy('${col}')">${col} ${arrow}</th>`;
|
||||||
}).join('') + '<th class="text-left p-3">Действия</th>';
|
}).join('') + '<th class="text-left p-3">Действия</th>';
|
||||||
|
|
||||||
|
// Generate filter inputs
|
||||||
|
filterInputs.innerHTML = columns.map(col => {
|
||||||
|
return `<td class="p-2"><input type="text" placeholder="Фильтр ${col}" class="w-full px-2 py-1 text-xs border border-slate-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500" oninput="app.updateFilter('${col}', this.value)" value="${this.filters[col] || ''}"></td>`;
|
||||||
|
}).join('') + '<td class="p-2"><button onclick="app.clearFilters()" class="text-xs text-slate-500 hover:text-slate-700">Очистить</button></td>';
|
||||||
|
|
||||||
// Generate rows
|
// Generate rows
|
||||||
body.innerHTML = records.map(record => {
|
body.innerHTML = records.map(record => {
|
||||||
const pkValue = this.primaryKey ? record[this.primaryKey] : null;
|
const pkValue = this.primaryKey ? record[this.primaryKey] : null;
|
||||||
@@ -1190,45 +1171,6 @@ SELECT * FROM users LIMIT 10;"></textarea>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showFiltersModal() {
|
|
||||||
this.renderFilters();
|
|
||||||
document.getElementById('filtersModal').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
renderFilters() {
|
|
||||||
const container = document.getElementById('filtersContainer');
|
|
||||||
container.innerHTML = this.filters.map((filter, index) => `
|
|
||||||
<div class="flex items-center gap-2 p-3 bg-slate-50 rounded-lg">
|
|
||||||
<select class="px-3 py-2 border border-slate-300 rounded focus:ring-2 focus:ring-blue-500 outline-none" onchange="app.updateFilterColumn(${index}, this.value)">
|
|
||||||
<option value="">Выберите колонку</option>
|
|
||||||
${this.tableStructure.map(col => `<option value="${col.name}" ${filter.column === col.name ? 'selected' : ''}>${col.name}</option>`).join('')}
|
|
||||||
</select>
|
|
||||||
<select class="px-3 py-2 border border-slate-300 rounded focus:ring-2 focus:ring-blue-500 outline-none" onchange="app.updateFilterOperator(${index}, this.value)">
|
|
||||||
<option value="=" ${filter.operator === '=' ? 'selected' : ''}>=</option>
|
|
||||||
<option value="!=" ${filter.operator === '!=' ? 'selected' : ''}>!=</option>
|
|
||||||
<option value=">" ${filter.operator === '>' ? 'selected' : ''}>></option>
|
|
||||||
<option value="<" ${filter.operator === '<' ? 'selected' : ''}><</option>
|
|
||||||
<option value=">=" ${filter.operator === '>=' ? 'selected' : ''}>>=</option>
|
|
||||||
<option value="<=" ${filter.operator === '<=' ? 'selected' : ''}><=</option>
|
|
||||||
<option value="LIKE" ${filter.operator === 'LIKE' ? 'selected' : ''}>Содержит</option>
|
|
||||||
<option value="ILIKE" ${filter.operator === 'ILIKE' ? 'selected' : ''}>Содержит (регистронезависимо)</option>
|
|
||||||
<option value="IS NULL" ${filter.operator === 'IS NULL' ? 'selected' : ''}>Пустое</option>
|
|
||||||
<option value="IS NOT NULL" ${filter.operator === 'IS NOT NULL' ? 'selected' : ''}>Не пустое</option>
|
|
||||||
</select>
|
|
||||||
<input type="text" value="${filter.value || ''}" class="flex-1 px-3 py-2 border border-slate-300 rounded focus:ring-2 focus:ring-blue-500 outline-none" placeholder="Значение" oninput="app.updateFilterValue(${index}, this.value)" ${filter.operator === 'IS NULL' || filter.operator === 'IS NOT NULL' ? 'disabled' : ''}>
|
|
||||||
<button onclick="app.removeFilter(${index})" class="p-2 text-red-600 hover:bg-red-50 rounded">
|
|
||||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
lucide.createIcons();
|
|
||||||
}
|
|
||||||
|
|
||||||
addFilter() {
|
|
||||||
this.filters.push({ column: '', operator: '=', value: '' });
|
|
||||||
this.renderFilters();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFilterColumn(index, column) {
|
updateFilterColumn(index, column) {
|
||||||
this.filters[index].column = column;
|
this.filters[index].column = column;
|
||||||
this.renderFilters(); // Re-render to update disabled state
|
this.renderFilters(); // Re-render to update disabled state
|
||||||
@@ -1239,23 +1181,28 @@ SELECT * FROM users LIMIT 10;"></textarea>
|
|||||||
this.renderFilters(); // Re-render to update disabled state
|
this.renderFilters(); // Re-render to update disabled state
|
||||||
}
|
}
|
||||||
|
|
||||||
updateFilterValue(index, value) {
|
toggleFilters() {
|
||||||
this.filters[index].value = value;
|
const filterRow = document.getElementById('filterRow');
|
||||||
|
filterRow.classList.toggle('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
removeFilter(index) {
|
updateFilter(column, value) {
|
||||||
this.filters.splice(index, 1);
|
if (value.trim()) {
|
||||||
this.renderFilters();
|
this.filters[column] = value.trim();
|
||||||
|
} else {
|
||||||
|
delete this.filters[column];
|
||||||
|
}
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.loadTableData();
|
||||||
}
|
}
|
||||||
|
|
||||||
clearFilters() {
|
clearFilters() {
|
||||||
this.filters = [];
|
this.filters = {};
|
||||||
this.renderFilters();
|
// Update all input values
|
||||||
}
|
document.querySelectorAll('#filterInputs input').forEach(input => {
|
||||||
|
input.value = '';
|
||||||
applyFilters() {
|
});
|
||||||
this.currentPage = 1;
|
this.currentPage = 1;
|
||||||
this.closeModal('filtersModal');
|
|
||||||
this.loadTableData();
|
this.loadTableData();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1266,7 +1213,7 @@ SELECT * FROM users LIMIT 10;"></textarea>
|
|||||||
|
|
||||||
// Find primary key
|
// Find primary key
|
||||||
const pkColumn = this.tableStructure.find(col => col.is_primary);
|
const pkColumn = this.tableStructure.find(col => col.is_primary);
|
||||||
this.primaryKey = pkColumn ? pkColumn.name : null;
|
this.primaryKey = pkColumn ? pkColumn.name : (this.tableStructure.find(col => col.name === 'id') ? 'id' : null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.showToast('Ошибка загрузки структуры таблицы', 'error');
|
this.showToast('Ошибка загрузки структуры таблицы', 'error');
|
||||||
}
|
}
|
||||||
|
|||||||
111
server.js
111
server.js
@@ -179,8 +179,8 @@ app.get('/api/tables/:tableName/data', requireAuth, async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
let whereClause = '';
|
let whereClause = '';
|
||||||
let params = [limit, offset];
|
let params = [];
|
||||||
let paramIndex = 3; // $1=limit, $2=offset, $3+ for conditions
|
let paramIndex = 1;
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
if (search) {
|
if (search) {
|
||||||
@@ -199,68 +199,23 @@ app.get('/api/tables/:tableName/data', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
if (filters.length > 0) {
|
if (filters && typeof filters === 'object') {
|
||||||
const filterConditions = filters.map(filter => {
|
const filterConditions = Object.entries(filters).map(([column, value]) => {
|
||||||
const { column, operator, value } = filter;
|
if (value && value.trim()) {
|
||||||
let condition = '';
|
|
||||||
|
|
||||||
switch (operator) {
|
|
||||||
case '=':
|
|
||||||
condition = `"${column}" = $${paramIndex}`;
|
|
||||||
params.push(value);
|
|
||||||
paramIndex++;
|
|
||||||
break;
|
|
||||||
case '!=':
|
|
||||||
condition = `"${column}" != $${paramIndex}`;
|
|
||||||
params.push(value);
|
|
||||||
paramIndex++;
|
|
||||||
break;
|
|
||||||
case '>':
|
|
||||||
condition = `"${column}" > $${paramIndex}`;
|
|
||||||
params.push(value);
|
|
||||||
paramIndex++;
|
|
||||||
break;
|
|
||||||
case '<':
|
|
||||||
condition = `"${column}" < $${paramIndex}`;
|
|
||||||
params.push(value);
|
|
||||||
paramIndex++;
|
|
||||||
break;
|
|
||||||
case '>=':
|
|
||||||
condition = `"${column}" >= $${paramIndex}`;
|
|
||||||
params.push(value);
|
|
||||||
paramIndex++;
|
|
||||||
break;
|
|
||||||
case '<=':
|
|
||||||
condition = `"${column}" <= $${paramIndex}`;
|
|
||||||
params.push(value);
|
|
||||||
paramIndex++;
|
|
||||||
break;
|
|
||||||
case 'LIKE':
|
|
||||||
condition = `"${column}" LIKE $${paramIndex}`;
|
|
||||||
params.push(`%${value}%`);
|
params.push(`%${value}%`);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
break;
|
return `"${column}" ILIKE $${paramIndex - 1}`;
|
||||||
case 'ILIKE':
|
|
||||||
condition = `"${column}" ILIKE $${paramIndex}`;
|
|
||||||
params.push(`%${value}%`);
|
|
||||||
paramIndex++;
|
|
||||||
break;
|
|
||||||
case 'IS NULL':
|
|
||||||
condition = `"${column}" IS NULL`;
|
|
||||||
break;
|
|
||||||
case 'IS NOT NULL':
|
|
||||||
condition = `"${column}" IS NOT NULL`;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}).filter(c => c);
|
||||||
|
|
||||||
return condition;
|
if (filterConditions.length > 0) {
|
||||||
}).filter(c => c).join(' AND ');
|
whereClause = whereClause ? `${whereClause} AND ${filterConditions.join(' AND ')}` : `WHERE ${filterConditions.join(' AND ')}`;
|
||||||
|
}
|
||||||
whereClause = whereClause ? `${whereClause} AND ${filterConditions}` : `WHERE ${filterConditions}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get total count
|
// Get total count
|
||||||
const countResult = await pool.query(`SELECT COUNT(*) as total FROM "${tableName}" ${whereClause}`, params.slice(2));
|
const countResult = await pool.query(`SELECT COUNT(*) as total FROM "${tableName}" ${whereClause}`, params);
|
||||||
const total = parseInt(countResult.rows[0].total);
|
const total = parseInt(countResult.rows[0].total);
|
||||||
|
|
||||||
// Build ORDER BY
|
// Build ORDER BY
|
||||||
@@ -274,8 +229,8 @@ app.get('/api/tables/:tableName/data', requireAuth, async (req, res) => {
|
|||||||
SELECT * FROM "${tableName}"
|
SELECT * FROM "${tableName}"
|
||||||
${whereClause}
|
${whereClause}
|
||||||
${orderBy}
|
${orderBy}
|
||||||
LIMIT $1 OFFSET $2
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||||
`, params);
|
`, [...params, limit, offset]);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
data: result.rows,
|
data: result.rows,
|
||||||
@@ -358,11 +313,43 @@ app.post('/api/tables/:tableName/records', requireAuth, async (req, res) => {
|
|||||||
const { tableName } = req.params;
|
const { tableName } = req.params;
|
||||||
const data = req.body;
|
const data = req.body;
|
||||||
|
|
||||||
const columns = Object.keys(data);
|
try {
|
||||||
const values = Object.values(data);
|
// Get table structure to check types
|
||||||
|
const structureResult = await pool.query(`
|
||||||
|
SELECT column_name, data_type
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND table_schema = 'public'
|
||||||
|
`, [tableName]);
|
||||||
|
|
||||||
|
const structure = structureResult.rows;
|
||||||
|
|
||||||
|
// Filter out empty UUIDs and generate if needed
|
||||||
|
const filteredData = {};
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
const colInfo = structure.find(col => col.column_name === key);
|
||||||
|
if (colInfo && colInfo.data_type === 'uuid') {
|
||||||
|
if (!value || value.trim() === '') {
|
||||||
|
// Generate UUID for empty UUID fields
|
||||||
|
filteredData[key] = require('crypto').randomUUID();
|
||||||
|
} else {
|
||||||
|
// Validate UUID
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
if (uuidRegex.test(value)) {
|
||||||
|
filteredData[key] = value;
|
||||||
|
} else {
|
||||||
|
// Invalid UUID, generate new one
|
||||||
|
filteredData[key] = require('crypto').randomUUID();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (value !== '') {
|
||||||
|
filteredData[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = Object.keys(filteredData);
|
||||||
|
const values = Object.values(filteredData);
|
||||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
|
const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
|
||||||
|
|
||||||
try {
|
|
||||||
const sql = `INSERT INTO "${tableName}" (${columns.map(c => `"${c}"`).join(', ')}) VALUES (${placeholders}) RETURNING *`;
|
const sql = `INSERT INTO "${tableName}" (${columns.map(c => `"${c}"`).join(', ')}) VALUES (${placeholders}) RETURNING *`;
|
||||||
const result = await pool.query(sql, values);
|
const result = await pool.query(sql, values);
|
||||||
res.json({ success: true, data: result.rows[0] });
|
res.json({ success: true, data: result.rows[0] });
|
||||||
|
|||||||
Reference in New Issue
Block a user