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,
|
||||
IconFilter,
|
||||
IconArrowUp,
|
||||
IconArrowDown
|
||||
IconArrowDown,
|
||||
IconCopy
|
||||
} from '@tabler/icons-react';
|
||||
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
|
||||
import S3MetaBar from './components/S3MetaBar.jsx';
|
||||
@@ -37,6 +38,8 @@ import ImportModal from './components/ImportModal.jsx';
|
||||
import QuickAddBar from './components/QuickAddBar.jsx';
|
||||
import AccordionCard from './components/AccordionCard.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';
|
||||
|
||||
const API_URL = '/api';
|
||||
@@ -67,6 +70,7 @@ function ASNsNewManager() {
|
||||
const editInputRef = useRef(null);
|
||||
const pageSize = 10;
|
||||
const [asnNameMap, setAsnNameMap] = useState({});
|
||||
const [selectedItems, setSelectedItems] = useState(new Set());
|
||||
|
||||
// Справочник community для подсказок
|
||||
const [communities, setCommunities] = useState([]);
|
||||
@@ -161,6 +165,65 @@ function ASNsNewManager() {
|
||||
|
||||
const handleDeleteItem = (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) => {
|
||||
@@ -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="col-lg-3">
|
||||
{/* Classic Add New ASN Card (improved) */}
|
||||
@@ -666,18 +669,32 @@ function ASNsNewManager() {
|
||||
</div>
|
||||
|
||||
<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-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>
|
||||
<div className="d-flex gap-2 w-50 flex-wrap">
|
||||
<div className="d-flex gap-2 flex-wrap">
|
||||
<SavedFilters
|
||||
pageKey="asns"
|
||||
currentFilters={{ searchTerm, filterCommunity, sortField, sortOrder }}
|
||||
onApplyFilter={(filters) => {
|
||||
if (filters.searchTerm !== undefined) setSearchTerm(filters.searchTerm);
|
||||
if (filters.filterCommunity !== undefined) setFilterCommunity(filters.filterCommunity);
|
||||
if (filters.sortField !== undefined) setSortField(filters.sortField);
|
||||
if (filters.sortOrder !== undefined) setSortOrder(filters.sortOrder);
|
||||
setSearchTerm(filters.searchTerm || '');
|
||||
setFilterCommunity(filters.filterCommunity || '');
|
||||
setSortField(filters.sortField || 'asn');
|
||||
setSortOrder(filters.sortOrder || 'asc');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
@@ -763,6 +780,21 @@ function ASNsNewManager() {
|
||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||
<thead>
|
||||
<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')}>
|
||||
<div className="d-flex align-items-center">
|
||||
<IconHash size={16} className="me-1 text-muted" />
|
||||
@@ -788,16 +820,34 @@ function ASNsNewManager() {
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="text-end"> </th>
|
||||
<th className="text-end">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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">
|
||||
<div className="d-flex align-items-center">
|
||||
<IconHash size={16} className="me-2 text-muted" />
|
||||
<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>
|
||||
</td>
|
||||
<td title={asnNameMap[item.asn] || ''}>
|
||||
@@ -819,13 +869,24 @@ function ASNsNewManager() {
|
||||
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
||||
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>
|
||||
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit} title="Отмена (Esc)"><IconX size={16} /></button>
|
||||
<Tooltip content="Сохранить (Enter)">
|
||||
<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>
|
||||
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)}><IconTrash size={16} /></button>
|
||||
<Tooltip content="Редактировать">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user