This commit is contained in:
2026-03-18 17:10:46 +07:00
parent 9f9d41f14a
commit 9fa65ba4af
2 changed files with 201 additions and 46 deletions

View File

@@ -162,6 +162,10 @@
</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">
<i data-lucide="filter" class="w-4 h-4"></i>
Фильтры
</button>
<div class="text-sm text-slate-600" id="recordCount"> <div class="text-sm text-slate-600" id="recordCount">
<!-- Record count will be shown here --> <!-- Record count will be shown here -->
</div> </div>
@@ -389,49 +393,32 @@ SELECT * FROM users LIMIT 10;"></textarea>
</div> </div>
</div> </div>
<!-- Column Modal --> <!-- Filters Modal -->
<div id="columnModal" class="hidden fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4"> <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-md fade-in"> <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"> <div class="p-6 border-b border-slate-200 flex items-center justify-between">
<h3 id="columnModalTitle" class="text-xl font-bold text-slate-800">Добавить колонку</h3> <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>
<div class="p-6 space-y-4"> <div class="p-6 overflow-y-auto flex-1">
<div> <div id="filtersContainer" class="space-y-4">
<label class="block text-sm font-medium text-slate-700 mb-1">Название колонки</label> <!-- Filters will be added here -->
<input type="text" id="columnName" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none" placeholder="column_name">
</div> </div>
<div> <div class="mt-4 flex justify-end">
<label class="block text-sm font-medium text-slate-700 mb-1">Тип данных</label> <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">
<select id="columnType" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none"> <i data-lucide="plus" class="w-4 h-4"></i>
<option value="VARCHAR(255)">VARCHAR(255)</option> Добавить фильтр
<option value="TEXT">TEXT</option> </button>
<option value="INTEGER">INTEGER</option>
<option value="BIGINT">BIGINT</option>
<option value="DECIMAL">DECIMAL</option>
<option value="BOOLEAN">BOOLEAN</option>
<option value="DATE">DATE</option>
<option value="TIMESTAMP">TIMESTAMP</option>
<option value="UUID">UUID</option>
<option value="JSON">JSON</option>
<option value="JSONB">JSONB</option>
</select>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="columnNullable" class="w-4 h-4 text-blue-600 rounded focus:ring-blue-500">
<label for="columnNullable" class="text-sm text-slate-700">Разрешить NULL</label>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Значение по умолчанию</label>
<input type="text" id="columnDefault" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none" placeholder="NULL">
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="columnPrimary" class="w-4 h-4 text-blue-600 rounded focus:ring-blue-500">
<label for="columnPrimary" class="text-sm text-slate-700">Первичный ключ</label>
</div> </div>
</div> </div>
<div class="p-6 border-t border-slate-200 bg-slate-50 flex justify-end gap-3"> <div class="p-6 border-t border-slate-200 bg-slate-50 flex justify-between">
<button onclick="app.closeModal('columnModal')" class="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg">Отмена</button> <button onclick="app.clearFilters()" class="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg">Очистить</button>
<button onclick="app.saveColumn()" class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white 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> </div>
</div> </div>
@@ -452,6 +439,9 @@ 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.sortColumn = '';
this.sortDirection = 'ASC';
this.init(); this.init();
} }
@@ -598,7 +588,10 @@ SELECT * FROM users LIMIT 10;"></textarea>
const params = new URLSearchParams({ const params = new URLSearchParams({
page: this.currentPage, page: this.currentPage,
limit: this.limit, limit: this.limit,
search: searchQuery search: searchQuery,
filters: JSON.stringify(this.filters),
sortColumn: this.sortColumn,
sortDirection: this.sortDirection
}); });
const response = await fetch(`/api/tables/${this.currentTable}/data?${params}`); const response = await fetch(`/api/tables/${this.currentTable}/data?${params}`);
@@ -623,7 +616,11 @@ SELECT * FROM users LIMIT 10;"></textarea>
// Generate headers // Generate headers
const columns = Object.keys(records[0]); const columns = Object.keys(records[0]);
headers.innerHTML = columns.map(col => `<th class="text-left p-3">${col}</th>`).join('') + '<th class="text-left p-3">Действия</th>'; headers.innerHTML = columns.map(col => {
const isSorted = this.sortColumn === col;
const arrow = isSorted ? (this.sortDirection === 'ASC' ? '↑' : '↓') : '';
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 rows // Generate rows
body.innerHTML = records.map(record => { body.innerHTML = records.map(record => {
@@ -647,6 +644,23 @@ SELECT * FROM users LIMIT 10;"></textarea>
}).join(''); }).join('');
} }
sortBy(column) {
if (this.sortColumn === column) {
if (this.sortDirection === 'ASC') {
this.sortDirection = 'DESC';
} else {
// Third click: remove sorting
this.sortColumn = '';
this.sortDirection = 'ASC';
}
} else {
this.sortColumn = column;
this.sortDirection = 'ASC';
}
this.currentPage = 1;
this.loadTableData();
}
editRecord(pkValue) { editRecord(pkValue) {
// Find the record by primary key // Find the record by primary key
// Since we have the data, we can find it // Since we have the data, we can find it
@@ -1176,6 +1190,75 @@ 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
}
updateFilterOperator(index, operator) {
this.filters[index].operator = operator;
this.renderFilters(); // Re-render to update disabled state
}
updateFilterValue(index, value) {
this.filters[index].value = value;
}
removeFilter(index) {
this.filters.splice(index, 1);
this.renderFilters();
}
clearFilters() {
this.filters = [];
this.renderFilters();
}
applyFilters() {
this.currentPage = 1;
this.closeModal('filtersModal');
this.loadTableData();
}
async loadTableStructure() { async loadTableStructure() {
try { try {
const response = await fetch(`/api/tables/${this.currentTable}/structure`); const response = await fetch(`/api/tables/${this.currentTable}/structure`);
@@ -1246,7 +1329,7 @@ SELECT * FROM users LIMIT 10;"></textarea>
// Close modals on outside click // Close modals on outside click
window.onclick = function(event) { window.onclick = function(event) {
if (event.target.classList.contains('fixed')) { if (event.target.classList.contains('fixed') && event.target.id !== 'loginScreen') {
event.target.classList.add('hidden'); event.target.classList.add('hidden');
} }
} }

View File

@@ -166,20 +166,24 @@ app.get('/api/tables', requireAuth, async (req, res) => {
} }
}); });
// Get table data with pagination and search // Get table data with pagination, search, filters and sort
app.get('/api/tables/:tableName/data', requireAuth, async (req, res) => { app.get('/api/tables/:tableName/data', requireAuth, async (req, res) => {
const { tableName } = req.params; const { tableName } = req.params;
const page = parseInt(req.query.page) || 1; const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10; const limit = parseInt(req.query.limit) || 10;
const search = req.query.search || ''; const search = req.query.search || '';
const filters = req.query.filters ? JSON.parse(req.query.filters) : [];
const sortColumn = req.query.sortColumn || '';
const sortDirection = req.query.sortDirection || 'ASC';
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
try { try {
let whereClause = ''; let whereClause = '';
let params = [limit, offset]; let params = [limit, offset];
let paramIndex = 3; // $1=limit, $2=offset, $3+ for conditions
// Search
if (search) { if (search) {
// Get column names for search
const columnsResult = await pool.query(` const columnsResult = await pool.query(`
SELECT column_name SELECT column_name
FROM information_schema.columns FROM information_schema.columns
@@ -188,20 +192,88 @@ app.get('/api/tables/:tableName/data', requireAuth, async (req, res) => {
`, [tableName]); `, [tableName]);
const columns = columnsResult.rows.map(row => row.column_name); const columns = columnsResult.rows.map(row => row.column_name);
const searchConditions = columns.map(col => `CAST("${col}" AS TEXT) ILIKE $${params.length + 1}`).join(' OR '); const searchConditions = columns.map(col => `CAST("${col}" AS TEXT) ILIKE $${paramIndex}`).join(' OR ');
whereClause = `WHERE ${searchConditions}`; whereClause = `WHERE ${searchConditions}`;
params.push(`%${search}%`); params.push(`%${search}%`);
paramIndex++;
}
// 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;
}
return condition;
}).filter(c => c).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.slice(2));
const total = parseInt(countResult.rows[0].total); const total = parseInt(countResult.rows[0].total);
// Build ORDER BY
let orderBy = 'ORDER BY (SELECT NULL)'; // Default no order
if (sortColumn) {
orderBy = `ORDER BY "${sortColumn}" ${sortDirection}`;
}
// Get data // Get data
const result = await pool.query(` const result = await pool.query(`
SELECT * FROM "${tableName}" SELECT * FROM "${tableName}"
${whereClause} ${whereClause}
ORDER BY (SELECT NULL) -- No specific order, but consistent ${orderBy}
LIMIT $1 OFFSET $2 LIMIT $1 OFFSET $2
`, params); `, params);