From 66eab3761d78c26783ccc46e0680b2215edd0a64 Mon Sep 17 00:00:00 2001 From: shats Date: Wed, 12 Nov 2025 19:12:21 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=20AutoUrlManager=20=D1=81=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC=20?= =?UTF-8?q?=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20=D1=84=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D1=86=D0=B8=D0=B8,=20=D1=81=D0=BE=D1=80?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B8=20=D0=B8=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=81=D1=81=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=B0=20URL.=20=D0=A0=D0=B5=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D1=8B=20=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=B8=D0=BA=D0=BE=D0=BD=D0=BA=D0=B8,=20=D1=83?= =?UTF-8?q?=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=B0=20=D0=BE=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B0=20=D0=BE=D1=88=D0=B8=D0=B1?= =?UTF-8?q?=D0=BE=D0=BA=20=D0=B8=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B9,=20=D0=B0=20=D1=82=D0=B0=D0=BA?= =?UTF-8?q?=D0=B6=D0=B5=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8=D1=87?= =?UTF-8?q?=D0=B5=D1=81=D0=BA=D0=B8=D0=B5=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B4=D0=BD=D1=8B=D1=85=20=D0=B8=20=D0=BD=D0=B5?= =?UTF-8?q?=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=BD=D1=8B=D1=85=20URL.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/AutoUrlManager.jsx | 604 ++++++++++++++++++++++++++------ 1 file changed, 502 insertions(+), 102 deletions(-) diff --git a/frontend/src/AutoUrlManager.jsx b/frontend/src/AutoUrlManager.jsx index d79e437..630d371 100644 --- a/frontend/src/AutoUrlManager.jsx +++ b/frontend/src/AutoUrlManager.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef, useMemo } from 'react'; import api from './lib/api.js'; import { IconPlus, @@ -6,28 +6,43 @@ import { IconDownload, IconAlertCircle, IconCheck, - IconLoader, IconUpload, IconDeviceFloppy, IconInfoCircle, IconCircleCheck, - IconCopy + IconCopy, + IconSearch, + IconLink, + IconHash, + IconWorld, + IconFileExport, + IconSortAscending, + IconSortDescending, + IconX } from '@tabler/icons-react'; import PageHeader from './components/PageHeader.jsx'; -import PageHeaderActions from './components/PageHeaderActions.jsx'; -import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx'; +import TableSkeleton from './components/TableSkeleton.jsx'; import EmptyState from './components/EmptyState.jsx'; import ImportModal from './components/ImportModal.jsx'; +import ErrorAlert from './components/ErrorAlert.jsx'; +import LastSaved from './components/LastSaved.jsx'; function AutoUrlManager() { const [urls, setUrls] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [processing, setProcessing] = useState(false); - const [message, setMessage] = useState(''); - const [messageType, setMessageType] = useState(''); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); const [touched, setTouched] = useState({}); 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(() => { fetchUrls(); @@ -36,12 +51,12 @@ function AutoUrlManager() { const fetchUrls = async () => { try { setLoading(true); + setError(''); const response = await api.get('/auto-urls'); - setUrls(response.data); - } catch (error) { - console.error('Error fetching URLs:', error); - setMessage('Ошибка при загрузке URL-адресов'); - setMessageType('error'); + setUrls(response.data || []); + } catch (err) { + console.error('Error fetching URLs:', err); + setError('Ошибка при загрузке URL-адресов'); } finally { setLoading(false); } @@ -89,26 +104,27 @@ function AutoUrlManager() { const saveUrls = async () => { try { setSaving(true); - setMessage(''); + setError(''); + setSuccess(''); // Validate URLs const validUrls = urls .filter(u => u != null && isValidHttpUrl(u.url) && isValidCommunity(u.community)) .map(u => ({ url: u.url.trim(), community: String(u.community).trim() })); if (validUrls.length === 0) { - setMessage('Добавьте хотя бы одну корректную запись (валидный URL и числовой community)'); - setMessageType('error'); + setError('Добавьте хотя бы одну корректную запись (валидный URL и числовой community)'); return; } await api.post('/auto-urls', { urls: validUrls }); setUrls(validUrls); - setMessage('URL-адреса сохранены успешно'); - setMessageType('success'); - } catch (error) { - console.error('Error saving URLs:', error); - setMessage('Ошибка при сохранении URL-адресов'); - setMessageType('error'); + setLastSaved(new Date().toISOString()); + setSuccess('URL-адреса сохранены успешно'); + setSelectedRows(new Set()); + setTimeout(() => setSuccess(''), 3000); + } catch (err) { + console.error('Error saving URLs:', err); + setError('Ошибка при сохранении URL-адресов'); } finally { setSaving(false); } @@ -117,15 +133,15 @@ function AutoUrlManager() { const processUrls = async () => { try { setProcessing(true); - setMessage(''); + setError(''); + setSuccess(''); const response = await api.post('/auto-urls/process'); - setMessage(response.data.message); - setMessageType('success'); - } catch (error) { - console.error('Error processing URLs:', error); - setMessage(error.response?.data || 'Ошибка при обработке URL-адресов'); - setMessageType('error'); + setSuccess(response.data?.message || 'URL-адреса успешно обработаны'); + setTimeout(() => setSuccess(''), 5000); + } catch (err) { + console.error('Error processing URLs:', err); + setError(err.response?.data?.message || err.response?.data || 'Ошибка при обработке URL-адресов'); } finally { setProcessing(false); } @@ -144,23 +160,127 @@ function AutoUrlManager() { document.execCommand('copy'); document.body.removeChild(ta); } - setMessage('Пример формата скопирован'); - setMessageType('success'); - setTimeout(() => setMessage(''), 2000); + setSuccess('Пример формата скопирован'); + setTimeout(() => setSuccess(''), 2000); } catch (e) { - setMessage('Не удалось скопировать пример'); - setMessageType('error'); - setTimeout(() => setMessage(''), 2000); + setError('Не удалось скопировать пример'); + setTimeout(() => setError(''), 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 (
+
+ {stats.total > 0 && ( + + )} +
+ )} + + {/* Статистика */} + {!loading && urls.length > 0 && ( +
+
+
+
+
+
+ + + +
+
+
{stats.total}
+
Всего URL
+
+
+
+
+
+
+
+
+
+
+ + + +
+
+
{stats.valid}
+
Валидных
+
+
+
+
+
+
+
+
+
+
+ + + +
+
+
{stats.invalid}
+
Невалидных
+
+
+
+
+
+
+
+
+
+
+ + + +
+
+
{filteredAndSortedUrls.length}
+
Отображается
+
+
+
+
-
)}
+
+
+
+

