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 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
117
server.js
@@ -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 null;
|
||||
}).filter(c => c);
|
||||
|
||||
return condition;
|
||||
}).filter(c => c).join(' AND ');
|
||||
|
||||
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] });
|
||||
|
||||
Reference in New Issue
Block a user