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

This commit is contained in:
2025-11-12 19:12:21 +07:00
parent 1769c07883
commit 66eab3761d
+502 -102
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef, useMemo } from 'react';
import api from './lib/api.js'; import api from './lib/api.js';
import { import {
IconPlus, IconPlus,
@@ -6,28 +6,43 @@ import {
IconDownload, IconDownload,
IconAlertCircle, IconAlertCircle,
IconCheck, IconCheck,
IconLoader,
IconUpload, IconUpload,
IconDeviceFloppy, IconDeviceFloppy,
IconInfoCircle, IconInfoCircle,
IconCircleCheck, IconCircleCheck,
IconCopy IconCopy,
IconSearch,
IconLink,
IconHash,
IconWorld,
IconFileExport,
IconSortAscending,
IconSortDescending,
IconX
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import PageHeader from './components/PageHeader.jsx'; import PageHeader from './components/PageHeader.jsx';
import PageHeaderActions from './components/PageHeaderActions.jsx'; import TableSkeleton from './components/TableSkeleton.jsx';
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
import EmptyState from './components/EmptyState.jsx'; import EmptyState from './components/EmptyState.jsx';
import ImportModal from './components/ImportModal.jsx'; import ImportModal from './components/ImportModal.jsx';
import ErrorAlert from './components/ErrorAlert.jsx';
import LastSaved from './components/LastSaved.jsx';
function AutoUrlManager() { function AutoUrlManager() {
const [urls, setUrls] = useState([]); const [urls, setUrls] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [message, setMessage] = useState(''); const [error, setError] = useState('');
const [messageType, setMessageType] = useState(''); const [success, setSuccess] = useState('');
const [touched, setTouched] = useState({}); const [touched, setTouched] = useState({});
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [filterValid, setFilterValid] = useState('all'); // 'all' | 'valid' | 'invalid'
const [sortField, setSortField] = useState(null); // 'url' | 'community' | null
const [sortDir, setSortDir] = useState('asc'); // 'asc' | 'desc'
const [lastSaved, setLastSaved] = useState(null);
const [selectedRows, setSelectedRows] = useState(new Set());
const searchInputRef = useRef(null);
useEffect(() => { useEffect(() => {
fetchUrls(); fetchUrls();
@@ -36,12 +51,12 @@ function AutoUrlManager() {
const fetchUrls = async () => { const fetchUrls = async () => {
try { try {
setLoading(true); setLoading(true);
setError('');
const response = await api.get('/auto-urls'); const response = await api.get('/auto-urls');
setUrls(response.data); setUrls(response.data || []);
} catch (error) { } catch (err) {
console.error('Error fetching URLs:', error); console.error('Error fetching URLs:', err);
setMessage('Ошибка при загрузке URL-адресов'); setError('Ошибка при загрузке URL-адресов');
setMessageType('error');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -89,26 +104,27 @@ function AutoUrlManager() {
const saveUrls = async () => { const saveUrls = async () => {
try { try {
setSaving(true); setSaving(true);
setMessage(''); setError('');
setSuccess('');
// Validate URLs // Validate URLs
const validUrls = urls const validUrls = urls
.filter(u => u != null && isValidHttpUrl(u.url) && isValidCommunity(u.community)) .filter(u => u != null && isValidHttpUrl(u.url) && isValidCommunity(u.community))
.map(u => ({ url: u.url.trim(), community: String(u.community).trim() })); .map(u => ({ url: u.url.trim(), community: String(u.community).trim() }));
if (validUrls.length === 0) { if (validUrls.length === 0) {
setMessage('Добавьте хотя бы одну корректную запись (валидный URL и числовой community)'); setError('Добавьте хотя бы одну корректную запись (валидный URL и числовой community)');
setMessageType('error');
return; return;
} }
await api.post('/auto-urls', { urls: validUrls }); await api.post('/auto-urls', { urls: validUrls });
setUrls(validUrls); setUrls(validUrls);
setMessage('URL-адреса сохранены успешно'); setLastSaved(new Date().toISOString());
setMessageType('success'); setSuccess('URL-адреса сохранены успешно');
} catch (error) { setSelectedRows(new Set());
console.error('Error saving URLs:', error); setTimeout(() => setSuccess(''), 3000);
setMessage('Ошибка при сохранении URL-адресов'); } catch (err) {
setMessageType('error'); console.error('Error saving URLs:', err);
setError('Ошибка при сохранении URL-адресов');
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -117,15 +133,15 @@ function AutoUrlManager() {
const processUrls = async () => { const processUrls = async () => {
try { try {
setProcessing(true); setProcessing(true);
setMessage(''); setError('');
setSuccess('');
const response = await api.post('/auto-urls/process'); const response = await api.post('/auto-urls/process');
setMessage(response.data.message); setSuccess(response.data?.message || 'URL-адреса успешно обработаны');
setMessageType('success'); setTimeout(() => setSuccess(''), 5000);
} catch (error) { } catch (err) {
console.error('Error processing URLs:', error); console.error('Error processing URLs:', err);
setMessage(error.response?.data || 'Ошибка при обработке URL-адресов'); setError(err.response?.data?.message || err.response?.data || 'Ошибка при обработке URL-адресов');
setMessageType('error');
} finally { } finally {
setProcessing(false); setProcessing(false);
} }
@@ -144,23 +160,127 @@ function AutoUrlManager() {
document.execCommand('copy'); document.execCommand('copy');
document.body.removeChild(ta); document.body.removeChild(ta);
} }
setMessage('Пример формата скопирован'); setSuccess('Пример формата скопирован');
setMessageType('success'); setTimeout(() => setSuccess(''), 2000);
setTimeout(() => setMessage(''), 2000);
} catch (e) { } catch (e) {
setMessage('Не удалось скопировать пример'); setError('Не удалось скопировать пример');
setMessageType('error'); setTimeout(() => setError(''), 2000);
setTimeout(() => setMessage(''), 2000);
} }
}; };
// Статистика
const stats = useMemo(() => {
const valid = urls.filter(u => u != null && isValidHttpUrl(u.url) && isValidCommunity(u.community)).length;
const invalid = urls.length - valid;
return { total: urls.length, valid, invalid };
}, [urls]);
// Фильтрация и сортировка
const filteredAndSortedUrls = useMemo(() => {
let filtered = urls.filter(u => {
if (!u) return false;
const matchesSearch = !searchTerm ||
u.url.toLowerCase().includes(searchTerm.toLowerCase()) ||
String(u.community).toLowerCase().includes(searchTerm.toLowerCase());
if (!matchesSearch) return false;
if (filterValid === 'valid') {
return isValidHttpUrl(u.url) && isValidCommunity(u.community);
} else if (filterValid === 'invalid') {
return !isValidHttpUrl(u.url) || !isValidCommunity(u.community);
}
return true;
});
if (sortField) {
filtered = [...filtered].sort((a, b) => {
const aVal = String(a[sortField] || '').toLowerCase();
const bVal = String(b[sortField] || '').toLowerCase();
const comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
return sortDir === 'asc' ? comparison : -comparison;
});
}
return filtered;
}, [urls, searchTerm, filterValid, sortField, sortDir]);
const toggleSort = (field) => {
if (sortField === field) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDir('asc');
}
};
const toggleSelectRow = (originalIndex) => {
setSelectedRows(prev => {
const next = new Set(prev);
if (next.has(originalIndex)) next.delete(originalIndex);
else next.add(originalIndex);
return next;
});
};
const toggleSelectAll = () => {
const allOriginalIndices = filteredAndSortedUrls.map(url => urls.findIndex(u => u === url));
const allSelected = allOriginalIndices.every(idx => selectedRows.has(idx));
if (allSelected) {
setSelectedRows(new Set());
} else {
setSelectedRows(new Set(allOriginalIndices));
}
};
const deleteSelected = () => {
setUrls(prev => prev.filter((_, i) => !selectedRows.has(i)));
setSelectedRows(new Set());
};
const exportUrls = () => {
const text = urls
.filter(u => u != null && isValidHttpUrl(u.url) && isValidCommunity(u.community))
.map(u => `${u.url.trim()} ${String(u.community).trim()}`)
.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 = `auto-urls-${new Date().toISOString().split('T')[0]}.txt`;
a.click();
URL.revokeObjectURL(url);
};
// Горячие клавиши
useEffect(() => {
const handleKeyDown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
if (hasAtLeastOneValidRow && !saving && !processing) {
saveUrls();
}
}
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
searchInputRef.current?.focus();
}
if (e.key === 'Escape') {
setSearchTerm('');
setFilterValid('all');
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [hasAtLeastOneValidRow, saving, processing, saveUrls]);
return ( return (
<div> <div>
<PageHeader <PageHeader
title="Автоматические URL" title="Автоматические URL"
pretitle="Управление автоматическими URL-адресами для загрузки списков IP и доменов" pretitle="Управление автоматическими URL-адресами для загрузки списков IP и доменов"
actions={( actions={(
<div className="d-flex align-items-center justify-content-between"> <div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div className="btn-list"> <div className="btn-list">
<button <button
className="btn btn-outline-primary" className="btn btn-outline-primary"
@@ -182,6 +302,17 @@ function AutoUrlManager() {
</button> </button>
</div> </div>
<div className="ms-auto btn-list"> <div className="ms-auto btn-list">
{stats.total > 0 && (
<button
className="btn btn-outline-secondary"
type="button"
onClick={exportUrls}
disabled={saving || processing || stats.valid === 0}
title="Экспорт валидных URL"
>
<IconFileExport className="me-1" /> Экспорт
</button>
)}
<button <button
className="btn btn-outline-secondary" className="btn btn-outline-secondary"
type="button" type="button"
@@ -196,7 +327,7 @@ function AutoUrlManager() {
type="button" type="button"
onClick={saveUrls} onClick={saveUrls}
disabled={saving || processing || !hasAtLeastOneValidRow} disabled={saving || processing || !hasAtLeastOneValidRow}
title="Сохранить список URL" title="Сохранить список URL (Ctrl+S)"
> >
{saving ? ( {saving ? (
<> <>
@@ -232,20 +363,164 @@ function AutoUrlManager() {
)} )}
/> />
{message && ( {/* Уведомления */}
<div className={`alert alert-${messageType === 'error' ? 'danger' : 'success'} alert-dismissible`} role="alert"> {error && (
<ErrorAlert
message={error}
onClose={() => setError('')}
onRetry={fetchUrls}
/>
)}
{success && (
<div className="alert alert-success alert-dismissible" role="alert">
<div className="d-flex"> <div className="d-flex">
{messageType === 'error' ? <IconAlertCircle className="me-2" /> : <IconCheck className="me-2" />} <IconCheck className="me-2" />
{message} {success}
</div>
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
</div>
)}
{/* Статистика */}
{!loading && urls.length > 0 && (
<div className="row g-3 mb-4">
<div className="col-md-3">
<div className="card card-sm">
<div className="card-body">
<div className="row align-items-center">
<div className="col-auto">
<span className="avatar avatar-lg bg-blue-lt text-blue">
<IconWorld size={24} />
</span>
</div>
<div className="col">
<div className="font-weight-medium">{stats.total}</div>
<div className="text-muted small">Всего URL</div>
</div>
</div>
</div>
</div>
</div>
<div className="col-md-3">
<div className="card card-sm">
<div className="card-body">
<div className="row align-items-center">
<div className="col-auto">
<span className="avatar avatar-lg bg-green-lt text-green">
<IconCheck size={24} />
</span>
</div>
<div className="col">
<div className="font-weight-medium">{stats.valid}</div>
<div className="text-muted small">Валидных</div>
</div>
</div>
</div>
</div>
</div>
<div className="col-md-3">
<div className="card card-sm">
<div className="card-body">
<div className="row align-items-center">
<div className="col-auto">
<span className="avatar avatar-lg bg-red-lt text-red">
<IconAlertCircle size={24} />
</span>
</div>
<div className="col">
<div className="font-weight-medium">{stats.invalid}</div>
<div className="text-muted small">Невалидных</div>
</div>
</div>
</div>
</div>
</div>
<div className="col-md-3">
<div className="card card-sm">
<div className="card-body">
<div className="row align-items-center">
<div className="col-auto">
<span className="avatar avatar-lg bg-orange-lt text-orange">
<IconLink size={24} />
</span>
</div>
<div className="col">
<div className="font-weight-medium">{filteredAndSortedUrls.length}</div>
<div className="text-muted small">Отображается</div>
</div>
</div>
</div>
</div>
</div> </div>
<button type="button" className="btn-close" onClick={() => setMessage('')}></button>
</div> </div>
)} )}
<div className="card"> <div className="card">
<div className="card-header">
<div className="row align-items-center g-2">
<div className="col">
<h3 className="card-title mb-0">
<IconLink className="me-2" />
Список URL-адресов
</h3>
</div>
<div className="col-auto">
<div className="input-group" style={{ maxWidth: 300 }}>
<span className="input-group-text">
<IconSearch size={16} />
</span>
<input
ref={searchInputRef}
type="text"
className="form-control"
placeholder="Поиск (Ctrl+F)..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{searchTerm && (
<button
className="btn btn-outline-secondary"
type="button"
onClick={() => setSearchTerm('')}
title="Очистить поиск"
>
<IconX size={16} />
</button>
)}
</div>
</div>
<div className="col-auto">
<div className="btn-group">
<button
className={`btn btn-sm ${filterValid === 'all' ? 'btn-primary' : 'btn-outline-secondary'}`}
onClick={() => setFilterValid('all')}
title="Все записи"
>
Все
</button>
<button
className={`btn btn-sm ${filterValid === 'valid' ? 'btn-success' : 'btn-outline-success'}`}
onClick={() => setFilterValid('valid')}
title="Только валидные"
>
<IconCheck size={14} className="me-1" />
Валидные
</button>
<button
className={`btn btn-sm ${filterValid === 'invalid' ? 'btn-danger' : 'btn-outline-danger'}`}
onClick={() => setFilterValid('invalid')}
title="Только невалидные"
>
<IconAlertCircle size={14} className="me-1" />
Невалидные
</button>
</div>
</div>
</div>
</div>
<div className="card-body"> <div className="card-body">
{loading ? ( {loading ? (
<TableSkeleton rows={6} cols={3} /> <TableSkeleton rows={6} cols={4} />
) : urls.length === 0 ? ( ) : urls.length === 0 ? (
<EmptyState <EmptyState
icon={IconAlertCircle} icon={IconAlertCircle}
@@ -273,86 +548,211 @@ function AutoUrlManager() {
</button> </button>
)} )}
/> />
) : filteredAndSortedUrls.length === 0 ? (
<EmptyState
icon={IconSearch}
title="Ничего не найдено"
description={searchTerm ? `По запросу "${searchTerm}" ничего не найдено` : 'Нет записей, соответствующих выбранному фильтру'}
size="default"
action={(
<button
className="btn btn-outline-secondary"
onClick={() => {
setSearchTerm('');
setFilterValid('all');
}}
>
<IconX className="me-1" />
Сбросить фильтры
</button>
)}
/>
) : ( ) : (
<div className="table-responsive"> <>
<table className="table card-table table-vcenter table-nowrap mb-0"> {selectedRows.size > 0 && (
<thead> <div className="alert alert-info d-flex justify-content-between align-items-center mb-3">
<tr> <span>Выбрано записей: <strong>{selectedRows.size}</strong></span>
<th>URL</th> <div className="btn-list">
<th>Community</th> <button
<th className="text-end" style={{ width: 100 }}>Действия</th> className="btn btn-sm btn-outline-danger"
</tr> onClick={deleteSelected}
</thead> >
<tbody> <IconTrash size={14} className="me-1" />
{urls.map((url, index) => ( Удалить выбранные
<tr key={index}> </button>
<td> <button
{(() => { className="btn btn-sm btn-outline-secondary"
const v = getRowValidity(url); onClick={() => setSelectedRows(new Set())}
const invalid = touched[index] && !v.url; >
return ( <IconX size={14} className="me-1" />
<> Отменить выбор
</button>
</div>
</div>
)}
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{ width: 40 }}>
<input
type="checkbox"
className="form-check-input"
checked={filteredAndSortedUrls.length > 0 && filteredAndSortedUrls.every(url => {
const idx = urls.findIndex(u => u === url);
return selectedRows.has(idx);
})}
onChange={toggleSelectAll}
title="Выбрать все"
/>
</th>
<th
className="cursor-pointer"
onClick={() => toggleSort('url')}
style={{ minWidth: 300 }}
>
<div className="d-flex align-items-center">
<IconLink size={16} className="me-1" />
URL
{sortField === 'url' && (
sortDir === 'asc' ? <IconSortAscending size={16} className="ms-1" /> : <IconSortDescending size={16} className="ms-1" />
)}
</div>
</th>
<th
className="cursor-pointer"
onClick={() => toggleSort('community')}
style={{ minWidth: 150 }}
>
<div className="d-flex align-items-center">
<IconHash size={16} className="me-1" />
Community
{sortField === 'community' && (
sortDir === 'asc' ? <IconSortAscending size={16} className="ms-1" /> : <IconSortDescending size={16} className="ms-1" />
)}
</div>
</th>
<th style={{ width: 100 }}>Статус</th>
<th className="text-end" style={{ width: 100 }}>Действия</th>
</tr>
</thead>
<tbody>
{filteredAndSortedUrls.map((url, displayIndex) => {
const originalIndex = urls.findIndex(u => u === url);
const v = getRowValidity(url);
const isValid = v.url && v.community;
const isInvalid = touched[originalIndex] && (!v.url || !v.community);
const isSelected = selectedRows.has(originalIndex);
return (
<tr
key={originalIndex}
className={`${isSelected ? 'table-active' : ''} ${isInvalid ? 'table-danger' : isValid ? 'table-success' : ''}`}
>
<td>
<input
type="checkbox"
className="form-check-input"
checked={isSelected}
onChange={() => toggleSelectRow(originalIndex)}
/>
</td>
<td>
<div className="input-group input-group-sm">
<span className="input-group-text bg-transparent border-0 p-0 me-1">
{v.url ? (
<IconCheck size={16} className="text-success" />
) : touched[originalIndex] ? (
<IconAlertCircle size={16} className="text-danger" />
) : null}
</span>
<input <input
type="text" type="text"
className={`form-control${invalid ? ' is-invalid' : ''}`} className={`form-control${isInvalid && !v.url ? ' is-invalid' : ''}`}
placeholder="https://example.com/ips.txt" placeholder="https://example.com/ips.txt"
value={url.url} value={url.url}
onChange={(e) => updateUrl(index, 'url', e.target.value)} onChange={(e) => updateUrl(originalIndex, 'url', e.target.value)}
disabled={saving || processing} disabled={saving || processing}
/> />
{invalid && ( </div>
<div className="invalid-feedback">Укажите корректный http/https URL</div> {isInvalid && !v.url && (
)} <div className="invalid-feedback d-block">Укажите корректный http/https URL</div>
</> )}
); </td>
})()} <td>
</td> <div className="input-group input-group-sm">
<td> <span className="input-group-text bg-transparent border-0 p-0 me-1">
{(() => { {v.community ? (
const v = getRowValidity(url); <IconCheck size={16} className="text-success" />
const invalid = touched[index] && !v.community; ) : touched[originalIndex] ? (
return ( <IconAlertCircle size={16} className="text-danger" />
<> ) : null}
</span>
<input <input
type="text" type="text"
className={`form-control${invalid ? ' is-invalid' : ''}`} className={`form-control${isInvalid && !v.community ? ' is-invalid' : ''}`}
placeholder="555" placeholder="555"
value={url.community} value={url.community}
onChange={(e) => updateUrl(index, 'community', e.target.value)} onChange={(e) => updateUrl(originalIndex, 'community', e.target.value)}
disabled={saving || processing} disabled={saving || processing}
/> />
{invalid && ( </div>
<div className="invalid-feedback">Только цифры, например 555</div> {isInvalid && !v.community && (
)} <div className="invalid-feedback d-block">Только цифры, например 555</div>
</> )}
); </td>
})()} <td>
</td> {isValid ? (
<td className="text-end"> <span className="badge bg-green-lt text-green">
<button <IconCheck size={12} className="me-1" />
className="btn btn-outline-danger btn-icon btn-sm" Валидно
onClick={() => removeUrl(index)} </span>
disabled={saving || processing} ) : isInvalid ? (
> <span className="badge bg-red-lt text-red">
<IconTrash size={16} /> <IconAlertCircle size={12} className="me-1" />
</button> Ошибка
</td> </span>
</tr> ) : (
))} <span className="badge bg-secondary-lt text-secondary">Не заполнено</span>
</tbody> )}
</table> </td>
</div> <td className="text-end">
<button
className="btn btn-outline-danger btn-icon btn-sm"
onClick={() => removeUrl(originalIndex)}
disabled={saving || processing}
title="Удалить"
>
<IconTrash size={16} />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)} )}
</div> </div>
<div className="card-footer"> <div className="card-footer">
<div className="d-flex justify-content-between align-items-center"> <div className="d-flex justify-content-between align-items-center flex-wrap gap-2">
<div className="text-muted"> <div className="d-flex align-items-center gap-2">
{urls.length > 0 && ( {urls.length > 0 && (
<span className="badge bg-blue-lt text-blue">Всего URL-адресов: {urls.length}</span> <>
<span className="badge bg-blue-lt text-blue">Всего: {stats.total}</span>
<span className="badge bg-green-lt text-green">Валидных: {stats.valid}</span>
{stats.invalid > 0 && (
<span className="badge bg-red-lt text-red">Невалидных: {stats.invalid}</span>
)}
</>
)}
{lastSaved && (
<LastSaved timestamp={lastSaved} variant="compact" />
)} )}
</div> </div>
<div className="text-muted small"> <div className="text-muted small">
Введите http/https URL и числовой community. Не валидные поля подсвечиваются. Введите http/https URL и числовой community. Невалидные поля подсвечиваются.
</div> </div>
</div> </div>
</div> </div>