This commit is contained in:
2026-03-18 17:19:02 +07:00
parent 9fa65ba4af
commit d1ba0eb58b
3 changed files with 96 additions and 148 deletions

14
favicon.svg Normal file
View 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

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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://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">
@@ -162,7 +163,7 @@
</div>
<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" />
<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>
Фильтры
</button>
@@ -186,6 +187,9 @@
<thead class="bg-slate-50 text-slate-600 font-medium border-b border-slate-200">
<tr id="tableHeaders"></tr>
</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>
</table>
</div>
@@ -393,36 +397,6 @@ SELECT * FROM users LIMIT 10;"></textarea>
</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 -->
<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.primaryKey = null;
this.tableStructure = [];
this.filters = [];
this.filters = {};
this.sortColumn = '';
this.sortDirection = 'ASC';
this.init();
@@ -606,10 +580,12 @@ SELECT * FROM users LIMIT 10;"></textarea>
renderTableData(records) {
const headers = document.getElementById('tableHeaders');
const filterInputs = document.getElementById('filterInputs');
const body = document.getElementById('tableBody');
if (records.length === 0) {
headers.innerHTML = '';
filterInputs.innerHTML = '';
body.innerHTML = '<tr><td colspan="100%" class="text-center py-8 text-slate-500">Нет данных</td></tr>';
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>`;
}).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
body.innerHTML = records.map(record => {
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) {
this.filters[index].column = column;
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
}
updateFilterValue(index, value) {
this.filters[index].value = value;
toggleFilters() {
const filterRow = document.getElementById('filterRow');
filterRow.classList.toggle('hidden');
}
removeFilter(index) {
this.filters.splice(index, 1);
this.renderFilters();
updateFilter(column, value) {
if (value.trim()) {
this.filters[column] = value.trim();
} else {
delete this.filters[column];
}
this.currentPage = 1;
this.loadTableData();
}
clearFilters() {
this.filters = [];
this.renderFilters();
}
applyFilters() {
this.filters = {};
// Update all input values
document.querySelectorAll('#filterInputs input').forEach(input => {
input.value = '';
});
this.currentPage = 1;
this.closeModal('filtersModal');
this.loadTableData();
}
@@ -1266,7 +1213,7 @@ SELECT * FROM users LIMIT 10;"></textarea>
// Find primary key
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) {
this.showToast('Ошибка загрузки структуры таблицы', 'error');
}

117
server.js
View File

@@ -179,8 +179,8 @@ app.get('/api/tables/:tableName/data', requireAuth, async (req, res) => {
try {
let whereClause = '';
let params = [limit, offset];
let paramIndex = 3; // $1=limit, $2=offset, $3+ for conditions
let params = [];
let paramIndex = 1;
// Search
if (search) {
@@ -199,68 +199,23 @@ app.get('/api/tables/:tableName/data', requireAuth, async (req, res) => {
}
// Filters
if (filters.length > 0) {
const filterConditions = filters.map(filter => {
const { column, operator, value } = filter;
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}%`);
paramIndex++;
break;
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;
if (filters && typeof filters === 'object') {
const filterConditions = Object.entries(filters).map(([column, value]) => {
if (value && value.trim()) {
params.push(`%${value}%`);
paramIndex++;
return `"${column}" ILIKE $${paramIndex - 1}`;
}
return condition;
}).filter(c => c).join(' AND ');
return null;
}).filter(c => c);
whereClause = whereClause ? `${whereClause} AND ${filterConditions}` : `WHERE ${filterConditions}`;
if (filterConditions.length > 0) {
whereClause = whereClause ? `${whereClause} AND ${filterConditions.join(' AND ')}` : `WHERE ${filterConditions.join(' AND ')}`;
}
}
// 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);
// Build ORDER BY
@@ -274,8 +229,8 @@ app.get('/api/tables/:tableName/data', requireAuth, async (req, res) => {
SELECT * FROM "${tableName}"
${whereClause}
${orderBy}
LIMIT $1 OFFSET $2
`, params);
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`, [...params, limit, offset]);
res.json({
data: result.rows,
@@ -358,11 +313,43 @@ app.post('/api/tables/:tableName/records', requireAuth, async (req, res) => {
const { tableName } = req.params;
const data = req.body;
const columns = Object.keys(data);
const values = Object.values(data);
const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
try {
// 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 sql = `INSERT INTO "${tableName}" (${columns.map(c => `"${c}"`).join(', ')}) VALUES (${placeholders}) RETURNING *`;
const result = await pool.query(sql, values);
res.json({ success: true, data: result.rows[0] });