feat: Добавить функциональность массовых операций в менеджер доменов, включая выбор всех, удаление и экспорт выбранных доменов. Обновить интерфейс с новыми компонентами для улучшения пользовательского опыта и добавление сохранённых фильтров для более удобной работы с данными.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m32s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m32s
This commit is contained in:
+69
-25
@@ -294,35 +294,79 @@ function MainLayout() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Правая часть навбара: язык и тема */}
|
||||
<div className="navbar-nav flex-row order-md-last ms-2">
|
||||
<div className="nav-item me-2">
|
||||
<select
|
||||
className="form-select form-select-sm"
|
||||
value={lang}
|
||||
onChange={(e) => setLang(e.target.value)}
|
||||
aria-label="language"
|
||||
{/* Правая часть навбара: язык, тема и действия */}
|
||||
<div className="navbar-nav flex-row order-md-last">
|
||||
{/* Язык */}
|
||||
<div className="nav-item dropdown me-1">
|
||||
<a
|
||||
href="#"
|
||||
className="nav-link px-2 dropdown-toggle"
|
||||
data-bs-toggle="dropdown"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<option value="ru">RU</option>
|
||||
<option value="en">EN</option>
|
||||
</select>
|
||||
<span className="badge">{lang.toUpperCase()}</span>
|
||||
</a>
|
||||
<div className="dropdown-menu dropdown-menu-end">
|
||||
<a
|
||||
href="#"
|
||||
className={`dropdown-item ${lang === 'ru' ? 'active' : ''}`}
|
||||
onClick={(e) => { e.preventDefault(); setLang('ru'); }}
|
||||
>
|
||||
Русский
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className={`dropdown-item ${lang === 'en' ? 'active' : ''}`}
|
||||
onClick={(e) => { e.preventDefault(); setLang('en'); }}
|
||||
>
|
||||
English
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Тема */}
|
||||
<div className="nav-item dropdown me-1">
|
||||
<a
|
||||
href="#"
|
||||
className="nav-link px-2 dropdown-toggle"
|
||||
data-bs-toggle="dropdown"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
{theme === 'dark' ? '🌙' : '☀️'}
|
||||
</a>
|
||||
<div className="dropdown-menu dropdown-menu-end">
|
||||
<a
|
||||
href="#"
|
||||
className={`dropdown-item ${theme === 'light' ? 'active' : ''}`}
|
||||
onClick={(e) => { e.preventDefault(); setTheme('light'); }}
|
||||
>
|
||||
☀️ {t('light')}
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className={`dropdown-item ${theme === 'dark' ? 'active' : ''}`}
|
||||
onClick={(e) => { e.preventDefault(); setTheme('dark'); }}
|
||||
>
|
||||
🌙 {t('dark')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Горячие клавиши */}
|
||||
<div className="nav-item me-1">
|
||||
<KeyboardShortcutsButton className="nav-link px-2" />
|
||||
</div>
|
||||
|
||||
{/* Настройки */}
|
||||
<div className="nav-item">
|
||||
<select
|
||||
className="form-select form-select-sm"
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value)}
|
||||
aria-label="theme"
|
||||
<a
|
||||
href="#"
|
||||
className="nav-link px-2"
|
||||
onClick={(e) => { e.preventDefault(); setSettingsOpen(true); }}
|
||||
title="Настройки"
|
||||
>
|
||||
<option value="light">{t('light')}</option>
|
||||
<option value="dark">{t('dark')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="nav-item ms-2 d-flex gap-1">
|
||||
<KeyboardShortcutsButton />
|
||||
<button className="btn btn-outline-primary btn-sm" onClick={() => setSettingsOpen(true)}>
|
||||
<IconSettings className="me-1" /> Настройки
|
||||
</button>
|
||||
<IconSettings size={20} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
IconDeviceFloppy,
|
||||
IconHash,
|
||||
IconClock,
|
||||
IconFileText
|
||||
IconFileText,
|
||||
IconFilter
|
||||
} from '@tabler/icons-react';
|
||||
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
|
||||
import PageHeader from './components/PageHeader.jsx';
|
||||
@@ -33,10 +34,15 @@ 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 ValidatedInput from './components/ValidatedInput.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('');
|
||||
@@ -62,6 +68,9 @@ function DomainsNewManager() {
|
||||
const editInputRef = useRef(null);
|
||||
const pageSize = 10;
|
||||
|
||||
// Bulk operations
|
||||
const [selectedDomains, setSelectedDomains] = useState(new Set());
|
||||
|
||||
// Справочник community для подсказок
|
||||
const [communities, setCommunities] = useState([]);
|
||||
useEffect(() => {
|
||||
@@ -399,6 +408,47 @@ function DomainsNewManager() {
|
||||
if (e.key === 'Escape') 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 newItems = items.filter(i => !selectedDomains.has(i.domain));
|
||||
setItems(newItems);
|
||||
setSelectedDomains(new Set());
|
||||
toast.success(`Удалено ${selectedDomains.size} доменов`);
|
||||
};
|
||||
|
||||
const handleBulkExport = () => {
|
||||
if (selectedDomains.size === 0) return;
|
||||
const selectedItems = items.filter(i => 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 && (
|
||||
@@ -422,11 +472,16 @@ function DomainsNewManager() {
|
||||
<PageHeader
|
||||
title="Домены"
|
||||
pretitle={(
|
||||
<Breadcrumbs items={[
|
||||
{ label: 'Главная', to: '/dashboard' },
|
||||
{ label: 'Данные' },
|
||||
{ label: 'Домены' },
|
||||
]} />
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Главная', to: '/dashboard' },
|
||||
{ label: 'Данные' },
|
||||
{ label: 'Домены', count: filtered.length },
|
||||
]}
|
||||
showBack={false}
|
||||
filterInfo={filterCommunity ? `Community: ${filterCommunity}` : (searchTerm ? `Поиск: "${searchTerm}"` : null)}
|
||||
count={filtered.length}
|
||||
/>
|
||||
)}
|
||||
actions={(
|
||||
<PageHeaderActions
|
||||
@@ -564,54 +619,86 @@ function DomainsNewManager() {
|
||||
</div>
|
||||
|
||||
<div className="col-lg-9">
|
||||
{/* Bulk Actions Bar */}
|
||||
<BulkActionsBar
|
||||
selectedCount={selectedDomains.size}
|
||||
totalCount={paginatedItems.length}
|
||||
onSelectAll={handleSelectAll}
|
||||
onDeselectAll={handleDeselectAll}
|
||||
onDelete={handleBulkDelete}
|
||||
onExport={handleBulkExport}
|
||||
/>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<h3 className="card-title mb-0">Список доменов <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3>
|
||||
<div className="d-flex gap-2 w-50">
|
||||
{/* Фильтр по community */}
|
||||
<select
|
||||
className="form-select w-auto"
|
||||
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 className="input-icon flex-grow-1">
|
||||
<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="Поиск домена"
|
||||
/>
|
||||
<div className="card-header">
|
||||
<div className="row align-items-center w-100">
|
||||
<div className="col-auto">
|
||||
<h3 className="card-title mb-0">
|
||||
Список доменов
|
||||
<span className="badge bg-blue-lt text-blue ms-2">{filtered.length}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="d-flex gap-2">
|
||||
{/* Сохранённые фильтры */}
|
||||
<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 */}
|
||||
<select
|
||||
className="form-select w-auto"
|
||||
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 className="input-icon flex-grow-1">
|
||||
<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="Поиск домена"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
{/* Мета-информация будет показана внизу карточки */}
|
||||
{loading ? (
|
||||
<TableSkeleton rows={10} cols={3} />
|
||||
<TableSkeleton rows={10} cols={4} hasCheckbox={true} />
|
||||
) : paginatedItems.length === 0 ? (
|
||||
<TableEmpty cols={3}>
|
||||
<EmptyState
|
||||
@@ -632,6 +719,14 @@ function DomainsNewManager() {
|
||||
<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={selectedDomains.size === paginatedItems.length && paginatedItems.length > 0}
|
||||
onChange={(e) => e.target.checked ? handleSelectAll() : handleDeselectAll()}
|
||||
/>
|
||||
</th>
|
||||
<th className="cursor-pointer" onClick={() => handleSort('domain')}>
|
||||
Домен
|
||||
{sortField === 'domain' && (
|
||||
@@ -654,6 +749,14 @@ function DomainsNewManager() {
|
||||
<tbody>
|
||||
{paginatedItems.map((item) => (
|
||||
<tr key={item.domain} className={editingDomain === item.domain ? 'table-info' : ''}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={selectedDomains.has(item.domain)}
|
||||
onChange={() => handleToggleSelect(item.domain)}
|
||||
/>
|
||||
</td>
|
||||
<td>{item.domain}</td>
|
||||
<td>{renderCommunityBadge(item.community)}</td>
|
||||
<td className="text-end">
|
||||
|
||||
@@ -203,15 +203,29 @@ export function KeyboardShortcutsButton({ className = '' }) {
|
||||
{ keys: ['Enter'], description: 'Подтвердить / Выбрать' },
|
||||
]
|
||||
|
||||
// Если className содержит nav-link, используем ссылку вместо кнопки
|
||||
const isNavLink = className.includes('nav-link')
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={`btn btn-ghost-secondary btn-icon ${className}`}
|
||||
onClick={() => setShowModal(true)}
|
||||
title="Горячие клавиши"
|
||||
>
|
||||
<IconKeyboard size={20} />
|
||||
</button>
|
||||
{isNavLink ? (
|
||||
<a
|
||||
href="#"
|
||||
className={className}
|
||||
onClick={(e) => { e.preventDefault(); setShowModal(true); }}
|
||||
title="Горячие клавиши (Ctrl+/)"
|
||||
>
|
||||
<IconKeyboard size={20} />
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
className={`btn btn-ghost-secondary btn-icon ${className}`}
|
||||
onClick={() => setShowModal(true)}
|
||||
title="Горячие клавиши (Ctrl+/)"
|
||||
>
|
||||
<IconKeyboard size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user