refactor(FilterManager): simplify notification handling and improve layout structure for enhanced user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m0s

This commit is contained in:
2026-02-16 21:19:05 +07:00
parent 8ad4f23a49
commit 5af5abb02d
+166 -128
View File
@@ -1740,32 +1740,32 @@ function FilterManager() {
return (
<div className="page-wrapper">
<>
{/* Уведомления */}
{error && (
<div className="alert alert-danger alert-dismissible" role="alert">
<IconAlertTriangle className="me-2" />
{error}
<button type="button" className="btn-close" onClick={() => setError('')}></button>
</div>
)}
{success && (
<div className="alert alert-success alert-dismissible" role="alert">
<IconCheck className="me-2" />
{success}
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
</div>
)}
<PageHeader
title="Управление фильтрами frouting"
icon={<IconFilter size={24} />}
/>
<div className="page-body">
<div className="container-xl">
{/* Уведомления */}
{error && (
<div className="alert alert-danger alert-dismissible" role="alert">
<IconAlertTriangle className="me-2" />
{error}
<button type="button" className="btn-close" onClick={() => setError('')}></button>
</div>
)}
{success && (
<div className="alert alert-success alert-dismissible" role="alert">
<IconCheck className="me-2" />
{success}
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
</div>
)}
{/* Селектор серверов на одной странице (Tabler UI) */}
<div className="card mb-3">
<div className="row">
<div className="col-12">
{/* Селектор серверов на одной странице (Tabler UI) */}
<div className="card mb-3">
<div className="card-header">
<h3 className="card-title d-flex align-items-center gap-2">
<IconServer className="icon" />
@@ -1976,9 +1976,9 @@ function FilterManager() {
</div>
</div>
{/* Секция фильтров выбранного сервера */}
{selectedServer && (
<div className="card">
{/* Секция фильтров выбранного сервера */}
{selectedServer && (
<div className="card">
<div className="card-header">
<div className="row align-items-center">
<div className="col">
@@ -2051,113 +2051,151 @@ function FilterManager() {
</div>
) : (
<>
<div className="alert alert-info mb-3">
<div className="d-flex align-items-center">
<IconGripVertical size={18} className="me-2" />
<span>Перетащите строки за иконку <IconGripVertical size={14} className="mx-1" /> чтобы изменить приоритет</span>
</div>
<div className="text-muted small mt-1">
<strong>Важно:</strong> В RouterOS правила внизу списка имеют <strong>высший приоритет</strong> (переопределяют верхние)
</div>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{ width: '40px' }}></th>
<th className="w-1">
<input
className="form-check-input m-0 align-middle"
type="checkbox"
aria-label="Выбрать все"
checked={areAllProcessedSelected}
onChange={toggleSelectAllProcessed}
/>
</th>
<th style={{ width: '60px' }}>#</th>
<th style={{ width: '22%' }} className="cursor-pointer" onClick={() => toggleSort('community')}>
<span className="me-1">COMMUNITY</span>
{sortBy === 'community' && (sortDir === 'asc' ? <IconSortAscending size={16} /> : <IconSortDescending size={16} />)}
</th>
<th style={{ width: '22%' }} className="cursor-pointer" onClick={() => toggleSort('gateway')}>
<span className="me-1">GATEWAY</span>
{sortBy === 'gateway' && (sortDir === 'asc' ? <IconSortAscending size={16} /> : <IconSortDescending size={16} />)}
</th>
<th style={{ width: '30%' }} className="cursor-pointer" onClick={() => toggleSort('description')}>
<span className="me-1">ОПИСАНИЕ</span>
{sortBy === 'description' && (sortDir === 'asc' ? <IconSortAscending size={16} /> : <IconSortDescending size={16} />)}
</th>
<th style={{ width: '120px' }} className="text-end">ДЕЙСТВИЯ</th>
</tr>
</thead>
<SortableContext
items={serverFilters.filter(f => f != null).map(f => filterKey(f))}
strategy={verticalListSortingStrategy}
>
<tbody>
{serverFilters.filter(f => f != null).map((filter, index) => {
const key = filterKey(filter);
const isSelected = selectedFilterKeys.has(key);
// Показываем только если совпадает с поиском
if (filterSearch.trim()) {
const search = filterSearch.trim().toLowerCase();
const c = String(filter.community || '').toLowerCase();
const g = String(filter.gateway || '').toLowerCase();
const d = String(filter.description || '').toLowerCase();
if (!c.includes(search) && !g.includes(search) && !d.includes(search)) {
return null;
}
}
return (
<SortableFilterRow
key={key}
filter={filter}
index={index}
totalCount={serverFilters.filter(f => f != null).length}
filterKey={key}
isSelected={isSelected}
onToggleSelect={() => toggleSelectFilter(key)}
onEdit={() => handleEditFilter(filter, index)}
onDelete={() => {
setFilterToDelete(filter);
setDeleteFilterModalOpen(true);
}}
communities={communities}
baseAS={baseAS}
selectedServerGateways={selectedServerGateways}
/>
);
})}
</tbody>
</SortableContext>
</table>
</div>
</DndContext>
{selectedFilterKeys.size > 0 && (
<div className="d-flex justify-content-between align-items-center mt-3">
<div className="text-muted">Выбрано: {selectedFilterKeys.size}</div>
<div className="btn-list">
<button className="btn btn-outline-danger btn-sm" onClick={deleteSelectedFilters}>
<IconTrash className="me-2" /> Удалить выбранные
</button>
<button className="btn btn-outline-secondary btn-sm" onClick={() => setSelectedFilterKeys(new Set())}>
<IconX className="me-2" /> Отменить выбор
</button>
<div className="alert alert-info mb-3">
<div className="d-flex align-items-center">
<IconGripVertical size={18} className="me-2" />
<span>
Перетащите строки за иконку{' '}
<IconGripVertical size={14} className="mx-1" /> чтобы изменить приоритет
</span>
</div>
<div className="text-muted small mt-1">
<strong>Важно:</strong> В RouterOS правила внизу списка имеют{' '}
<strong>высший приоритет</strong> (переопределяют верхние)
</div>
</div>
)}
</>
)}
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{ width: '40px' }}></th>
<th className="w-1">
<input
className="form-check-input m-0 align-middle"
type="checkbox"
aria-label="Выбрать все"
checked={areAllProcessedSelected}
onChange={toggleSelectAllProcessed}
/>
</th>
<th style={{ width: '60px' }}>#</th>
<th
style={{ width: '22%' }}
className="cursor-pointer"
onClick={() => toggleSort('community')}
>
<span className="me-1">COMMUNITY</span>
{sortBy === 'community' &&
(sortDir === 'asc' ? (
<IconSortAscending size={16} />
) : (
<IconSortDescending size={16} />
))}
</th>
<th
style={{ width: '22%' }}
className="cursor-pointer"
onClick={() => toggleSort('gateway')}
>
<span className="me-1">GATEWAY</span>
{sortBy === 'gateway' &&
(sortDir === 'asc' ? (
<IconSortAscending size={16} />
) : (
<IconSortDescending size={16} />
))}
</th>
<th
style={{ width: '30%' }}
className="cursor-pointer"
onClick={() => toggleSort('description')}
>
<span className="me-1">ОПИСАНИЕ</span>
{sortBy === 'description' &&
(sortDir === 'asc' ? (
<IconSortAscending size={16} />
) : (
<IconSortDescending size={16} />
))}
</th>
<th style={{ width: '120px' }} className="text-end">
ДЕЙСТВИЯ
</th>
</tr>
</thead>
<SortableContext
items={serverFilters.filter((f) => f != null).map((f) => filterKey(f))}
strategy={verticalListSortingStrategy}
>
<tbody>
{serverFilters
.filter((f) => f != null)
.map((filter, index) => {
const key = filterKey(filter);
const isSelected = selectedFilterKeys.has(key);
// Показываем только если совпадает с поиском
if (filterSearch.trim()) {
const search = filterSearch.trim().toLowerCase();
const c = String(filter.community || '').toLowerCase();
const g = String(filter.gateway || '').toLowerCase();
const d = String(filter.description || '').toLowerCase();
if (!c.includes(search) && !g.includes(search) && !d.includes(search)) {
return null;
}
}
return (
<SortableFilterRow
key={key}
filter={filter}
index={index}
totalCount={serverFilters.filter((f) => f != null).length}
filterKey={key}
isSelected={isSelected}
onToggleSelect={() => toggleSelectFilter(key)}
onEdit={() => handleEditFilter(filter, index)}
onDelete={() => {
setFilterToDelete(filter);
setDeleteFilterModalOpen(true);
}}
communities={communities}
baseAS={baseAS}
selectedServerGateways={selectedServerGateways}
/>
);
})}
</tbody>
</SortableContext>
</table>
</div>
</DndContext>
{selectedFilterKeys.size > 0 && (
<div className="d-flex justify-content-between align-items-center mt-3">
<div className="text-muted">Выбрано: {selectedFilterKeys.size}</div>
<div className="btn-list">
<button className="btn btn-outline-danger btn-sm" onClick={deleteSelectedFilters}>
<IconTrash className="me-2" /> Удалить выбранные
</button>
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => setSelectedFilterKeys(new Set())}
>
<IconX className="me-2" /> Отменить выбор
</button>
</div>
</div>
)}
</>
)}
</div>
)}
</div>
</div>
)}
</div>
</div>
{/* Модальные окна */}
<FormModal
@@ -2635,7 +2673,7 @@ function FilterManager() {
onCancel={() => setConfirmState({ open: false, text: '', onConfirm: null })}
onConfirm={() => confirmState.onConfirm?.()}
/>
</div>
</>
);
}