feat: Добавление статистических карточек и улучшение форм в компонентах ASNsNewManager, DomainsNewManager и IPRangesManager. Обновлены стили для улучшения пользовательского интерфейса и добавлены новые иконки для визуализации данных.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m43s

This commit is contained in:
2025-11-11 19:39:28 +07:00
parent 216e05df9f
commit b34bf4af95
4 changed files with 966 additions and 364 deletions
+205 -108
View File
@@ -16,7 +16,11 @@ import {
IconDeviceFloppy,
IconHash,
IconClock,
IconFileText
IconFileText,
IconFilter,
IconArrowUp,
IconArrowDown,
IconNetwork
} from '@tabler/icons-react';
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
import S3MetaBar from './components/S3MetaBar.jsx';
@@ -33,6 +37,7 @@ import { notifyMutationSuccess } from './components/NotifyProvider.jsx';
import ImportModal from './components/ImportModal.jsx';
import QuickAddBar from './components/QuickAddBar.jsx';
import AccordionCard from './components/AccordionCard.jsx';
import SavedFilters from './components/SavedFilters.jsx';
const API_URL = '/api';
@@ -740,12 +745,72 @@ 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="col-lg-3">
{/* Classic Add New Item Card (restored) */}
<div className="card card-md">
<div className="card-header">
<h3 className="card-title">
{/* Classic Add New Item Card (improved) */}
<div className="card card-md animate-in">
<div className="card-header bg-primary-lt">
<h3 className="card-title text-primary">
<IconPlus className="icon me-2" />
Добавить новый IP-диапазон
</h3>
@@ -753,45 +818,63 @@ function IPRangesManager() {
<div className="card-body">
<form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
<div className="mb-3">
<label className="form-label">IP-диапазон</label>
<input
type="text"
className={`form-control${newInvalid.ipRange ? ' is-invalid' : ''}`}
placeholder="192.168.1.0/24"
value={newItem.ipRange}
onChange={(e) => setNewItem({ ...newItem, ipRange: e.target.value })}
/>
<label className="form-label required">IP-диапазон</label>
<div className="input-icon">
<span className="input-icon-addon">
<IconNetwork size={18} />
</span>
<input
type="text"
className={`form-control${newInvalid.ipRange ? ' is-invalid' : isValidCidr(newItem.ipRange) ? ' is-valid' : ''}`}
placeholder="192.168.1.0/24"
value={newItem.ipRange}
onChange={(e) => setNewItem({ ...newItem, ipRange: e.target.value })}
/>
</div>
{newInvalid.ipRange && <div className="invalid-feedback">Некорректный CIDR</div>}
<div className="form-text">Формат: A.B.C.D/Маска, например 10.0.0.0/24</div>
{isValidCidr(newItem.ipRange) && !newInvalid.ipRange && (<div className="valid-feedback">Корректный CIDR</div>)}
<div className="form-text text-muted small">Формат: A.B.C.D/Маска, например 10.0.0.0/24</div>
</div>
<div className="mb-3">
<label className="form-label">Community</label>
<CommunityAutocompleteInput
value={newItem.community}
onChange={(v) => setNewItem({ ...newItem, community: v })}
communities={communities}
placeholder="Начните вводить номер или имя"
className={`form-control${newInvalid.community ? ' is-invalid' : ''}`}
/>
{newInvalid.community && <div className="invalid-feedback">Только цифры</div>}
<label className="form-label required">Community</label>
<div className="input-icon">
<span className="input-icon-addon">
<IconHash size={18} />
</span>
<CommunityAutocompleteInput
value={newItem.community}
onChange={(v) => setNewItem({ ...newItem, community: v })}
communities={communities}
placeholder="Начните вводить номер или имя"
className={`form-control${newInvalid.community ? ' is-invalid' : isValidCommunity(newItem.community) ? ' is-valid' : ''}`}
/>
</div>
{newInvalid.community && <div className="invalid-feedback">Неверный формат community</div>}
{isValidCommunity(newItem.community) && !newInvalid.community && (<div className="valid-feedback">Корректный формат community</div>)}
{(() => {
const match = communities.find(c => c.value === String(newItem.community).trim());
if (!match) return null;
return (
<div className="form-text">
{match.name && (<><strong>{match.name}</strong> </>)}
{match.description || ''}
{match.tags && match.tags.length > 0 && (
<> (<span className="text-muted">{match.tags.join(', ')}</span>)</>
)}
<div className="alert alert-info alert-dismissible mt-2 mb-0" role="alert">
<div className="d-flex">
<div>
<strong>{match.name || match.value}</strong>
{match.description && <div className="text-muted small">{match.description}</div>}
{match.tags && match.tags.length > 0 && (
<div className="mt-1">
{match.tags.map((tag, idx) => (<span key={idx} className="badge bg-blue-lt text-blue me-1">{tag}</span>))}
</div>
)}
</div>
</div>
</div>
);
})()}
</div>
<div className="form-footer">
<button type="submit" className="btn btn-primary w-100">
<button type="submit" className="btn btn-primary w-100" disabled={!isValidCidr(newItem.ipRange) || !isValidCommunity(newItem.community)}>
<IconPlus className="icon me-2" />
Добавить
Добавить диапазон
</button>
</div>
</form>
@@ -799,7 +882,7 @@ function IPRangesManager() {
</div>
{/* Дополнительные варианты */}
<AccordionCard title={'Дополнительные варианты'} defaultOpen={false}>
<AccordionCard title={'Дополнительные варианты'} defaultOpen={false} className="animate-in" style={{animationDelay: '0.1s'}}>
<QuickAddBar
placeholder={'192.168.1.0/24 65000:100'}
help={'Вставьте строки: CIDR ПРОБЕЛ COMMUNITY'}
@@ -817,25 +900,20 @@ function IPRangesManager() {
});
}}
/>
<div className="card card-md border-dashed mt-3"
onDragOver={(e) => { e.preventDefault(); }}
onDrop={onDropImport}
<div className="card card-md border-dashed mt-3 animate-in"
style={{animationDelay: '0.15s'}}
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = 'var(--tblr-primary)'; }}
onDragLeave={(e) => { e.currentTarget.style.borderColor = ''; }}
onDrop={(e) => { e.currentTarget.style.borderColor = ''; onDropImport(e); }}
>
<div className="card-body text-center text-muted">
Перетащите файл TXT/CSV сюда для импорта (формат: CIDR community)
<div className="card-body text-center text-muted py-4">
<IconUpload size={32} className="mb-2 text-muted" />
<div className="fw-medium">Перетащите файл TXT/CSV сюда</div>
<div className="small">Формат: CIDR community</div>
</div>
</div>
</AccordionCard>
{/* Drag & Drop импорт */}
<div
className="card card-md border-dashed"
onDragOver={(e) => { e.preventDefault(); }}
onDrop={onDropImport}
>
<div className="card-body text-center text-muted">
Перетащите файл TXT/CSV сюда для импорта (формат: CIDR community)
</div>
</div>
{/* Карточка действий больше не нужна — действия перенесены в page-header */}
</div>
@@ -908,29 +986,40 @@ function IPRangesManager() {
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th className="cursor-pointer" onClick={() => handleSort('ipRange')}>
IP-диапазон
{sortField === 'ipRange' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
<th className="cursor-pointer user-select-none" onClick={() => handleSort('ipRange')}>
<div className="d-flex align-items-center">
<IconNetwork size={16} className="me-1 text-muted" />
IP-диапазон
{sortField === 'ipRange' && (
<span className="ms-1">
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
</span>
)}
</div>
</th>
<th className="cursor-pointer" onClick={() => handleSort('community')}>
Community
{sortField === 'community' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
<th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}>
<div className="d-flex align-items-center">
<IconHash size={16} className="me-1 text-muted" />
Community
{sortField === 'community' && (
<span className="ms-1">
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
</span>
)}
</div>
</th>
<th className="text-end"> </th>
</tr>
</thead>
<tbody>
{paginatedItems.map((item) => (
<tr key={item.ipRange} className={editingIp === item.ipRange ? 'table-info' : ''}>
<td className="font-monospace" title="CIDR">{item.ipRange}</td>
{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` }}>
<td className="font-monospace" title="CIDR">
<div className="d-flex align-items-center">
<IconNetwork size={16} className="me-2 text-muted" />
<span>{item.ipRange}</span>
</div>
</td>
<td>{renderCommunityBadge(item.community)}</td>
<td className="text-end">
{editingIp === item.ipRange ? (
@@ -939,11 +1028,12 @@ function IPRangesManager() {
value={editingValue}
onChange={setEditingValue}
communities={communities}
className={`form-control d-inline-block w-auto me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
style={{ width: '150px' }}
onKeyDown={(e) => handleEditKeyDown(e, item.ipRange)}
/>
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.ipRange)} disabled={!isValidCommunity(editingValue)}><IconCheck size={16} /></button>
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit}><IconX size={16} /></button>
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.ipRange)} 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>
</>
) : (
<>
@@ -960,48 +1050,55 @@ function IPRangesManager() {
)}
{/* Пагинация */}
{totalPages > 1 && (
<div className="card-footer d-flex align-items-center justify-content-between">
<div className="text-muted">
Показано {((currentPage - 1) * pageSize) + 1} - {((currentPage - 1) * pageSize) + paginatedItems.length} из {totalItems || paginatedItems.length}
</div>
<ul className="pagination m-0">
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(1)} tabIndex={-1} disabled={currentPage === 1}>Первая</button>
</li>
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} tabIndex={-1} disabled={currentPage === 1}>Назад</button>
</li>
{(() => {
const pages = [];
const maxToShow = 7; // 1 .. 5 .. N
let start = Math.max(1, currentPage - 2);
let end = Math.min(totalPages, currentPage + 2);
if (currentPage <= 3) { end = Math.min(totalPages, 5); }
if (currentPage >= totalPages - 2) { start = Math.max(1, totalPages - 4); }
if (start > 1) pages.push('start-ellipsis');
for (let p = start; p <= end; p++) pages.push(p);
if (end < totalPages) pages.push('end-ellipsis');
const renderItem = (p) => {
if (p === 'start-ellipsis' || p === 'end-ellipsis') {
return (
<div className="card-footer">
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div className="text-muted small">
Показано <strong>{((currentPage - 1) * pageSize) + 1}</strong> - <strong>{((currentPage - 1) * pageSize) + paginatedItems.length}</strong> из <strong>{totalItems || filtered.length}</strong>
</div>
<ul className="pagination m-0">
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(1)} disabled={currentPage === 1} aria-label="Первая страница">
<IconArrowUp size={14} style={{ transform: 'rotate(-90deg)' }} />
</button>
</li>
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} disabled={currentPage === 1} aria-label="Предыдущая страница">
<IconArrowUp size={14} style={{ transform: 'rotate(-90deg)' }} />
<span className="d-none d-sm-inline ms-1">Назад</span>
</button>
</li>
{(() => {
const pages = [];
let start = Math.max(1, currentPage - 2);
let end = Math.min(totalPages, currentPage + 2);
if (currentPage <= 3) end = Math.min(totalPages, 5);
if (currentPage >= totalPages - 2) start = Math.max(1, totalPages - 4);
if (start > 1) pages.push('start-ellipsis');
for (let p = start; p <= end; p++) pages.push(p);
if (end < totalPages) pages.push('end-ellipsis');
return pages.map((p) => (
p === 'start-ellipsis' || p === 'end-ellipsis' ? (
<li key={p} className="page-item disabled"><span className="page-link"></span></li>
);
}
return (
<li key={p} className={`page-item${currentPage === p ? ' active' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(p)}>{p}</button>
</li>
);
};
return pages.map(renderItem);
})()}
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} tabIndex={-1} disabled={currentPage === totalPages}>Вперед</button>
</li>
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(totalPages)} tabIndex={-1} disabled={currentPage === totalPages}>Последняя</button>
</li>
</ul>
) : (
<li key={p} className={`page-item${currentPage === p ? ' active' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(p)} aria-label={`Страница ${p}`} aria-current={currentPage === p ? 'page' : undefined}>{p}</button>
</li>
)
));
})()}
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} disabled={currentPage === totalPages} aria-label="Следующая страница">
<span className="d-none d-sm-inline me-1">Вперед</span>
<IconArrowDown size={14} style={{ transform: 'rotate(-90deg)' }} />
</button>
</li>
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages} aria-label="Последняя страница">
<IconArrowDown size={14} style={{ transform: 'rotate(-90deg)' }} />
</button>
</li>
</ul>
</div>
</div>
)}
<S3MetaBar keys={["ipRanges"]} />