modified: frontend/src/App.tsx deleted: frontend/src/components/EndpointModal.tsx new file: frontend/src/pages/EndpointEditor.tsx modified: frontend/src/pages/Endpoints.tsx modified: frontend/src/pages/Folders.tsx modified: frontend/src/types/index.ts
239 lines
9.1 KiB
TypeScript
239 lines
9.1 KiB
TypeScript
import { useState, useRef } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import { endpointsApi } from '@/services/api';
|
||
import { ImportPreviewResponse } from '@/types';
|
||
import { Plus, Search, Edit2, Trash2, Download, Upload } from 'lucide-react';
|
||
import toast from 'react-hot-toast';
|
||
import ImportEndpointModal from '@/components/ImportEndpointModal';
|
||
import Dialog from '@/components/Dialog';
|
||
|
||
export default function Endpoints() {
|
||
const queryClient = useQueryClient();
|
||
const navigate = useNavigate();
|
||
const [search, setSearch] = useState('');
|
||
const [showImportModal, setShowImportModal] = useState(false);
|
||
const [importFile, setImportFile] = useState<File | null>(null);
|
||
const [importPreview, setImportPreview] = useState<ImportPreviewResponse | null>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const [dialog, setDialog] = useState<{
|
||
isOpen: boolean;
|
||
title: string;
|
||
message: string;
|
||
type: 'alert' | 'confirm';
|
||
onConfirm?: () => void;
|
||
}>({
|
||
isOpen: false,
|
||
title: '',
|
||
message: '',
|
||
type: 'alert',
|
||
});
|
||
|
||
const { data: endpoints, isLoading } = useQuery({
|
||
queryKey: ['endpoints', search],
|
||
queryFn: () => endpointsApi.getAll(search).then(res => res.data),
|
||
});
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: (id: string) => endpointsApi.delete(id),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['endpoints'] });
|
||
toast.success('Эндпоинт успешно удален');
|
||
},
|
||
onError: () => toast.error('Не удалось удалить эндпоинт'),
|
||
});
|
||
|
||
const handleDelete = (id: string) => {
|
||
setDialog({
|
||
isOpen: true,
|
||
title: 'Подтверждение',
|
||
message: 'Вы уверены, что хотите удалить этот эндпоинт?',
|
||
type: 'confirm',
|
||
onConfirm: () => {
|
||
deleteMutation.mutate(id);
|
||
},
|
||
});
|
||
};
|
||
|
||
const handleExport = async (endpointId: string, endpointName: string) => {
|
||
try {
|
||
const response = await endpointsApi.exportEndpoint(endpointId);
|
||
const blob = new Blob([response.data]);
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `${endpointName.replace(/[^a-zA-Z0-9_\-а-яА-ЯёЁ]/g, '_')}.kabe`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
toast.success('Эндпоинт экспортирован');
|
||
} catch {
|
||
toast.error('Ошибка экспорта эндпоинта');
|
||
}
|
||
};
|
||
|
||
const handleImportFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
if (!file.name.endsWith('.kabe')) {
|
||
toast.error('Выберите файл с расширением .kabe');
|
||
return;
|
||
}
|
||
try {
|
||
const response = await endpointsApi.importPreview(file);
|
||
setImportFile(file);
|
||
setImportPreview(response.data);
|
||
setShowImportModal(true);
|
||
} catch (error: any) {
|
||
toast.error(error.response?.data?.error || 'Ошибка чтения файла');
|
||
}
|
||
e.target.value = '';
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<div className="flex items-center justify-between mb-6">
|
||
<div>
|
||
<h1 className="text-3xl font-bold text-gray-900 mb-2">API Эндпоинты</h1>
|
||
<p className="text-gray-600">Управление динамическими API эндпоинтами</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept=".kabe"
|
||
className="hidden"
|
||
onChange={handleImportFileSelect}
|
||
/>
|
||
<button
|
||
onClick={() => fileInputRef.current?.click()}
|
||
className="btn btn-secondary flex items-center gap-2"
|
||
>
|
||
<Upload size={20} />
|
||
Импорт
|
||
</button>
|
||
<button onClick={() => navigate('/endpoints/new')} className="btn btn-primary flex items-center gap-2">
|
||
<Plus size={20} />
|
||
Новый эндпоинт
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card p-4 mb-6">
|
||
<div className="flex items-center gap-3">
|
||
<Search size={20} className="text-gray-400" />
|
||
<input
|
||
type="text"
|
||
placeholder="Поиск эндпоинтов по имени, пути или SQL запросу..."
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
className="flex-1 outline-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="text-center py-12">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||
<p className="mt-4 text-gray-600">Загрузка эндпоинтов...</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{endpoints?.map((endpoint) => (
|
||
<div key={endpoint.id} className="card p-6 hover:shadow-md transition-shadow">
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-3 mb-2">
|
||
<h3 className="text-xl font-semibold text-gray-900">{endpoint.name}</h3>
|
||
<span className={`px-3 py-1 text-xs font-semibold rounded ${
|
||
endpoint.method === 'GET' ? 'bg-green-100 text-green-700' :
|
||
endpoint.method === 'POST' ? 'bg-blue-100 text-blue-700' :
|
||
endpoint.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
|
||
'bg-red-100 text-red-700'
|
||
}`}>
|
||
{endpoint.method}
|
||
</span>
|
||
{endpoint.is_public && (
|
||
<span className="px-3 py-1 text-xs font-semibold rounded bg-purple-100 text-purple-700">
|
||
Публичный
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="text-gray-600 mb-2">{endpoint.description}</p>
|
||
<code className="text-sm bg-gray-100 px-3 py-1 rounded text-gray-800">
|
||
{endpoint.path}
|
||
</code>
|
||
{endpoint.folder_name && (
|
||
<span className="ml-2 text-sm text-gray-500">📁 {endpoint.folder_name}</span>
|
||
)}
|
||
{endpoint.parameters && endpoint.parameters.length > 0 && (
|
||
<div className="mt-2">
|
||
<span className="text-xs text-gray-500">Параметры: </span>
|
||
{endpoint.parameters.map((param: any, idx: number) => (
|
||
<span key={idx} className="inline-block text-xs bg-gray-200 text-gray-700 px-2 py-1 rounded mr-1 mb-1">
|
||
{param.name} ({param.type}){param.required && '*'}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => handleExport(endpoint.id, endpoint.name)}
|
||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||
title="Экспорт"
|
||
>
|
||
<Download size={18} className="text-gray-600" />
|
||
</button>
|
||
<button
|
||
onClick={() => navigate(`/endpoints/${endpoint.id}`)}
|
||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||
title="Редактировать"
|
||
>
|
||
<Edit2 size={18} className="text-gray-600" />
|
||
</button>
|
||
<button
|
||
onClick={() => handleDelete(endpoint.id)}
|
||
className="p-2 hover:bg-red-50 rounded-lg transition-colors"
|
||
title="Удалить"
|
||
>
|
||
<Trash2 size={18} className="text-red-600" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{endpoints?.length === 0 && (
|
||
<div className="text-center py-12">
|
||
<p className="text-gray-500">Эндпоинты не найдены. Создайте первый эндпоинт!</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{showImportModal && importPreview && importFile && (
|
||
<ImportEndpointModal
|
||
preview={importPreview}
|
||
file={importFile}
|
||
onClose={() => {
|
||
setShowImportModal(false);
|
||
setImportFile(null);
|
||
setImportPreview(null);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
<Dialog
|
||
isOpen={dialog.isOpen}
|
||
onClose={() => setDialog({ ...dialog, isOpen: false })}
|
||
title={dialog.title}
|
||
message={dialog.message}
|
||
type={dialog.type}
|
||
onConfirm={dialog.onConfirm}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|