feat: Добавить модальные окна для импорта данных и компонент QuickAddBar в менеджерах ASNs, Domains и IPRanges, улучшив пользовательский интерфейс и упрощая процесс добавления записей
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s

This commit is contained in:
2025-08-27 18:05:25 +07:00
parent 6a81ad9983
commit d30a507f60
6 changed files with 406 additions and 220 deletions
+150
View File
@@ -0,0 +1,150 @@
import { useEffect, useRef, useState } from 'react';
function ImportModal({
show,
title = 'Импорт',
description = 'Вставьте текст или перетащите файл TXT/CSV. Формат: VALUE COMMUNITY',
parseLine,
validateItem,
onConfirm,
onClose,
sampleHeader = ['value', 'community'],
placeholder = 'value community\nvalue community',
}) {
const [text, setText] = useState('');
const [dragOver, setDragOver] = useState(false);
const [parsed, setParsed] = useState({ items: [], invalid: [] });
const [fileName, setFileName] = useState('');
const textAreaRef = useRef(null);
useEffect(() => {
if (show) {
setText('');
setParsed({ items: [], invalid: [] });
setFileName('');
setDragOver(false);
setTimeout(() => textAreaRef.current?.focus(), 0);
}
}, [show]);
if (!show) return null;
const parseText = (raw) => {
const lines = String(raw || '')
.split(/\r?\n/)
.map(l => l.trim())
.filter(Boolean);
const items = [];
const invalid = [];
for (const line of lines) {
const obj = parseLine(line);
if (obj && validateItem(obj)) items.push(obj); else invalid.push(line);
}
setParsed({ items, invalid });
};
const handleDrop = async (e) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer?.files?.[0];
if (!file) return;
setFileName(file.name);
const content = await file.text();
setText(content);
parseText(content);
};
const handleChange = (value) => {
setText(value);
parseText(value);
};
const handleConfirm = () => {
if (parsed.items.length === 0) return;
onConfirm?.(parsed.items);
};
const downloadSample = () => {
const csv = [sampleHeader, ['example', '65000:100']]
.map(r => r.map(x => `"${String(x ?? '').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 = 'sample.csv';
a.click();
URL.revokeObjectURL(url);
};
return (
<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={onClose}></button>
<div className="modal-header">
<h3 className="modal-title">{title}</h3>
</div>
<div className="modal-body">
<div className="mb-2 text-muted">{description}</div>
<div
className={`mb-3 border-dashed rounded p-3 ${dragOver ? 'bg-blue-lt' : ''}`}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
>
<div className="d-flex align-items-center justify-content-between">
<div className="me-3 text-muted">
{fileName ? `Файл: ${fileName}` : 'Перетащите TXT/CSV сюда или вставьте текст ниже'}
</div>
<label className="btn btn-outline-primary mb-0">
Выбрать файл
<input type="file" accept=".txt,.csv,.log" hidden onChange={async (e) => {
const f = e.target.files?.[0];
if (!f) return;
setFileName(f.name);
const t = await f.text();
setText(t);
parseText(t);
}} />
</label>
</div>
</div>
<textarea
ref={textAreaRef}
className="form-control"
rows={8}
value={text}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
/>
<div className="row mt-3 g-3">
<div className="col-md-6">
<div className="card"><div className="card-body">
<div className="text-muted">Готово к импорту</div>
<div className="h2 m-0">{parsed.items.length}</div>
</div></div>
</div>
<div className="col-md-6">
<div className="card"><div className="card-body">
<div className="text-muted">Пропущено (ошибки)</div>
<div className="h2 m-0">{parsed.invalid.length}</div>
</div></div>
</div>
</div>
<div className="form-text mt-2">Проверьте предпросмотр и нажмите «Импортировать».</div>
</div>
<div className="modal-footer">
<button className="btn btn-outline-secondary" onClick={downloadSample}>Шаблон CSV</button>
<button className="btn" onClick={onClose}>Отмена</button>
<button className="btn btn-primary" disabled={parsed.items.length === 0} onClick={handleConfirm}>Импортировать</button>
</div>
</div>
</div>
</div>
);
}
export default ImportModal;
@@ -6,7 +6,8 @@ import {
IconPlayerPlay,
IconBolt,
IconEraser,
IconDots
IconDots,
IconEye
} from '@tabler/icons-react';
import PortalDropdown from './PortalDropdown.jsx';
import { IconClock } from '@tabler/icons-react';
@@ -98,6 +99,11 @@ function PageHeaderActions({
</div>
<span className="vr d-none d-lg-inline" />
<div className="btn-group d-none d-sm-inline-flex">
{onPreview && (
<button className="btn btn-outline-primary" type="button" onClick={onPreview} disabled={disablePreview} title="Предпросмотр изменений">
<IconEye className="me-1" /> Предпросмотр
</button>
)}
{onRefresh && (
<button className="btn btn-outline-secondary" type="button" onClick={onRefresh} disabled={disableRefresh} title="Обновить данные">
<IconRefresh className={loading ? 'spin me-1' : 'me-1'} /> Обновить
+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useRef, useState } from 'react';
import { IconPlus, IconChecks } from '@tabler/icons-react';
function QuickAddBar({
placeholder = 'value community\nvalue community',
parseLine,
validateItem,
onApply,
className = '',
help = 'Вставьте строки: VALUE ПРОБЕЛ COMMUNITY',
}) {
const [value, setValue] = useState('');
const [readyCount, setReadyCount] = useState(0);
const [invalidCount, setInvalidCount] = useState(0);
const textareaRef = useRef(null);
useEffect(() => {
const lines = String(value || '')
.split(/\r?\n/)
.map(l => l.trim())
.filter(Boolean);
let ok = 0, bad = 0;
for (const line of lines) {
const obj = parseLine(line);
if (obj && validateItem(obj)) ok++; else bad++;
}
setReadyCount(ok);
setInvalidCount(bad);
}, [value, parseLine, validateItem]);
const handleApply = () => {
const lines = String(value || '')
.split(/\r?\n/)
.map(l => l.trim())
.filter(Boolean);
const items = [];
for (const line of lines) {
const obj = parseLine(line);
if (obj && validateItem(obj)) items.push(obj);
}
if (items.length > 0) {
onApply?.(items);
setValue('');
}
};
return (
<div className={`card card-md ${className}`}>
<div className="card-header">
<h3 className="card-title"><IconPlus className="icon me-2" />Быстрое добавление</h3>
</div>
<div className="card-body">
<div className="mb-2 text-muted">{help}</div>
<textarea
ref={textareaRef}
className="form-control"
rows={4}
placeholder={placeholder}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<div className="row g-3 mt-2">
<div className="col">
<div className="card"><div className="card-body p-2">
<div className="text-muted">Готово</div>
<div className="h3 m-0">{readyCount}</div>
</div></div>
</div>
<div className="col">
<div className="card"><div className="card-body p-2">
<div className="text-muted">Ошибки</div>
<div className="h3 m-0">{invalidCount}</div>
</div></div>
</div>
</div>
</div>
<div className="card-footer d-flex justify-content-end">
<button className="btn btn-primary" disabled={readyCount === 0} onClick={handleApply}>
<IconChecks className="icon me-2" />Добавить к списку
</button>
</div>
</div>
);
}
export default QuickAddBar;