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

This commit is contained in:
2025-10-02 18:30:31 +07:00
parent 339144fb11
commit cd5e169bc6
3 changed files with 241 additions and 80 deletions
+69 -25
View File
@@ -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>
+151 -48
View File
@@ -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">
+21 -7
View File
@@ -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 && (
<>