feat: Добавление функциональности массового выбора и действий в компоненты ASNsNewManager, DomainsNewManager и IPRangesManager. Реализованы функции для выбора всех элементов, удаления и экспорта выбранных записей, а также добавлены новые иконки и улучшены стили для повышения удобства использования.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m45s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m45s
This commit is contained in:
+134
-73
@@ -19,7 +19,8 @@ import {
|
|||||||
IconFileText,
|
IconFileText,
|
||||||
IconFilter,
|
IconFilter,
|
||||||
IconArrowUp,
|
IconArrowUp,
|
||||||
IconArrowDown
|
IconArrowDown,
|
||||||
|
IconCopy
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
|
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
|
||||||
import S3MetaBar from './components/S3MetaBar.jsx';
|
import S3MetaBar from './components/S3MetaBar.jsx';
|
||||||
@@ -37,6 +38,8 @@ import ImportModal from './components/ImportModal.jsx';
|
|||||||
import QuickAddBar from './components/QuickAddBar.jsx';
|
import QuickAddBar from './components/QuickAddBar.jsx';
|
||||||
import AccordionCard from './components/AccordionCard.jsx';
|
import AccordionCard from './components/AccordionCard.jsx';
|
||||||
import SavedFilters from './components/SavedFilters.jsx';
|
import SavedFilters from './components/SavedFilters.jsx';
|
||||||
|
import BulkActionsBar from './components/BulkActionsBar.jsx';
|
||||||
|
import Tooltip from './components/Tooltip.jsx';
|
||||||
import { getAsnName, getAsnNameSync } from './lib/asn.js';
|
import { getAsnName, getAsnNameSync } from './lib/asn.js';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
@@ -67,6 +70,7 @@ function ASNsNewManager() {
|
|||||||
const editInputRef = useRef(null);
|
const editInputRef = useRef(null);
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
const [asnNameMap, setAsnNameMap] = useState({});
|
const [asnNameMap, setAsnNameMap] = useState({});
|
||||||
|
const [selectedItems, setSelectedItems] = useState(new Set());
|
||||||
|
|
||||||
// Справочник community для подсказок
|
// Справочник community для подсказок
|
||||||
const [communities, setCommunities] = useState([]);
|
const [communities, setCommunities] = useState([]);
|
||||||
@@ -161,6 +165,65 @@ function ASNsNewManager() {
|
|||||||
|
|
||||||
const handleDeleteItem = (asnToDelete) => {
|
const handleDeleteItem = (asnToDelete) => {
|
||||||
setItems(items.filter(i => i.asn !== asnToDelete));
|
setItems(items.filter(i => i.asn !== asnToDelete));
|
||||||
|
setSelectedItems(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(asnToDelete);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функции для работы с выбранными элементами
|
||||||
|
const toggleSelectItem = (asn) => {
|
||||||
|
setSelectedItems(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(asn)) {
|
||||||
|
next.delete(asn);
|
||||||
|
} else {
|
||||||
|
next.add(asn);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAll = () => {
|
||||||
|
setSelectedItems(new Set(paginatedItems.map(i => i.asn)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const deselectAll = () => {
|
||||||
|
setSelectedItems(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkDelete = () => {
|
||||||
|
if (selectedItems.size === 0) return;
|
||||||
|
const count = selectedItems.size;
|
||||||
|
setItems(items.filter(i => !selectedItems.has(i.asn)));
|
||||||
|
setSelectedItems(new Set());
|
||||||
|
window.notify?.success?.(`Удалено ${count} записей`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkExport = () => {
|
||||||
|
if (selectedItems.size === 0) return;
|
||||||
|
const selected = items.filter(i => selectedItems.has(i.asn));
|
||||||
|
const header = ['asn', 'community'];
|
||||||
|
const csv = [header, ...selected.map(i => [i.asn, i.community])]
|
||||||
|
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
||||||
|
.join('\n');
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `asns_selected_${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
window.notify?.success?.(`Экспортировано ${selectedItems.size} записей`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text) => {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
window.notify?.success?.('Скопировано в буфер обмена');
|
||||||
|
}).catch(() => {
|
||||||
|
window.notify?.error?.('Не удалось скопировать');
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = (item) => {
|
const confirmDelete = (item) => {
|
||||||
@@ -491,66 +554,6 @@ function ASNsNewManager() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{/* Статистические карточки */}
|
|
||||||
<div className="row g-3 mb-4">
|
|
||||||
<div className="col-sm-6 col-lg-3">
|
|
||||||
<div className="card card-sm animate-in">
|
|
||||||
<div className="card-body">
|
|
||||||
<div className="row align-items-center">
|
|
||||||
<div className="col-auto">
|
|
||||||
<span className="bg-blue text-white avatar">
|
|
||||||
<IconHash size={24} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="col">
|
|
||||||
<div className="font-weight-medium">Всего ASN</div>
|
|
||||||
<div className="text-muted">
|
|
||||||
<span className="h2 m-0">{items.length}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-6 col-lg-3">
|
|
||||||
<div className="card card-sm animate-in" style={{animationDelay: '0.1s'}}>
|
|
||||||
<div className="card-body">
|
|
||||||
<div className="row align-items-center">
|
|
||||||
<div className="col-auto">
|
|
||||||
<span className="bg-green text-white avatar">
|
|
||||||
<IconFilter size={24} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="col">
|
|
||||||
<div className="font-weight-medium">Отфильтровано</div>
|
|
||||||
<div className="text-muted">
|
|
||||||
<span className="h2 m-0">{filtered.length}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-6 col-lg-3">
|
|
||||||
<div className="card card-sm animate-in" style={{animationDelay: '0.2s'}}>
|
|
||||||
<div className="card-body">
|
|
||||||
<div className="row align-items-center">
|
|
||||||
<div className="col-auto">
|
|
||||||
<span className="bg-purple text-white avatar">
|
|
||||||
<IconHash size={24} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="col">
|
|
||||||
<div className="font-weight-medium">Уникальных Community</div>
|
|
||||||
<div className="text-muted">
|
|
||||||
<span className="h2 m-0">{allCommunities.length}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-lg-3">
|
<div className="col-lg-3">
|
||||||
{/* Classic Add New ASN Card (improved) */}
|
{/* Classic Add New ASN Card (improved) */}
|
||||||
@@ -666,18 +669,32 @@ function ASNsNewManager() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-lg-9">
|
<div className="col-lg-9">
|
||||||
|
{/* BulkActionsBar */}
|
||||||
|
<BulkActionsBar
|
||||||
|
selectedCount={selectedItems.size}
|
||||||
|
totalCount={paginatedItems.length}
|
||||||
|
onSelectAll={selectAll}
|
||||||
|
onDeselectAll={deselectAll}
|
||||||
|
onDelete={selectedItems.size > 0 ? () => {
|
||||||
|
if (confirm(`Удалить ${selectedItems.size} записей?`)) {
|
||||||
|
handleBulkDelete();
|
||||||
|
}
|
||||||
|
} : null}
|
||||||
|
onExport={selectedItems.size > 0 ? handleBulkExport : null}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header d-flex justify-content-between align-items-center">
|
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
<h3 className="card-title mb-0">Список ASN <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3>
|
<h3 className="card-title mb-0">Список ASN <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3>
|
||||||
<div className="d-flex gap-2 w-50 flex-wrap">
|
<div className="d-flex gap-2 flex-wrap">
|
||||||
<SavedFilters
|
<SavedFilters
|
||||||
pageKey="asns"
|
pageKey="asns"
|
||||||
currentFilters={{ searchTerm, filterCommunity, sortField, sortOrder }}
|
currentFilters={{ searchTerm, filterCommunity, sortField, sortOrder }}
|
||||||
onApplyFilter={(filters) => {
|
onApplyFilter={(filters) => {
|
||||||
if (filters.searchTerm !== undefined) setSearchTerm(filters.searchTerm);
|
setSearchTerm(filters.searchTerm || '');
|
||||||
if (filters.filterCommunity !== undefined) setFilterCommunity(filters.filterCommunity);
|
setFilterCommunity(filters.filterCommunity || '');
|
||||||
if (filters.sortField !== undefined) setSortField(filters.sortField);
|
setSortField(filters.sortField || 'asn');
|
||||||
if (filters.sortOrder !== undefined) setSortOrder(filters.sortOrder);
|
setSortOrder(filters.sortOrder || 'asc');
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -763,6 +780,21 @@ function ASNsNewManager() {
|
|||||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style={{width: '40px'}}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
checked={paginatedItems.length > 0 && paginatedItems.every(i => selectedItems.has(i.asn))}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
selectAll();
|
||||||
|
} else {
|
||||||
|
deselectAll();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Выбрать все на странице"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
<th className="cursor-pointer user-select-none" onClick={() => handleSort('asn')}>
|
<th className="cursor-pointer user-select-none" onClick={() => handleSort('asn')}>
|
||||||
<div className="d-flex align-items-center">
|
<div className="d-flex align-items-center">
|
||||||
<IconHash size={16} className="me-1 text-muted" />
|
<IconHash size={16} className="me-1 text-muted" />
|
||||||
@@ -788,16 +820,34 @@ function ASNsNewManager() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="text-end"> </th>
|
<th className="text-end">Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{paginatedItems.map((item, idx) => (
|
{paginatedItems.map((item, idx) => (
|
||||||
<tr key={item.asn} className={editingAsn === item.asn ? 'table-active' : ''} style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}>
|
<tr key={item.asn} className={editingAsn === item.asn ? 'table-active' : (selectedItems.has(item.asn) ? 'table-selected' : '')} style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
checked={selectedItems.has(item.asn)}
|
||||||
|
onChange={() => toggleSelectItem(item.asn)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td className="font-monospace" title="ASN">
|
<td className="font-monospace" title="ASN">
|
||||||
<div className="d-flex align-items-center">
|
<div className="d-flex align-items-center">
|
||||||
<IconHash size={16} className="me-2 text-muted" />
|
<IconHash size={16} className="me-2 text-muted" />
|
||||||
<span>{item.asn}</span>
|
<span>{item.asn}</span>
|
||||||
|
<Tooltip content="Копировать ASN">
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
|
||||||
|
onClick={() => copyToClipboard(item.asn)}
|
||||||
|
aria-label="Копировать"
|
||||||
|
>
|
||||||
|
<IconCopy size={14} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td title={asnNameMap[item.asn] || ''}>
|
<td title={asnNameMap[item.asn] || ''}>
|
||||||
@@ -819,13 +869,24 @@ function ASNsNewManager() {
|
|||||||
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
||||||
style={{ width: '150px' }}
|
style={{ width: '150px' }}
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.asn)} disabled={!isValidCommunity(editingValue)} title="Сохранить (Enter)"><IconCheck size={16} /></button>
|
<Tooltip content="Сохранить (Enter)">
|
||||||
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit} title="Отмена (Esc)"><IconX size={16} /></button>
|
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.asn)} disabled={!isValidCommunity(editingValue)}><IconCheck size={16} /></button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Отмена (Esc)">
|
||||||
|
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit}><IconX size={16} /></button>
|
||||||
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)}><IconEdit size={16} /></button>
|
<Tooltip content="Редактировать">
|
||||||
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)}><IconTrash size={16} /></button>
|
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)}><IconEdit size={16} /></button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Копировать">
|
||||||
|
<button className="btn btn-outline-secondary btn-icon btn-sm me-1" aria-label="Копировать" onClick={() => copyToClipboard(`${item.asn} ${item.community}`)}><IconCopy size={16} /></button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Удалить">
|
||||||
|
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)}><IconTrash size={16} /></button>
|
||||||
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ import {
|
|||||||
IconTrendingUp,
|
IconTrendingUp,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
IconArrowUp,
|
IconArrowUp,
|
||||||
IconArrowDown
|
IconArrowDown,
|
||||||
|
IconCopy
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
|
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
|
||||||
import PageHeader from './components/PageHeader.jsx';
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
@@ -44,6 +45,7 @@ import AccordionCard from './components/AccordionCard.jsx';
|
|||||||
import SavedFilters from './components/SavedFilters.jsx';
|
import SavedFilters from './components/SavedFilters.jsx';
|
||||||
import BulkActionsBar from './components/BulkActionsBar.jsx';
|
import BulkActionsBar from './components/BulkActionsBar.jsx';
|
||||||
import FormField from './components/FormField.jsx';
|
import FormField from './components/FormField.jsx';
|
||||||
|
import Tooltip from './components/Tooltip.jsx';
|
||||||
import { useToast } from './components/ToastContainer.jsx';
|
import { useToast } from './components/ToastContainer.jsx';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
@@ -171,6 +173,19 @@ function DomainsNewManager() {
|
|||||||
|
|
||||||
const handleDeleteItem = (domainNameToDelete) => {
|
const handleDeleteItem = (domainNameToDelete) => {
|
||||||
setItems(items.filter(i => i.domain !== domainNameToDelete));
|
setItems(items.filter(i => i.domain !== domainNameToDelete));
|
||||||
|
setSelectedDomains(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(domainNameToDelete);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text) => {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
toast.success('Скопировано в буфер обмена');
|
||||||
|
}).catch(() => {
|
||||||
|
toast.error('Не удалось скопировать');
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = (item) => {
|
const confirmDelete = (item) => {
|
||||||
@@ -448,10 +463,11 @@ function DomainsNewManager() {
|
|||||||
|
|
||||||
const handleBulkDelete = () => {
|
const handleBulkDelete = () => {
|
||||||
if (selectedDomains.size === 0) return;
|
if (selectedDomains.size === 0) return;
|
||||||
|
const count = selectedDomains.size;
|
||||||
const newItems = items.filter(i => !selectedDomains.has(i.domain));
|
const newItems = items.filter(i => !selectedDomains.has(i.domain));
|
||||||
setItems(newItems);
|
setItems(newItems);
|
||||||
setSelectedDomains(new Set());
|
setSelectedDomains(new Set());
|
||||||
toast.success(`Удалено ${selectedDomains.size} доменов`);
|
toast.success(`Удалено ${count} доменов`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBulkExport = () => {
|
const handleBulkExport = () => {
|
||||||
@@ -536,96 +552,6 @@ function DomainsNewManager() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Статистические карточки */}
|
|
||||||
<div className="row g-3 mb-4">
|
|
||||||
<div className="col-sm-6 col-lg-3">
|
|
||||||
<div className="card card-sm animate-in">
|
|
||||||
<div className="card-body">
|
|
||||||
<div className="row align-items-center">
|
|
||||||
<div className="col-auto">
|
|
||||||
<span className="bg-blue text-white avatar">
|
|
||||||
<IconWorld size={24} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="col">
|
|
||||||
<div className="font-weight-medium">Всего доменов</div>
|
|
||||||
<div className="text-muted">
|
|
||||||
<span className="h2 mb-0">{items.length}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-6 col-lg-3">
|
|
||||||
<div className="card card-sm animate-in" style={{animationDelay: '0.1s'}}>
|
|
||||||
<div className="card-body">
|
|
||||||
<div className="row align-items-center">
|
|
||||||
<div className="col-auto">
|
|
||||||
<span className="bg-green text-white avatar">
|
|
||||||
<IconFilter size={24} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="col">
|
|
||||||
<div className="font-weight-medium">Отфильтровано</div>
|
|
||||||
<div className="text-muted">
|
|
||||||
<span className="h2 mb-0">{filtered.length}</span>
|
|
||||||
{filtered.length !== items.length && (
|
|
||||||
<span className="text-muted small ms-1">
|
|
||||||
({items.length - filtered.length} скрыто)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-6 col-lg-3">
|
|
||||||
<div className="card card-sm animate-in" style={{animationDelay: '0.2s'}}>
|
|
||||||
<div className="card-body">
|
|
||||||
<div className="row align-items-center">
|
|
||||||
<div className="col-auto">
|
|
||||||
<span className="bg-purple text-white avatar">
|
|
||||||
<IconUsers size={24} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="col">
|
|
||||||
<div className="font-weight-medium">Уникальных Community</div>
|
|
||||||
<div className="text-muted">
|
|
||||||
<span className="h2 mb-0">{allCommunities.length}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-6 col-lg-3">
|
|
||||||
<div className="card card-sm animate-in" style={{animationDelay: '0.3s'}}>
|
|
||||||
<div className="card-body">
|
|
||||||
<div className="row align-items-center">
|
|
||||||
<div className="col-auto">
|
|
||||||
<span className="bg-orange text-white avatar">
|
|
||||||
<IconTrendingUp size={24} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="col">
|
|
||||||
<div className="font-weight-medium">Выбрано</div>
|
|
||||||
<div className="text-muted">
|
|
||||||
<span className="h2 mb-0">{selectedDomains.size}</span>
|
|
||||||
{selectedDomains.size > 0 && (
|
|
||||||
<span className="text-muted small ms-1">
|
|
||||||
({Math.round((selectedDomains.size / filtered.length) * 100)}%)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-lg-3">
|
<div className="col-lg-3">
|
||||||
{/* Classic Add New Domain Card (improved) */}
|
{/* Classic Add New Domain Card (improved) */}
|
||||||
@@ -908,14 +834,14 @@ function DomainsNewManager() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="text-end"> </th>
|
<th className="text-end">Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{paginatedItems.map((item, idx) => (
|
{paginatedItems.map((item, idx) => (
|
||||||
<tr
|
<tr
|
||||||
key={item.domain}
|
key={item.domain}
|
||||||
className={editingDomain === item.domain ? 'table-active' : ''}
|
className={editingDomain === item.domain ? 'table-active' : (selectedDomains.has(item.domain) ? 'table-selected' : '')}
|
||||||
style={{
|
style={{
|
||||||
animation: `fadeIn 0.3s ease ${idx * 0.02}s both`
|
animation: `fadeIn 0.3s ease ${idx * 0.02}s both`
|
||||||
}}
|
}}
|
||||||
@@ -927,12 +853,22 @@ function DomainsNewManager() {
|
|||||||
checked={selectedDomains.has(item.domain)}
|
checked={selectedDomains.has(item.domain)}
|
||||||
onChange={() => handleToggleSelect(item.domain)}
|
onChange={() => handleToggleSelect(item.domain)}
|
||||||
aria-label={`Выбрать ${item.domain}`}
|
aria-label={`Выбрать ${item.domain}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="d-flex align-items-center">
|
<div className="d-flex align-items-center">
|
||||||
<IconWorld size={16} className="me-2 text-muted" />
|
<IconWorld size={16} className="me-2 text-muted" />
|
||||||
<span className="font-monospace">{item.domain}</span>
|
<span className="font-monospace">{item.domain}</span>
|
||||||
|
<Tooltip content="Копировать домен">
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
|
||||||
|
onClick={() => copyToClipboard(item.domain)}
|
||||||
|
aria-label="Копировать"
|
||||||
|
>
|
||||||
|
<IconCopy size={14} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -952,42 +888,55 @@ function DomainsNewManager() {
|
|||||||
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
||||||
style={{ width: '150px' }}
|
style={{ width: '150px' }}
|
||||||
/>
|
/>
|
||||||
<button
|
<Tooltip content="Сохранить (Enter)">
|
||||||
className="btn btn-success btn-icon btn-sm"
|
<button
|
||||||
onClick={() => handleSaveEdit(item.domain)}
|
className="btn btn-success btn-icon btn-sm"
|
||||||
disabled={!isValidCommunity(editingValue)}
|
onClick={() => handleSaveEdit(item.domain)}
|
||||||
aria-label="Сохранить"
|
disabled={!isValidCommunity(editingValue)}
|
||||||
title="Сохранить (Enter)"
|
aria-label="Сохранить"
|
||||||
>
|
>
|
||||||
<IconCheck size={16} />
|
<IconCheck size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
</Tooltip>
|
||||||
className="btn btn-secondary btn-icon btn-sm"
|
<Tooltip content="Отмена (Esc)">
|
||||||
onClick={handleCancelEdit}
|
<button
|
||||||
aria-label="Отмена"
|
className="btn btn-secondary btn-icon btn-sm"
|
||||||
title="Отмена (Esc)"
|
onClick={handleCancelEdit}
|
||||||
>
|
aria-label="Отмена"
|
||||||
<IconX size={16} />
|
>
|
||||||
</button>
|
<IconX size={16} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<Tooltip content="Редактировать">
|
||||||
className="btn btn-outline-primary btn-icon btn-sm"
|
<button
|
||||||
aria-label="Редактировать"
|
className="btn btn-outline-primary btn-icon btn-sm"
|
||||||
onClick={() => handleEdit(item)}
|
aria-label="Редактировать"
|
||||||
title="Редактировать community"
|
onClick={() => handleEdit(item)}
|
||||||
>
|
>
|
||||||
<IconEdit size={16} />
|
<IconEdit size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
</Tooltip>
|
||||||
className="btn btn-outline-danger btn-icon btn-sm"
|
<Tooltip content="Копировать">
|
||||||
aria-label="Удалить"
|
<button
|
||||||
onClick={() => confirmDelete(item)}
|
className="btn btn-outline-secondary btn-icon btn-sm"
|
||||||
title="Удалить домен"
|
aria-label="Копировать"
|
||||||
>
|
onClick={() => copyToClipboard(`${item.domain} ${item.community}`)}
|
||||||
<IconTrash size={16} />
|
>
|
||||||
</button>
|
<IconCopy size={16} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Удалить">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-danger btn-icon btn-sm"
|
||||||
|
aria-label="Удалить"
|
||||||
|
onClick={() => confirmDelete(item)}
|
||||||
|
>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+192
-112
@@ -20,7 +20,9 @@ import {
|
|||||||
IconFilter,
|
IconFilter,
|
||||||
IconArrowUp,
|
IconArrowUp,
|
||||||
IconArrowDown,
|
IconArrowDown,
|
||||||
IconNetwork
|
IconNetwork,
|
||||||
|
IconCopy,
|
||||||
|
IconCopyCheck
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
|
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
|
||||||
import S3MetaBar from './components/S3MetaBar.jsx';
|
import S3MetaBar from './components/S3MetaBar.jsx';
|
||||||
@@ -38,6 +40,8 @@ import ImportModal from './components/ImportModal.jsx';
|
|||||||
import QuickAddBar from './components/QuickAddBar.jsx';
|
import QuickAddBar from './components/QuickAddBar.jsx';
|
||||||
import AccordionCard from './components/AccordionCard.jsx';
|
import AccordionCard from './components/AccordionCard.jsx';
|
||||||
import SavedFilters from './components/SavedFilters.jsx';
|
import SavedFilters from './components/SavedFilters.jsx';
|
||||||
|
import BulkActionsBar from './components/BulkActionsBar.jsx';
|
||||||
|
import Tooltip from './components/Tooltip.jsx';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
|
|
||||||
@@ -66,6 +70,7 @@ function IPRangesManager() {
|
|||||||
const [filterCommunity, setFilterCommunity] = useState('');
|
const [filterCommunity, setFilterCommunity] = useState('');
|
||||||
const editInputRef = useRef(null);
|
const editInputRef = useRef(null);
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
|
const [selectedItems, setSelectedItems] = useState(new Set());
|
||||||
|
|
||||||
// Справочник community для подсказок
|
// Справочник community для подсказок
|
||||||
const [communities, setCommunities] = useState([]);
|
const [communities, setCommunities] = useState([]);
|
||||||
@@ -170,6 +175,70 @@ function IPRangesManager() {
|
|||||||
|
|
||||||
const handleDeleteItem = (ipRangeToDelete) => {
|
const handleDeleteItem = (ipRangeToDelete) => {
|
||||||
setItems(items.filter(i => i.ipRange !== ipRangeToDelete));
|
setItems(items.filter(i => i.ipRange !== ipRangeToDelete));
|
||||||
|
setSelectedItems(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(ipRangeToDelete);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функции для работы с выбранными элементами
|
||||||
|
const toggleSelectItem = (ipRange) => {
|
||||||
|
setSelectedItems(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(ipRange)) {
|
||||||
|
next.delete(ipRange);
|
||||||
|
} else {
|
||||||
|
next.add(ipRange);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAll = () => {
|
||||||
|
setSelectedItems(new Set(paginatedItems.map(i => i.ipRange)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const deselectAll = () => {
|
||||||
|
setSelectedItems(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkDelete = () => {
|
||||||
|
if (selectedItems.size === 0) return;
|
||||||
|
const count = selectedItems.size;
|
||||||
|
setItems(items.filter(i => !selectedItems.has(i.ipRange)));
|
||||||
|
setSelectedItems(new Set());
|
||||||
|
window.notify?.success?.(`Удалено ${count} записей`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkExport = () => {
|
||||||
|
if (selectedItems.size === 0) return;
|
||||||
|
const selected = items.filter(i => selectedItems.has(i.ipRange));
|
||||||
|
const header = ['ipRange', 'community'];
|
||||||
|
const csv = [header, ...selected.map(i => [i.ipRange, i.community])]
|
||||||
|
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
||||||
|
.join('\n');
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `ip_ranges_selected_${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
window.notify?.success?.(`Экспортировано ${selectedItems.size} записей`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text) => {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
window.notify?.success?.('Скопировано в буфер обмена');
|
||||||
|
}).catch(() => {
|
||||||
|
window.notify?.error?.('Не удалось скопировать');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const duplicateItem = (item) => {
|
||||||
|
setItems([...items, { ...item, ipRange: `${item.ipRange.split('/')[0]}/24` }]);
|
||||||
|
window.notify?.success?.('Запись продублирована');
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = (item) => {
|
const confirmDelete = (item) => {
|
||||||
@@ -745,66 +814,6 @@ function IPRangesManager() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{/* Статистические карточки */}
|
|
||||||
<div className="row g-3 mb-4">
|
|
||||||
<div className="col-sm-6 col-lg-3">
|
|
||||||
<div className="card card-sm animate-in">
|
|
||||||
<div className="card-body">
|
|
||||||
<div className="row align-items-center">
|
|
||||||
<div className="col-auto">
|
|
||||||
<span className="bg-blue text-white avatar">
|
|
||||||
<IconNetwork size={24} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="col">
|
|
||||||
<div className="font-weight-medium">Всего диапазонов</div>
|
|
||||||
<div className="text-muted">
|
|
||||||
<span className="h2 m-0">{items.length}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-6 col-lg-3">
|
|
||||||
<div className="card card-sm animate-in" style={{animationDelay: '0.1s'}}>
|
|
||||||
<div className="card-body">
|
|
||||||
<div className="row align-items-center">
|
|
||||||
<div className="col-auto">
|
|
||||||
<span className="bg-green text-white avatar">
|
|
||||||
<IconFilter size={24} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="col">
|
|
||||||
<div className="font-weight-medium">Отфильтровано</div>
|
|
||||||
<div className="text-muted">
|
|
||||||
<span className="h2 m-0">{filtered.length}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-6 col-lg-3">
|
|
||||||
<div className="card card-sm animate-in" style={{animationDelay: '0.2s'}}>
|
|
||||||
<div className="card-body">
|
|
||||||
<div className="row align-items-center">
|
|
||||||
<div className="col-auto">
|
|
||||||
<span className="bg-purple text-white avatar">
|
|
||||||
<IconHash size={24} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="col">
|
|
||||||
<div className="font-weight-medium">Уникальных Community</div>
|
|
||||||
<div className="text-muted">
|
|
||||||
<span className="h2 m-0">{allCommunities.length}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-lg-3">
|
<div className="col-lg-3">
|
||||||
{/* Classic Add New Item Card (improved) */}
|
{/* Classic Add New Item Card (improved) */}
|
||||||
@@ -919,54 +928,81 @@ function IPRangesManager() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-lg-9">
|
<div className="col-lg-9">
|
||||||
|
{/* BulkActionsBar */}
|
||||||
|
<BulkActionsBar
|
||||||
|
selectedCount={selectedItems.size}
|
||||||
|
totalCount={paginatedItems.length}
|
||||||
|
onSelectAll={selectAll}
|
||||||
|
onDeselectAll={deselectAll}
|
||||||
|
onDelete={selectedItems.size > 0 ? () => {
|
||||||
|
if (confirm(`Удалить ${selectedItems.size} записей?`)) {
|
||||||
|
handleBulkDelete();
|
||||||
|
}
|
||||||
|
} : null}
|
||||||
|
onExport={selectedItems.size > 0 ? handleBulkExport : null}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header d-flex justify-content-between align-items-center">
|
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
<h3 className="card-title mb-0">Список IP-диапазонов <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3>
|
<h3 className="card-title mb-0">Список IP-диапазонов <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3>
|
||||||
<div className="d-flex gap-2 w-50">
|
<div className="d-flex gap-2 flex-wrap">
|
||||||
{/* Фильтр по community */}
|
<SavedFilters
|
||||||
<select
|
pageKey="ip-ranges"
|
||||||
className="form-select w-auto"
|
currentFilters={{ searchTerm, filterCommunity, sortField, sortOrder }}
|
||||||
value={filterCommunity}
|
onApplyFilter={(filters) => {
|
||||||
onChange={e => { setFilterCommunity(e.target.value); setCurrentPage(1); }}
|
setSearchTerm(filters.searchTerm || '');
|
||||||
title={(() => {
|
setFilterCommunity(filters.filterCommunity || '');
|
||||||
const meta = communities.find(c => c.value === filterCommunity);
|
setSortField(filters.sortField || 'ipRange');
|
||||||
if (!meta) return 'Все community';
|
setSortOrder(filters.sortOrder || 'asc');
|
||||||
const name = meta.name ? meta.name + ' — ' : '';
|
setCurrentPage(1);
|
||||||
const desc = meta.description || '';
|
}}
|
||||||
const tags = meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : '';
|
/>
|
||||||
return name + desc + tags;
|
<div className="d-flex gap-2 w-auto flex-grow-1" style={{minWidth: '300px'}}>
|
||||||
})()}
|
{/* Фильтр по community */}
|
||||||
>
|
<select
|
||||||
<option value="">Все community</option>
|
className="form-select w-auto"
|
||||||
{allCommunities.map(community => {
|
value={filterCommunity}
|
||||||
const meta = communities.find(c => c.value === community);
|
onChange={e => { setFilterCommunity(e.target.value); setCurrentPage(1); }}
|
||||||
const label = meta && meta.name ? `${community} — ${meta.name}` : community;
|
title={(() => {
|
||||||
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : community;
|
const meta = communities.find(c => c.value === filterCommunity);
|
||||||
return (
|
if (!meta) return 'Все community';
|
||||||
<option key={community} value={community} title={title}>{label}</option>
|
const name = meta.name ? meta.name + ' — ' : '';
|
||||||
);
|
const desc = meta.description || '';
|
||||||
})}
|
const tags = meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : '';
|
||||||
</select>
|
return name + desc + tags;
|
||||||
{/* Поиск */}
|
})()}
|
||||||
<div className="input-icon flex-grow-1">
|
>
|
||||||
<span className="input-icon-addon">
|
<option value="">Все community</option>
|
||||||
<IconSearch size={18} />
|
{allCommunities.map(community => {
|
||||||
</span>
|
const meta = communities.find(c => c.value === community);
|
||||||
<input
|
const label = meta && meta.name ? `${community} — ${meta.name}` : community;
|
||||||
type="text"
|
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : community;
|
||||||
className="form-control"
|
return (
|
||||||
placeholder="Поиск IP-диапазона..."
|
<option key={community} value={community} title={title}>{label}</option>
|
||||||
value={searchTerm}
|
);
|
||||||
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
})}
|
||||||
aria-label="Поиск IP-диапазона"
|
</select>
|
||||||
/>
|
{/* Поиск */}
|
||||||
|
<div className="input-icon flex-grow-1">
|
||||||
|
<span className="input-icon-addon">
|
||||||
|
<IconSearch size={18} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Поиск IP-диапазона..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
||||||
|
aria-label="Поиск IP-диапазона"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Tooltip content="Анализ и суммаризация IP-диапазонов">
|
||||||
<div>
|
<button className="btn btn-outline-primary" onClick={() => setAnalyzeOpen(true)}>
|
||||||
<button className="btn btn-outline-primary" onClick={() => setAnalyzeOpen(true)}>
|
<IconDatabase className="icon me-2" />
|
||||||
<IconDatabase className="icon me-2" />
|
Анализ
|
||||||
Анализ
|
</button>
|
||||||
</button>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Мета-информация будет показана внизу карточки */}
|
{/* Мета-информация будет показана внизу карточки */}
|
||||||
@@ -986,6 +1022,21 @@ function IPRangesManager() {
|
|||||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style={{width: '40px'}}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
checked={paginatedItems.length > 0 && paginatedItems.every(i => selectedItems.has(i.ipRange))}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
selectAll();
|
||||||
|
} else {
|
||||||
|
deselectAll();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Выбрать все на странице"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
<th className="cursor-pointer user-select-none" onClick={() => handleSort('ipRange')}>
|
<th className="cursor-pointer user-select-none" onClick={() => handleSort('ipRange')}>
|
||||||
<div className="d-flex align-items-center">
|
<div className="d-flex align-items-center">
|
||||||
<IconNetwork size={16} className="me-1 text-muted" />
|
<IconNetwork size={16} className="me-1 text-muted" />
|
||||||
@@ -1008,16 +1059,34 @@ function IPRangesManager() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="text-end"> </th>
|
<th className="text-end">Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{paginatedItems.map((item, idx) => (
|
{paginatedItems.map((item, idx) => (
|
||||||
<tr key={item.ipRange} className={editingIp === item.ipRange ? 'table-active' : ''} style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}>
|
<tr key={item.ipRange} className={editingIp === item.ipRange ? 'table-active' : (selectedItems.has(item.ipRange) ? 'table-selected' : '')} style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
checked={selectedItems.has(item.ipRange)}
|
||||||
|
onChange={() => toggleSelectItem(item.ipRange)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td className="font-monospace" title="CIDR">
|
<td className="font-monospace" title="CIDR">
|
||||||
<div className="d-flex align-items-center">
|
<div className="d-flex align-items-center">
|
||||||
<IconNetwork size={16} className="me-2 text-muted" />
|
<IconNetwork size={16} className="me-2 text-muted" />
|
||||||
<span>{item.ipRange}</span>
|
<span>{item.ipRange}</span>
|
||||||
|
<Tooltip content="Копировать IP-диапазон">
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
|
||||||
|
onClick={() => copyToClipboard(item.ipRange)}
|
||||||
|
aria-label="Копировать"
|
||||||
|
>
|
||||||
|
<IconCopy size={14} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{renderCommunityBadge(item.community)}</td>
|
<td>{renderCommunityBadge(item.community)}</td>
|
||||||
@@ -1032,13 +1101,24 @@ function IPRangesManager() {
|
|||||||
style={{ width: '150px' }}
|
style={{ width: '150px' }}
|
||||||
onKeyDown={(e) => handleEditKeyDown(e, item.ipRange)}
|
onKeyDown={(e) => handleEditKeyDown(e, item.ipRange)}
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.ipRange)} disabled={!isValidCommunity(editingValue)} title="Сохранить (Enter)"><IconCheck size={16} /></button>
|
<Tooltip content="Сохранить (Enter)">
|
||||||
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit} title="Отмена (Esc)"><IconX size={16} /></button>
|
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.ipRange)} disabled={!isValidCommunity(editingValue)}><IconCheck size={16} /></button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Отмена (Esc)">
|
||||||
|
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit}><IconX size={16} /></button>
|
||||||
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)}><IconEdit size={16} /></button>
|
<Tooltip content="Редактировать">
|
||||||
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)}><IconTrash size={16} /></button>
|
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)}><IconEdit size={16} /></button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Копировать">
|
||||||
|
<button className="btn btn-outline-secondary btn-icon btn-sm me-1" aria-label="Копировать" onClick={() => copyToClipboard(`${item.ipRange} ${item.community}`)}><IconCopy size={16} /></button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Удалить">
|
||||||
|
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)}><IconTrash size={16} /></button>
|
||||||
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
.page { min-height: 100vh; display: flex; flex-direction: column; }
|
.page { min-height: 100vh; display: flex; flex-direction: column; }
|
||||||
.footer { margin-top: auto; }
|
.footer { margin-top: auto; }
|
||||||
|
|
||||||
|
/* Table selected row styling */
|
||||||
|
.table-selected {
|
||||||
|
background-color: rgba(32, 107, 196, 0.08) !important;
|
||||||
|
}
|
||||||
|
.table-selected:hover {
|
||||||
|
background-color: rgba(32, 107, 196, 0.12) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Tabler visual consistency (global overrides) ===== */
|
/* ===== Tabler visual consistency (global overrides) ===== */
|
||||||
:root {
|
:root {
|
||||||
--app-gap-xs: .25rem;
|
--app-gap-xs: .25rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user