+ + Список URL-адресов +

+
+
+
+ + + + setSearchTerm(e.target.value)} + /> + {searchTerm && ( + + )} +
+
+
+
+ + + +
+
+
+
{loading ? ( - + ) : urls.length === 0 ? ( )} /> + ) : filteredAndSortedUrls.length === 0 ? ( + { + setSearchTerm(''); + setFilterValid('all'); + }} + > + + Сбросить фильтры + + )} + /> ) : ( -
- - - - - - - - - - {urls.map((url, index) => ( - - + + + + ); + })} + +
URLCommunityДействия
- {(() => { - const v = getRowValidity(url); - const invalid = touched[index] && !v.url; - return ( - <> + <> + {selectedRows.size > 0 && ( +
+ Выбрано записей: {selectedRows.size} +
+ + +
+
+ )} +
+ + + + + + + + + + + + {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 ( + + + - + - - - ))} - -
+ 0 && filteredAndSortedUrls.every(url => { + const idx = urls.findIndex(u => u === url); + return selectedRows.has(idx); + })} + onChange={toggleSelectAll} + title="Выбрать все" + /> + toggleSort('url')} + style={{ minWidth: 300 }} + > +
+ + URL + {sortField === 'url' && ( + sortDir === 'asc' ? : + )} +
+
toggleSort('community')} + style={{ minWidth: 150 }} + > +
+ + Community + {sortField === 'community' && ( + sortDir === 'asc' ? : + )} +
+
СтатусДействия
+ toggleSelectRow(originalIndex)} + /> + +
+ + {v.url ? ( + + ) : touched[originalIndex] ? ( + + ) : null} + updateUrl(index, 'url', e.target.value)} + onChange={(e) => updateUrl(originalIndex, 'url', e.target.value)} disabled={saving || processing} /> - {invalid && ( -
Укажите корректный http/https URL
- )} - - ); - })()} -
- {(() => { - const v = getRowValidity(url); - const invalid = touched[index] && !v.community; - return ( - <> + + {isInvalid && !v.url && ( +
Укажите корректный http/https URL
+ )} +
+
+ + {v.community ? ( + + ) : touched[originalIndex] ? ( + + ) : null} + updateUrl(index, 'community', e.target.value)} + onChange={(e) => updateUrl(originalIndex, 'community', e.target.value)} disabled={saving || processing} /> - {invalid && ( -
Только цифры, например 555
- )} - - ); - })()} -
- -
-
+ + {isInvalid && !v.community && ( +
Только цифры, например 555
+ )} +
+ {isValid ? ( + + + Валидно + + ) : isInvalid ? ( + + + Ошибка + + ) : ( + Не заполнено + )} + + +
+
+ )}
-
-
+
+
{urls.length > 0 && ( - Всего URL-адресов: {urls.length} + <> + Всего: {stats.total} + Валидных: {stats.valid} + {stats.invalid > 0 && ( + Невалидных: {stats.invalid} + )} + + )} + {lastSaved && ( + )}
- Введите http/https URL и числовой community. Не валидные поля подсвечиваются. + Введите http/https URL и числовой community. Невалидные поля подсвечиваются.