Files
router-lists-ui/frontend/src/DomainsNewManager.jsx
T
denozord 50a7edd50f
Frontend CI / frontend (push) Has been cancelled
Publish Docker image / build-and-push (push) Failing after 56s
feat: enhance various components with useCallback for performance optimization and improve chunk size warning limit in Vite config
2026-03-16 00:07:25 +07:00

965 lines
41 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef, useCallback } from 'react';
import api from './lib/api.js';
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
import CommunityBadge from './components/CommunityBadge.jsx';
import ConfirmModal from './components/ConfirmModal.jsx';
import {
IconPlus,
IconSearch,
IconEdit,
IconTrash,
IconCheck,
IconX,
IconDatabase,
IconRefresh,
IconUpload,
IconDownload,
IconDeviceFloppy,
IconHash,
IconClock,
IconFileText,
IconFilter,
IconWorld,
IconTrendingUp,
IconUsers,
IconArrowUp,
IconArrowDown,
IconCopy
} from '@tabler/icons-react';
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
import PageHeader from './components/PageHeader.jsx';
import EmptyState from './components/EmptyState.jsx';
import { IconAlertTriangle } from '@tabler/icons-react';
import PageHeaderActions from './components/PageHeaderActions.jsx';
import ConfirmDialog from './components/ConfirmDialog.jsx';
import WsUpdateModal from './components/WsUpdateModal.jsx';
import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
import HistoryModal from './components/HistoryModal.jsx';
import ErrorAlert from './components/ErrorAlert.jsx';
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';
import BulkActionsBar from './components/BulkActionsBar.jsx';
import FormField from './components/FormField.jsx';
import Tooltip from './components/Tooltip.jsx';
import Pagination from './components/Pagination.jsx';
import AddItemModal from './components/AddItemModal.jsx';
import LastSaved from './components/LastSaved.jsx';
import FormModal from './components/FormModal.jsx';
import { useToast } from './components/ToastContainer.jsx';
const API_URL = '/api';
function DomainsNewManager() {
const toast = useToast();
const [items, setItems] = useState([]);
const [originalItems, setOriginalItems] = useState([]);
const [etag, setEtag] = useState('');
const [lastModified, setLastModified] = useState('');
const [, setContentLength] = useState(null);
const [newItem, setNewItem] = useState({ domain: '', community: '' });
const [, setNewInvalid] = useState({ domain: false, community: false });
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const _searchTimer = useRef(null);
const didInit = useRef(false);
const [editingValue, setEditingValue] = useState('');
const [editModalOpen, setEditModalOpen] = useState(false);
const [itemBeingEdited, setItemBeingEdited] = useState(null);
const [loading, setLoading] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const [addModalOpen, setAddModalOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [sortField, setSortField] = useState('domain');
const [sortOrder, setSortOrder] = useState('asc');
const [filterCommunity, setFilterCommunity] = useState('');
const pageSize = 10;
// Bulk operations
const [selectedDomains, setSelectedDomains] = useState(new Set());
// Справочник community для подсказок
const [communities, setCommunities] = useState([]);
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
(async () => {
try {
const res = await api.get(`/communities`, { signal });
setCommunities(Array.isArray(res.data) ? res.data : []);
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
}
})();
(async () => {
try {
const r = await api.get('/ws/url', { signal });
setWsUrl(String(r.data?.url || ''));
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
}
})();
return () => controller.abort();
}, []);
const renderCommunityBadge = (value) => (<CommunityBadge value={value} communities={communities} />);
const isValidDomain = (value) => {
const v = String(value).trim().toLowerCase();
// Простой валидатор FQDN: sub.domain.tld
return /^([a-z0-9-]+\.)+[a-z]{2,}$/i.test(v);
};
// Допускаем как числовые, так и строковые (AS:NNN) community
const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim());
const [wsOpen, setWsOpen] = useState(false);
const [wsUrl, setWsUrl] = useState('');
const fetchItems = useCallback(async (abortSignal) => {
setLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const response = await api.get(`/domains-new`, { params: { offset: 0, limit: 0, format: 'std' }, ...opts });
const payload = Array.isArray(response.data?.items) ? response.data.items : [];
const total = payload.length;
setItems(payload);
setOriginalItems(payload);
setTotalItems(Number.isFinite(total) ? total : 0);
setEtag(response.headers?.etag || '');
setLastModified(response.headers?.['last-modified'] || '');
const lengthHeader = response.headers?.['content-length-source'];
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
setError('');
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
console.error('Error fetching domains-new:', error);
setError('Не удалось загрузить домены. Проверьте, запущен ли бэкенд.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!didInit.current) {
didInit.current = true;
const controller = new AbortController();
fetchItems(controller.signal);
return () => controller.abort();
}
}, [fetchItems]);
const handleAddItem = (item) => {
// Если item передан (из модалки), используем его, иначе из state (старый способ)
const domain = item?.domain || newItem.domain;
const community = item?.community || newItem.community;
const domainOk = isValidDomain(domain);
const communityOk = isValidCommunity(community);
if (!domainOk || !communityOk) {
setError('Заполните корректный домен (example.com) и числовой community.');
return;
}
setError('');
const newDomain = { domain: domain.trim().toLowerCase(), community: String(community).trim() };
setItems([...items, newDomain]);
if (!item) setNewItem({ domain: '', community: '' }); // Сброс только если не из модалки
setNewInvalid({ domain: false, community: false });
};
const handleEdit = (item) => {
setItemBeingEdited(item);
setEditingValue(item.community);
setEditModalOpen(true);
};
const handleSaveEdit = () => {
if (!itemBeingEdited) return;
if (!isValidCommunity(editingValue)) {
setError('Community должен быть числом.');
return;
}
const updatedItems = items.map(i =>
i.domain === itemBeingEdited.domain ? { ...i, community: String(editingValue).trim() } : i
);
setItems(updatedItems);
setEditModalOpen(false);
setItemBeingEdited(null);
};
const handleCancelEdit = () => {
setEditModalOpen(false);
setItemBeingEdited(null);
};
const handleDeleteItem = (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) => {
setItemToDelete(item);
setShowDeleteModal(true);
};
const executeDelete = () => {
if (itemToDelete) {
handleDeleteItem(itemToDelete.domain);
setShowDeleteModal(false);
setItemToDelete(null);
}
};
const deduplicate = (arr) => {
const seen = new Set();
const out = [];
for (const i of arr) {
const key = String(i.domain).trim().toLowerCase();
if (!key) continue;
if (seen.has(key)) continue;
seen.add(key);
out.push({ domain: key, community: String(i.community || '').trim() });
}
return out;
};
const computeDiff = (before, after) => {
const mapBefore = new Map(before.map(i => [i.domain, i]));
const mapAfter = new Map(after.map(i => [i.domain, i]));
const added = [];
const removed = [];
const changed = [];
for (const [k, v] of mapAfter) {
if (!mapBefore.has(k)) { added.push(v); continue; }
const prev = mapBefore.get(k);
if (String(prev.community) !== String(v.community)) changed.push({ from: prev, to: v });
}
for (const [k, v] of mapBefore) {
if (!mapAfter.has(k)) removed.push(v);
}
return { added, removed, changed };
};
const [showDiff, setShowDiff] = useState(false);
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [clearCommunitiesOpen, setClearCommunitiesOpen] = useState(false);
const handlePreviewDiff = () => {
const valid = items.filter(i => i != null && isValidDomain(i.domain) && isValidCommunity(i.community))
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
setDiff(computeDiff(originalItems, unique));
setShowDiff(true);
};
const performSave = useCallback(async () => {
setConfirmSaveOpen(false);
setLoading(true);
try {
const valid = items.filter(i => isValidDomain(i.domain) && isValidCommunity(i.community))
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
// Подгружаем весь список, применяем изменения текущей страницы и сохраняем полные данные
const fullRes = await api.get(`/domains-new`, { params: { offset: 0, limit: 0, format: 'std' } });
const full = Array.isArray(fullRes.data?.items) ? fullRes.data.items : (Array.isArray(fullRes.data) ? fullRes.data : []);
const fullMap = new Map(full.filter(i => i != null).map(i => [String(i.domain || '').trim().toLowerCase(), { domain: String(i.domain || '').trim().toLowerCase(), community: String(i.community || '').trim() }]));
const originalPageMap = new Map(originalItems.map(i => [String(i.domain).trim().toLowerCase(), true]));
const uniqueMap = new Map(unique.map(i => [String(i.domain).trim().toLowerCase(), { domain: String(i.domain).trim().toLowerCase(), community: String(i.community).trim() }]));
// Удаления: всё, что было на странице, но отсутствует в изменённом наборе
for (const key of originalPageMap.keys()) {
if (!uniqueMap.has(key)) fullMap.delete(key);
}
// Добавления/изменения
for (const [key, val] of uniqueMap.entries()) fullMap.set(key, val);
const fullToSave = Array.from(fullMap.values());
const et = fullRes?.headers?.etag || etag;
const response = await api.post(`/domains-new`, { domains: fullToSave, etag: et }, { validateStatus: () => true });
if (response.status === 412) {
setError('Данные изменились в S3 (ETag mismatch). Обновите список и попробуйте снова.');
return;
}
if (response.status >= 400) {
throw new Error(`Save failed with status ${response.status}`);
}
setEtag(response.headers?.etag || et);
// После сохранения перезагрузим текущую страницу для консистентности
await fetchItems();
setSuccess('Изменения успешно сохранены!');
setTimeout(() => setSuccess(''), 3000);
notifyMutationSuccess('Изменения сохранены');
} catch (error) {
console.error('Error saving changes:', error);
setError('Не удалось сохранить изменения.');
} finally {
setLoading(false);
}
}, [items, originalItems, etag, fetchItems]);
const handleSaveChanges = useCallback(async () => {
const valid = items.filter(i => i != null && isValidDomain(i.domain) && isValidCommunity(i.community))
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
const diffData = computeDiff(originalItems, unique);
setDiff(diffData);
const totalChanges = (diffData.added?.length || 0) + (diffData.removed?.length || 0) + (diffData.changed?.length || 0);
if (totalChanges > 10) {
setConfirmSaveOpen(true);
} else {
performSave();
}
}, [items, originalItems, performSave]);
// Drag&Drop импорт
const _onDropImport = async (e) => {
e.preventDefault();
const file = e.dataTransfer?.files?.[0];
if (!file) return;
const text = await file.text();
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
const parsed = lines.map(l => {
const [d, c] = l.split(/\s+/);
return { domain: (d || '').toLowerCase(), community: (c || '').trim() };
}).filter(i => i != null && isValidDomain(i.domain) && isValidCommunity(i.community));
setItems(prev => {
const merged = [...prev, ...parsed];
const map = new Map();
for (const it of merged) {
const key = String(it.domain).trim().toLowerCase();
if (!map.has(key)) map.set(key, { domain: key, community: String(it.community).trim() });
}
return Array.from(map.values());
});
};
const handleImport = () => {
setImportOpen(true);
};
const handleExport = async () => {
try {
const effectiveQ = searchTerm ? searchTerm : (filterCommunity ? filterCommunity : '');
const response = await api.get(`/domains-new`, { params: { q: effectiveQ, offset: 0, limit: 0, format: 'std' } });
const all = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []);
const header = ['domain', 'community'];
const csv = [header, ...all.filter(i => i != null).map(i => [i.domain || '', 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 = `domains_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
window.notify?.error('Экспорт доменов не удался', String(e?.message || e));
}
};
const clearInvalid = () => {
setItems(prev => prev.filter(i => i != null && (i.domain || i.community))
.filter(i => isValidDomain(i.domain) && isValidCommunity(i.community))
);
};
// ===== Импорт через модалку =====
const [importOpen, setImportOpen] = useState(false);
const parseLine = (line) => {
const [d, c] = String(line).split(/\s+/);
return { domain: (d || '').toLowerCase(), community: (c || '').trim() };
};
const validateItem = (obj) => isValidDomain(obj.domain) && isValidCommunity(obj.community);
const applyImportedItems = (list) => {
setItems(prev => {
const merged = [...prev, ...list];
const map = new Map();
for (const it of merged) {
const key = String(it.domain).trim().toLowerCase();
if (!map.has(key)) map.set(key, { domain: key, community: String(it.community).trim() });
}
return Array.from(map.values());
});
setImportOpen(false);
};
// Сортировка
const sortedItems = [...items].sort((a, b) => {
let valA = a[sortField] || '';
let valB = b[sortField] || '';
if (typeof valA === 'string') valA = valA.toLowerCase();
if (typeof valB === 'string') valB = valB.toLowerCase();
if (valA < valB) return sortOrder === 'asc' ? -1 : 1;
if (valA > valB) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
// Локальная фильтрация и пагинация
const filtered = sortedItems.filter(i => {
if (!i) return false;
const byCommunity = !filterCommunity || i.community === filterCommunity;
const term = String(searchTerm || '').toLowerCase();
const bySearch = !term || String(i.domain || '').toLowerCase().includes(term);
return byCommunity && bySearch;
});
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const paginatedItems = filtered.slice((currentPage - 1) * pageSize, (currentPage) * pageSize);
// Для фильтра - список всех уникальных community
const allCommunities = Array.from(new Set(items.filter(i => i != null).map(i => i.community).filter(c => c != null)));
// Сортировка по клику
const handleSort = (field) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('asc');
}
};
// Глобальные хоткеи: Ctrl+S — сохранить изменения
useEffect(() => {
const onKey = (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
e.preventDefault();
if (!loading) handleSaveChanges();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [loading, handleSaveChanges]);
const handleEditKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveEdit();
}
if (e.key === 'Escape') {
e.preventDefault();
handleCancelEdit();
}
};
// Bulk operations handlers
const handleSelectAll = () => {
setSelectedDomains(new Set(paginatedItems.map(i => i.domain)));
};
const handleDeselectAll = () => {
setSelectedDomains(new Set());
};
const handleToggleSelect = (domain) => {
const newSet = new Set(selectedDomains);
if (newSet.has(domain)) {
newSet.delete(domain);
} else {
newSet.add(domain);
}
setSelectedDomains(newSet);
};
const handleBulkDelete = () => {
if (selectedDomains.size === 0) return;
const count = selectedDomains.size;
const newItems = items.filter(i => !selectedDomains.has(i.domain));
setItems(newItems);
setSelectedDomains(new Set());
toast.success(`Удалено ${count} доменов`);
};
const handleBulkExport = () => {
if (selectedDomains.size === 0) return;
const selectedItems = items.filter(i => i != null && selectedDomains.has(i.domain));
const text = selectedItems.map(i => `${i.domain || ''} ${i.community || ''}`).join('\n');
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `domains-selected-${selectedDomains.size}.txt`;
a.click();
URL.revokeObjectURL(url);
toast.success(`Экспортировано ${selectedDomains.size} доменов`);
};
return (
<>
{error && (
<ErrorAlert message={error} onClose={() => setError('')} />
)}
{success && (
<div className="alert alert-success alert-dismissible" role="alert">
<div className="d-flex">
<div>
<IconCheck className="icon alert-icon" />
</div>
<div>
{success}
</div>
</div>
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
</div>
)}
<PageHeader
title="Домены"
icon={<IconWorld size={24} />}
meta={<LastSaved timestamp={lastModified} variant="compact" />}
actions={(
<PageHeaderActions
loading={loading}
onPreview={handlePreviewDiff}
disablePreview={loading || items.length === 0}
onRefresh={fetchItems}
disableRefresh={loading}
onImport={handleImport}
onExport={handleExport}
disableExport={items.length === 0}
onClear={clearInvalid}
disableClear={items.length === 0}
onClearCommunities={() => { if (!filterCommunity) { setError('Выберите community в фильтре для очистки'); return; } setClearCommunitiesOpen(true); }}
disableClearCommunities={items.length === 0 || !filterCommunity}
onSave={handleSaveChanges}
disableSave={loading}
onHistory={() => setHistoryOpen(true)}
lastModified={lastModified}
onOnlineUpdate={() => setWsOpen(true)}
onBackgroundUpdate={async () => {
try {
const res = await fetch('/api/update-bgp/background', { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) {
throw new Error(data?.message || `HTTP ${res.status}`);
}
notifyMutationSuccess('Фоновое обновление запущено', data);
} catch (e) {
window.notify?.error('Не удалось запустить фоновое обновление', String(e));
}
}}
/>
)}
/>
<AddItemModal
open={addModalOpen}
onClose={() => setAddModalOpen(false)}
onSubmit={handleAddItem}
type="domain"
communities={communities}
loading={loading}
/>
<div className="row">
<div className="col-12">
{/* Bulk Actions Bar */}
<BulkActionsBar
selectedCount={selectedDomains.size}
totalCount={paginatedItems.length}
onSelectAll={handleSelectAll}
onDeselectAll={handleDeselectAll}
onDelete={handleBulkDelete}
onExport={handleBulkExport}
/>
<div className="card card-flat animate-in" style={{animationDelay: '0.3s'}}>
<div className="card-header">
<div className="row align-items-center w-100 g-2">
<div className="col">
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div className="d-flex align-items-center gap-2">
<IconWorld className="icon text-muted" size={18} />
<span className="text-muted text-uppercase small">Список</span>
<span className="badge bg-blue-lt text-blue">{filtered.length}</span>
</div>
<div className="d-none d-md-block text-muted small">
Всего записей: {filtered.length}
</div>
</div>
</div>
<div className="col-12 mt-2"></div>
<div className="col">
<div className="d-flex gap-2 flex-wrap">
{/* Сохранённые фильтры */}
<SavedFilters
pageKey="domains"
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);
setCurrentPage(1);
}}
/>
{/* Фильтр по community */}
<div className="input-icon">
<span className="input-icon-addon">
<IconFilter size={18} />
</span>
<select
className="form-select"
style={{ minWidth: '200px' }}
value={filterCommunity}
onChange={e => { setFilterCommunity(e.target.value); setCurrentPage(1); }}
title={(() => {
const meta = communities.find(c => c.value === filterCommunity);
if (!meta) return 'Все community';
const name = meta.name ? meta.name + ' — ' : '';
const desc = meta.description || '';
const tags = meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : '';
return name + desc + tags;
})()}
>
<option value="">Все community</option>
{allCommunities.map(community => {
const meta = communities.find(c => c.value === community);
const label = meta && meta.name ? `${community}${meta.name}` : community;
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : community;
return (
<option key={community} value={community} title={title}>{label}</option>
);
})}
</select>
</div>
{/* Поиск */}
<div className="input-icon flex-grow-1" style={{ minWidth: '200px', position: 'relative' }}>
<span className="input-icon-addon">
<IconSearch size={18} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск домена..."
value={searchTerm}
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
aria-label="Поиск домена"
style={{ paddingRight: searchTerm ? '2.5rem' : undefined }}
/>
{searchTerm && (
<button
type="button"
className="btn btn-ghost-secondary btn-icon btn-sm"
style={{
position: 'absolute',
right: '0.25rem',
top: '50%',
transform: 'translateY(-50%)',
zIndex: 2
}}
onClick={() => { setSearchTerm(''); setCurrentPage(1); }}
aria-label="Очистить поиск"
>
<IconX size={16} />
</button>
)}
</div>
</div>
</div>
<div className="col-auto">
<button
className="btn btn-primary"
onClick={() => setAddModalOpen(true)}
title="Добавить новый домен"
>
<IconPlus className="me-1" size={18} />
Добавить
</button>
</div>
</div>
</div>
<div className="card-body p-0">
{/* Таблица с лёгкой карточной подложкой (плоский стиль) */}
{loading ? (
<TableSkeleton rows={10} cols={4} hasCheckbox={true} flat />
) : paginatedItems.length === 0 ? (
<TableEmpty cols={3} flat>
<EmptyState
title="Нет доменов"
description="Импортируйте или добавьте записи, чтобы начать."
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
const header = ['domain','community'];
const lines = [header, ['example.com','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'domains_sample.csv'; a.click(); URL.revokeObjectURL(url);
}}>Шаблон CSV</button>}
/>
</TableEmpty>
) : (
<div className="table-responsive">
<table className="table table-flat table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{ width: '40px' }}>
<input
type="checkbox"
className="form-check-input"
checked={selectedDomains.size === paginatedItems.length && paginatedItems.length > 0}
onChange={(e) => e.target.checked ? handleSelectAll() : handleDeselectAll()}
/>
</th>
<th className="cursor-pointer user-select-none" onClick={() => handleSort('domain')}>
<div className="d-flex align-items-center">
<IconWorld size={16} className="me-1 text-muted" />
Домен
{sortField === 'domain' && (
<span className="ms-1">
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
</span>
)}
</div>
</th>
<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, idx) => (
<tr
key={item.domain}
className={selectedDomains.has(item.domain) ? 'table-selected' : ''}
style={{
animation: `fadeIn 0.3s ease ${idx * 0.02}s both`
}}
>
<td>
<input
type="checkbox"
className="form-check-input"
checked={selectedDomains.has(item.domain)}
onChange={() => handleToggleSelect(item.domain)}
aria-label={`Выбрать ${item.domain}`}
onClick={(e) => e.stopPropagation()}
/>
</td>
<td>
<div className="d-flex align-items-center">
<IconWorld size={16} className="me-2 text-muted" />
<span className="font-monospace">{item.domain}</span>
<Tooltip content="Копировать домен" position="top">
<button
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
onClick={() => copyToClipboard(item.domain)}
aria-label="Копировать"
title="Копировать домен"
>
<IconCopy size={14} />
</button>
</Tooltip>
</div>
</td>
<td>
<div className="d-flex align-items-center">
{renderCommunityBadge(item.community)}
</div>
</td>
<td className="text-end">
<div className="btn-group btn-group-sm" role="group" aria-label={`Действия с доменом ${item.domain}`}>
<Tooltip content="Редактировать" position="top">
<button
className="btn btn-icon btn-outline-primary"
aria-label="Редактировать"
onClick={() => handleEdit(item)}
title="Редактировать"
>
<IconEdit size={16} />
</button>
</Tooltip>
<Tooltip content="Удалить" position="top">
<button
className="btn btn-icon btn-outline-danger"
aria-label="Удалить"
onClick={() => confirmDelete(item)}
title="Удалить"
>
<IconTrash size={16} />
</button>
</Tooltip>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="card-footer border-top-0">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems || filtered.length}
pageSize={pageSize}
onPageChange={setCurrentPage}
/>
</div>
</div>
</div>
</div>
<ConfirmModal
show={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onConfirm={executeDelete}
title="Удалить домен?"
message={
itemToDelete ? (
<>
<p>Вы уверены, что хотите удалить <strong>"{itemToDelete.domain}"</strong>?</p>
<p className="text-muted mb-0">Это действие необратимо.</p>
</>
) : null
}
confirmLabel="Удалить"
variant="danger"
icon={IconTrash}
/>
{/* Confirm Save Modal */}
<ConfirmDiffModal
show={confirmSaveOpen}
diff={diff}
onConfirm={performSave}
onClose={() => setConfirmSaveOpen(false)}
/>
<ImportModal
show={importOpen}
title={'Импорт доменов'}
description={'Формат: DOMAIN ПРОБЕЛ COMMUNITY'}
parseLine={parseLine}
validateItem={validateItem}
onConfirm={applyImportedItems}
onClose={() => setImportOpen(false)}
sampleHeader={['domain','community']}
placeholder={'example.com 65000:100'}
/>
<HistoryModal
resource="domains-new"
show={historyOpen}
onClose={() => setHistoryOpen(false)}
onRolledBack={() => fetchItems()}
/>
<WsUpdateModal
show={wsOpen}
url={wsUrl}
onClose={() => setWsOpen(false)}
/>
{/* Модальное окно редактирования community */}
<FormModal
show={editModalOpen && !!itemBeingEdited}
onClose={handleCancelEdit}
onSubmit={handleSaveEdit}
title="Редактировать community"
submitLabel="Сохранить"
submitIcon={IconCheck}
size="md"
centered
disabled={!isValidCommunity(editingValue)}
>
{itemBeingEdited && (
<>
<p className="text-muted mb-2">
Домен: <span className="font-monospace">{itemBeingEdited.domain}</span>
</p>
<div className="mb-3">
<label className="form-label">Community</label>
<CommunityAutocompleteInput
value={editingValue}
onChange={setEditingValue}
communities={communities}
onKeyDown={handleEditKeyDown}
className={`form-control${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
useShortValue={true}
/>
{!isValidCommunity(editingValue) && (
<div className="invalid-feedback d-block">
Введите корректное значение community (число или N:N).
</div>
)}
</div>
</>
)}
</FormModal>
<ConfirmModal
show={clearCommunitiesOpen}
onClose={() => setClearCommunitiesOpen(false)}
onConfirm={() => {
setItems(prev => prev.filter(i => i != null && i.community !== filterCommunity));
setClearCommunitiesOpen(false);
}}
title="Удалить записи community?"
message={
<>
<p>Будут удалены все домены с community <strong>"{filterCommunity}"</strong>.</p>
<p className="text-warning mb-0">
<IconAlertTriangle size={16} className="me-1" />
Действие НЕ сохраняет изменения автоматически.
</p>
</>
}
confirmLabel="Удалить"
variant="warning"
/>
{/* Diff Modal */}
{showDiff && (
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
<div className="modal-dialog modal-lg modal-dialog-centered" role="document">
<div className="modal-content">
<button type="button" className="btn-close" onClick={() => setShowDiff(false)}></button>
<div className="modal-header"><h3 className="modal-title">Изменения</h3></div>
<div className="modal-body">
<div className="row g-2 g-md-3">
<div className="col-6 col-md-4"><div className="card"><div className="card-body py-2"><strong>Добавлено</strong><div className="text-muted">{diff.added.length}</div></div></div></div>
<div className="col-6 col-md-4"><div className="card"><div className="card-body py-2"><strong>Удалено</strong><div className="text-muted">{diff.removed.length}</div></div></div></div>
<div className="col-6 col-md-4"><div className="card"><div className="card-body py-2"><strong>Изменено</strong><div className="text-muted">{diff.changed.length}</div></div></div></div>
</div>
</div>
<div className="modal-footer">
<button className="btn" onClick={() => setShowDiff(false)}>Закрыть</button>
</div>
</div>
</div>
</div>
)}
{/* datalist больше не нужен, т.к. используем кастомный автокомплит */}
</>
);
}
export default DomainsNewManager;