new file: .claude/settings.local.json
new file: .gitignore new file: backend/.env.example new file: backend/.gitignore new file: backend/ecosystem.config.js new file: backend/nodemon.json new file: backend/package-lock.json new file: backend/package.json new file: backend/src/config/database.ts new file: backend/src/config/dynamicSwagger.ts new file: backend/src/config/environment.ts new file: backend/src/config/swagger.ts new file: backend/src/controllers/apiKeyController.ts new file: backend/src/controllers/authController.ts new file: backend/src/controllers/databaseController.ts new file: backend/src/controllers/databaseManagementController.ts new file: backend/src/controllers/dynamicApiController.ts new file: backend/src/controllers/endpointController.ts new file: backend/src/controllers/folderController.ts new file: backend/src/controllers/logsController.ts new file: backend/src/controllers/userController.ts new file: backend/src/middleware/apiKey.ts new file: backend/src/middleware/auth.ts new file: backend/src/middleware/logging.ts new file: backend/src/migrations/001_initial_schema.sql new file: backend/src/migrations/002_add_logging.sql new file: backend/src/migrations/003_add_scripting.sql new file: backend/src/migrations/004_add_superadmin.sql new file: backend/src/migrations/run.ts new file: backend/src/migrations/seed.ts new file: backend/src/routes/apiKeys.ts new file: backend/src/routes/auth.ts new file: backend/src/routes/databaseManagement.ts new file: backend/src/routes/databases.ts new file: backend/src/routes/dynamic.ts new file: backend/src/routes/endpoints.ts new file: backend/src/routes/folders.ts new file: backend/src/routes/logs.ts new file: backend/src/routes/users.ts new file: backend/src/server.ts new file: backend/src/services/DatabasePoolManager.ts new file: backend/src/services/ScriptExecutor.ts new file: backend/src/services/SqlExecutor.ts new file: backend/src/types/index.ts new file: backend/tsconfig.json new file: frontend/.gitignore new file: frontend/index.html new file: frontend/nginx.conf new file: frontend/package-lock.json new file: frontend/package.json new file: frontend/postcss.config.js new file: frontend/src/App.tsx new file: frontend/src/components/CodeEditor.tsx
This commit is contained in:
36
frontend/src/components/CodeEditor.tsx
Normal file
36
frontend/src/components/CodeEditor.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import Editor from '@monaco-editor/react';
|
||||
|
||||
interface CodeEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
language: 'javascript' | 'python';
|
||||
height?: string;
|
||||
}
|
||||
|
||||
export default function CodeEditor({ value, onChange, language, height = '400px' }: CodeEditorProps) {
|
||||
return (
|
||||
<div className="border border-gray-300 rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height={height}
|
||||
defaultLanguage={language}
|
||||
value={value}
|
||||
onChange={(value) => onChange(value || '')}
|
||||
theme="vs-light"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
wordWrap: 'on',
|
||||
formatOnPaste: true,
|
||||
formatOnType: true,
|
||||
suggestOnTriggerCharacters: true,
|
||||
quickSuggestions: true,
|
||||
acceptSuggestionOnEnter: 'on',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
frontend/src/components/Dialog.tsx
Normal file
63
frontend/src/components/Dialog.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface DialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
type?: 'alert' | 'confirm';
|
||||
onConfirm?: () => void;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
}
|
||||
|
||||
export default function Dialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
message,
|
||||
type = 'alert',
|
||||
onConfirm,
|
||||
confirmText = 'OK',
|
||||
cancelText = 'Отмена',
|
||||
}: DialogProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm();
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[100] p-4">
|
||||
<div className="bg-white rounded-lg max-w-md w-full shadow-xl">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<p className="text-gray-700 whitespace-pre-wrap">{message}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 p-4 border-t border-gray-200 justify-end">
|
||||
{type === 'confirm' && (
|
||||
<button onClick={onClose} className="btn btn-secondary">
|
||||
{cancelText}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleConfirm} className="btn btn-primary">
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
718
frontend/src/components/EndpointModal.tsx
Normal file
718
frontend/src/components/EndpointModal.tsx
Normal file
@@ -0,0 +1,718 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { endpointsApi, foldersApi } from '@/services/api';
|
||||
import { Endpoint, EndpointParameter } from '@/types';
|
||||
import { Plus, Trash2, Play, Edit2 } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import SqlEditor from '@/components/SqlEditor';
|
||||
import CodeEditor from '@/components/CodeEditor';
|
||||
|
||||
interface EndpointModalProps {
|
||||
endpoint: Endpoint | null;
|
||||
databases: any[];
|
||||
folderId?: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function EndpointModal({
|
||||
endpoint,
|
||||
databases,
|
||||
folderId,
|
||||
onClose,
|
||||
}: EndpointModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [formData, setFormData] = useState({
|
||||
name: endpoint?.name || '',
|
||||
description: endpoint?.description || '',
|
||||
method: endpoint?.method || 'GET',
|
||||
path: endpoint?.path || '',
|
||||
database_id: endpoint?.database_id || '',
|
||||
sql_query: endpoint?.sql_query || '',
|
||||
parameters: endpoint?.parameters || [],
|
||||
folder_id: endpoint?.folder_id || folderId || '',
|
||||
is_public: endpoint?.is_public || false,
|
||||
enable_logging: endpoint?.enable_logging || false,
|
||||
execution_type: endpoint?.execution_type || 'sql',
|
||||
script_language: endpoint?.script_language || 'javascript',
|
||||
script_code: endpoint?.script_code || '',
|
||||
script_queries: endpoint?.script_queries || [],
|
||||
});
|
||||
|
||||
const [editingQueryIndex, setEditingQueryIndex] = useState<number | null>(null);
|
||||
const [showScriptCodeEditor, setShowScriptCodeEditor] = useState(false);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: any) =>
|
||||
endpoint ? endpointsApi.update(endpoint.id, data) : endpointsApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['endpoints'] });
|
||||
toast.success(endpoint ? 'Эндпоинт обновлен' : 'Эндпоинт создан');
|
||||
onClose();
|
||||
},
|
||||
onError: () => toast.error('Не удалось сохранить эндпоинт'),
|
||||
});
|
||||
|
||||
const [testParams, setTestParams] = useState<any>({});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
// Собираем тестовые значения параметров в массив в правильном порядке
|
||||
const paramValues = formData.parameters.map((param: any) => {
|
||||
const value = testParams[param.name];
|
||||
if (value === undefined || value === '') return null;
|
||||
|
||||
// Преобразуем тип если нужно
|
||||
switch (param.type) {
|
||||
case 'number':
|
||||
return Number(value);
|
||||
case 'boolean':
|
||||
return value === 'true' || value === true;
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
});
|
||||
|
||||
if (formData.execution_type === 'script') {
|
||||
// Для скриптов используем database_id из первого запроса или пустую строку
|
||||
const firstDbId = formData.script_queries.length > 0 ? formData.script_queries[0].database_id : '';
|
||||
return endpointsApi.test({
|
||||
database_id: firstDbId || '',
|
||||
execution_type: 'script',
|
||||
script_language: formData.script_language,
|
||||
script_code: formData.script_code,
|
||||
script_queries: formData.script_queries,
|
||||
parameters: paramValues,
|
||||
endpoint_parameters: formData.parameters,
|
||||
});
|
||||
} else {
|
||||
return endpointsApi.test({
|
||||
database_id: formData.database_id,
|
||||
execution_type: 'sql',
|
||||
sql_query: formData.sql_query,
|
||||
parameters: paramValues,
|
||||
endpoint_parameters: formData.parameters,
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
toast.success(`Запрос выполнен за ${response.data.executionTime}мс. Возвращено строк: ${response.data.rowCount}.`);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || 'Ошибка тестирования запроса');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
saveMutation.mutate(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{endpoint ? 'Редактировать эндпоинт' : 'Создать новый эндпоинт'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Название</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="input w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Метод</label>
|
||||
<select
|
||||
value={formData.method}
|
||||
onChange={(e) => setFormData({ ...formData, method: e.target.value as any })}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="DELETE">DELETE</option>
|
||||
<option value="PATCH">PATCH</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Описание</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="input w-full"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Тип выполнения</label>
|
||||
<select
|
||||
value={formData.execution_type}
|
||||
onChange={(e) => setFormData({ ...formData, execution_type: e.target.value as 'sql' | 'script' })}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="sql">SQL Запрос</option>
|
||||
<option value="script">Скрипт (JavaScript/Python)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={`grid ${formData.execution_type === 'sql' ? 'grid-cols-3' : 'grid-cols-2'} gap-4`}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Путь</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.path}
|
||||
onChange={(e) => setFormData({ ...formData, path: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="/api/v1/users"
|
||||
/>
|
||||
</div>
|
||||
{formData.execution_type === 'sql' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">База данных</label>
|
||||
<select
|
||||
required
|
||||
value={formData.database_id}
|
||||
onChange={(e) => setFormData({ ...formData, database_id: e.target.value })}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="">Выберите базу данных</option>
|
||||
{databases.map((db) => (
|
||||
<option key={db.id} value={db.id}>{db.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Папка</label>
|
||||
<FolderSelector
|
||||
value={formData.folder_id}
|
||||
onChange={(value) => setFormData({ ...formData, folder_id: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Параметры запроса
|
||||
<span className="text-xs text-gray-500 ml-2">
|
||||
(используйте $имяПараметра в SQL запросе)
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newParam: EndpointParameter = {
|
||||
name: '',
|
||||
type: 'string' as const,
|
||||
required: false,
|
||||
in: 'query' as const,
|
||||
description: '',
|
||||
};
|
||||
setFormData({ ...formData, parameters: [...formData.parameters, newParam] });
|
||||
}}
|
||||
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Добавить параметр
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formData.parameters.length > 0 ? (
|
||||
<div className="space-y-3 mb-4 border border-gray-200 rounded-lg p-4">
|
||||
{formData.parameters.map((param: any, index: number) => (
|
||||
<div key={index} className="flex gap-2 items-start bg-gray-50 p-3 rounded">
|
||||
<div className="flex-1 grid grid-cols-5 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Имя параметра"
|
||||
value={param.name}
|
||||
onChange={(e) => {
|
||||
const newParams = [...formData.parameters];
|
||||
newParams[index].name = e.target.value;
|
||||
setFormData({ ...formData, parameters: newParams });
|
||||
}}
|
||||
className="input text-sm"
|
||||
/>
|
||||
<select
|
||||
value={param.type}
|
||||
onChange={(e) => {
|
||||
const newParams = [...formData.parameters];
|
||||
newParams[index].type = e.target.value as 'string' | 'number' | 'boolean' | 'date';
|
||||
setFormData({ ...formData, parameters: newParams });
|
||||
}}
|
||||
className="input text-sm"
|
||||
>
|
||||
<option value="string">string</option>
|
||||
<option value="number">number</option>
|
||||
<option value="boolean">boolean</option>
|
||||
<option value="date">date</option>
|
||||
</select>
|
||||
<select
|
||||
value={param.in}
|
||||
onChange={(e) => {
|
||||
const newParams = [...formData.parameters];
|
||||
newParams[index].in = e.target.value as 'query' | 'body' | 'path';
|
||||
setFormData({ ...formData, parameters: newParams });
|
||||
}}
|
||||
className="input text-sm"
|
||||
>
|
||||
<option value="query">Query</option>
|
||||
<option value="body">Body</option>
|
||||
<option value="path">Path</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Описание"
|
||||
value={param.description || ''}
|
||||
onChange={(e) => {
|
||||
const newParams = [...formData.parameters];
|
||||
newParams[index].description = e.target.value;
|
||||
setFormData({ ...formData, parameters: newParams });
|
||||
}}
|
||||
className="input text-sm"
|
||||
/>
|
||||
<label className="flex items-center gap-1 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={param.required}
|
||||
onChange={(e) => {
|
||||
const newParams = [...formData.parameters];
|
||||
newParams[index].required = e.target.checked;
|
||||
setFormData({ ...formData, parameters: newParams });
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-xs">Обязательный</span>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newParams = formData.parameters.filter((_: any, i: number) => i !== index);
|
||||
setFormData({ ...formData, parameters: newParams });
|
||||
}}
|
||||
className="p-1 hover:bg-red-50 rounded text-red-600"
|
||||
title="Удалить параметр"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 mb-4 border border-gray-200 rounded-lg bg-gray-50">
|
||||
<p className="text-sm text-gray-500">Нет параметров. Добавьте параметры для динамического запроса.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formData.execution_type === 'sql' ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">SQL Запрос</label>
|
||||
<div className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700 space-y-1">
|
||||
<div><strong>Совет:</strong> Используйте <code className="bg-blue-100 px-1 rounded">$имяПараметра</code> для подстановки значений параметров в SQL запрос.</div>
|
||||
<div>Пример: <code className="bg-blue-100 px-1 rounded">SELECT * FROM users WHERE id = $userId AND status = $status</code></div>
|
||||
<div className="text-xs text-blue-600 mt-1">
|
||||
💡 <strong>Query</strong> параметры передаются в URL, <strong>Body</strong> - в теле запроса (для POST/PUT/DELETE можно использовать оба типа)
|
||||
</div>
|
||||
</div>
|
||||
<SqlEditor
|
||||
value={formData.sql_query}
|
||||
onChange={(value) => setFormData({ ...formData, sql_query: value })}
|
||||
databaseId={formData.database_id}
|
||||
height="300px"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Язык скрипта</label>
|
||||
<select
|
||||
value={formData.script_language}
|
||||
onChange={(e) => setFormData({ ...formData, script_language: e.target.value as 'javascript' | 'python' })}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="javascript">JavaScript</option>
|
||||
<option value="python">Python</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
SQL Запросы для скрипта
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newQueries = [...formData.script_queries, { name: '', sql: '', database_id: '' }];
|
||||
setFormData({ ...formData, script_queries: newQueries });
|
||||
setEditingQueryIndex(newQueries.length - 1);
|
||||
}}
|
||||
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Добавить запрос
|
||||
</button>
|
||||
</div>
|
||||
{formData.script_queries.length > 0 ? (
|
||||
<div className="space-y-2 mb-4">
|
||||
{formData.script_queries.map((query: any, idx: number) => (
|
||||
<div key={idx} className="border border-gray-200 rounded-lg p-4 bg-white hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<code className="text-sm font-semibold text-gray-900">{query.name || 'Безымянный запрос'}</code>
|
||||
{query.database_id && (
|
||||
<span className="text-xs text-gray-500">
|
||||
→ {databases.find(db => db.id === query.database_id)?.name || 'БД не выбрана'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{query.sql && (
|
||||
<div className="text-xs text-gray-600 font-mono bg-gray-50 p-2 rounded mt-1 truncate">
|
||||
{query.sql.substring(0, 100)}{query.sql.length > 100 ? '...' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingQueryIndex(idx)}
|
||||
className="p-2 hover:bg-blue-50 rounded text-blue-600"
|
||||
title="Редактировать запрос"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newQueries = formData.script_queries.filter((_: any, i: number) => i !== idx);
|
||||
setFormData({ ...formData, script_queries: newQueries });
|
||||
}}
|
||||
className="p-2 hover:bg-red-50 rounded text-red-600"
|
||||
title="Удалить запрос"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6 mb-4 border border-gray-200 rounded-lg bg-gray-50">
|
||||
<p className="text-sm text-gray-500">Нет SQL запросов. Добавьте запросы для использования в скрипте.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700">Код скрипта</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowScriptCodeEditor(true)}
|
||||
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
Редактировать код
|
||||
</button>
|
||||
</div>
|
||||
<div className="border border-gray-200 rounded-lg p-4 bg-white">
|
||||
{formData.script_code ? (
|
||||
<pre className="text-xs font-mono text-gray-800 whitespace-pre-wrap max-h-32 overflow-y-auto">
|
||||
{formData.script_code}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 italic">Код скрипта не задан. Нажмите "Редактировать код"</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_public}
|
||||
onChange={(e) => setFormData({ ...formData, is_public: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Публичный эндпоинт (не требует API ключ)</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.enable_logging}
|
||||
onChange={(e) => setFormData({ ...formData, enable_logging: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Логгировать запросы</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{formData.parameters.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Тестовые значения параметров
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3 border border-gray-200 rounded-lg p-4 bg-gray-50">
|
||||
{formData.parameters.map((param: any, index: number) => (
|
||||
<div key={index}>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
${param.name} ({param.type})
|
||||
{param.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
{param.type === 'boolean' ? (
|
||||
<select
|
||||
value={testParams[param.name] || ''}
|
||||
onChange={(e) => setTestParams({ ...testParams, [param.name]: e.target.value })}
|
||||
className="input w-full text-sm"
|
||||
>
|
||||
<option value="">Не задано</option>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={param.type === 'number' ? 'number' : param.type === 'date' ? 'datetime-local' : 'text'}
|
||||
placeholder={param.description || `Введите ${param.name}`}
|
||||
value={testParams[param.name] || ''}
|
||||
onChange={(e) => setTestParams({ ...testParams, [param.name]: e.target.value })}
|
||||
className="input w-full text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => testMutation.mutate()}
|
||||
disabled={
|
||||
!formData.database_id ||
|
||||
(formData.execution_type === 'sql' ? !formData.sql_query : !formData.script_code) ||
|
||||
testMutation.isPending
|
||||
}
|
||||
className="btn btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<Play size={18} />
|
||||
{testMutation.isPending ? 'Тестирование...' : 'Тест запроса'}
|
||||
</button>
|
||||
<div className="flex-1"></div>
|
||||
<button type="button" onClick={onClose} className="btn btn-secondary">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" disabled={saveMutation.isPending} className="btn btn-primary">
|
||||
{saveMutation.isPending ? 'Сохранение...' : 'Сохранить эндпоинт'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Query Editor Modal */}
|
||||
{editingQueryIndex !== null && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[60] p-4">
|
||||
<div className="bg-white rounded-lg max-w-5xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h3 className="text-xl font-bold text-gray-900">Редактировать SQL запрос</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Имя запроса</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="mainQuery"
|
||||
value={formData.script_queries[editingQueryIndex]?.name || ''}
|
||||
onChange={(e) => {
|
||||
const newQueries = [...formData.script_queries];
|
||||
newQueries[editingQueryIndex].name = e.target.value;
|
||||
setFormData({ ...formData, script_queries: newQueries });
|
||||
}}
|
||||
className="input w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">База данных</label>
|
||||
<select
|
||||
required
|
||||
value={formData.script_queries[editingQueryIndex]?.database_id || ''}
|
||||
onChange={(e) => {
|
||||
const newQueries = [...formData.script_queries];
|
||||
newQueries[editingQueryIndex].database_id = e.target.value;
|
||||
setFormData({ ...formData, script_queries: newQueries });
|
||||
}}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="">Выберите базу данных</option>
|
||||
{databases.map((db) => (
|
||||
<option key={db.id} value={db.id}>{db.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">SQL Запрос</label>
|
||||
<div className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
|
||||
<div><strong>Совет:</strong> Используйте <code className="bg-blue-100 px-1 rounded">$имяПараметра</code> для параметров из запроса или дополнительных параметров из скрипта.</div>
|
||||
</div>
|
||||
<SqlEditor
|
||||
value={formData.script_queries[editingQueryIndex]?.sql || ''}
|
||||
onChange={(value) => {
|
||||
const newQueries = [...formData.script_queries];
|
||||
newQueries[editingQueryIndex].sql = value;
|
||||
setFormData({ ...formData, script_queries: newQueries });
|
||||
}}
|
||||
databaseId={formData.script_queries[editingQueryIndex]?.database_id || ''}
|
||||
height="400px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 border-t border-gray-200 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingQueryIndex(null)}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingQueryIndex(null)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Script Code Editor Modal */}
|
||||
{showScriptCodeEditor && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[60] p-4">
|
||||
<div className="bg-white rounded-lg max-w-6xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h3 className="text-xl font-bold text-gray-900">Редактировать код скрипта</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="mb-3 p-3 bg-blue-50 border border-blue-200 rounded text-sm text-blue-700 space-y-2">
|
||||
<div><strong>Доступные функции:</strong></div>
|
||||
{formData.script_language === 'javascript' ? (
|
||||
<>
|
||||
<div>• <code className="bg-blue-100 px-1 rounded">params</code> - объект с параметрами из запроса (query/body)</div>
|
||||
<div>• <code className="bg-blue-100 px-1 rounded">await execQuery('queryName', {'{'}additional: 'params'{'}'})</code> - выполнить SQL запрос</div>
|
||||
<div className="text-xs mt-2">Пример: <code className="bg-blue-100 px-1 rounded">const result = await execQuery('mainQuery', {'{'}diff: 123{'}'}); return result.data;</code></div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>• <code className="bg-blue-100 px-1 rounded">params</code> - словарь с параметрами из запроса (query/body)</div>
|
||||
<div>• <code className="bg-blue-100 px-1 rounded">exec_query('queryName', {'{'}' additional': 'params'{'}'})</code> - выполнить SQL запрос</div>
|
||||
<div className="text-xs mt-2">Пример: <code className="bg-blue-100 px-1 rounded">result = exec_query('mainQuery', {'{'}'diff': 123{'}'}); return result</code></div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<CodeEditor
|
||||
value={formData.script_code}
|
||||
onChange={(value) => setFormData({ ...formData, script_code: value })}
|
||||
language={formData.script_language}
|
||||
height="500px"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-6 border-t border-gray-200 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowScriptCodeEditor(false)}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowScriptCodeEditor(false)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Компонент для выбора папки с иерархией
|
||||
function FolderSelector({ value, onChange }: { value: string; onChange: (value: string) => void }) {
|
||||
const { data: folders } = useQuery({
|
||||
queryKey: ['folders'],
|
||||
queryFn: () => foldersApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
// Построение списка с иерархией для отображения
|
||||
const buildFolderList = (): Array<{ id: string; name: string; level: number }> => {
|
||||
if (!folders) return [];
|
||||
|
||||
const folderMap = new Map(folders.map(f => [f.id, { ...f, children: [] }]));
|
||||
const result: Array<{ id: string; name: string; level: number }> = [];
|
||||
|
||||
// Группируем папки по parent_id
|
||||
folders.forEach(folder => {
|
||||
if (folder.parent_id && folderMap.has(folder.parent_id)) {
|
||||
const parent: any = folderMap.get(folder.parent_id)!;
|
||||
if (!parent.children) parent.children = [];
|
||||
parent.children.push(folderMap.get(folder.id)!);
|
||||
}
|
||||
});
|
||||
|
||||
// Рекурсивно добавляем папки в список с учетом уровня вложенности
|
||||
const addFolderRecursive = (folder: any, level: number) => {
|
||||
result.push({ id: folder.id, name: folder.name, level });
|
||||
if (folder.children && folder.children.length > 0) {
|
||||
folder.children.forEach((child: any) => addFolderRecursive(child, level + 1));
|
||||
}
|
||||
};
|
||||
|
||||
// Добавляем корневые папки и их детей
|
||||
folders.forEach(folder => {
|
||||
if (!folder.parent_id) {
|
||||
addFolderRecursive(folderMap.get(folder.id)!, 0);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const folderList = buildFolderList();
|
||||
|
||||
return (
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)} className="input w-full">
|
||||
<option value="">Без папки</option>
|
||||
{folderList.map((folder) => (
|
||||
<option key={folder.id} value={folder.id}>
|
||||
{'\u00A0'.repeat(folder.level * 4)}{folder.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
56
frontend/src/components/Navbar.tsx
Normal file
56
frontend/src/components/Navbar.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { authApi } from '@/services/api';
|
||||
import { LogOut, User } from 'lucide-react';
|
||||
|
||||
export default function Navbar() {
|
||||
const { user, logout, setAuth } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
// Load user data on mount if we have a token
|
||||
const loadUser = async () => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token && !user) {
|
||||
try {
|
||||
const { data } = await authApi.getMe();
|
||||
setAuth(data, token);
|
||||
} catch (error) {
|
||||
console.error('Failed to load user:', error);
|
||||
logout();
|
||||
}
|
||||
}
|
||||
};
|
||||
loadUser();
|
||||
}, [user, setAuth, logout]);
|
||||
|
||||
return (
|
||||
<nav className="bg-white border-b border-gray-200 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold text-primary-600">KIS API Builder</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{user && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<User size={20} />
|
||||
<span className="font-medium">{user.username}</span>
|
||||
<span className="text-xs bg-primary-100 text-primary-700 px-2 py-1 rounded">
|
||||
{user.role === 'admin' ? 'Администратор' : 'Пользователь'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="btn btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
Выход
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/Sidebar.tsx
Normal file
46
frontend/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { Home, Key, Folder, Settings, FileCode, FileText } from 'lucide-react';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: Home, label: 'Главная' },
|
||||
{ to: '/endpoints', icon: FileCode, label: 'Эндпоинты' },
|
||||
{ to: '/folders', icon: Folder, label: 'Папки' },
|
||||
{ to: '/api-keys', icon: Key, label: 'API Ключи' },
|
||||
{ to: '/logs', icon: FileText, label: 'Логи' },
|
||||
{ to: '/settings', icon: Settings, label: 'Настройки' },
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
return (
|
||||
<aside className="w-64 bg-white border-r border-gray-200 flex flex-col">
|
||||
<div className="flex-1 py-6">
|
||||
<nav className="space-y-1 px-3">
|
||||
{navItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700 font-medium'
|
||||
: 'text-gray-700 hover:bg-gray-50'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<span>{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-gray-200">
|
||||
<div className="text-xs text-gray-500">
|
||||
<p className="font-semibold">KIS API Builder v1.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
108
frontend/src/components/SqlEditor.tsx
Normal file
108
frontend/src/components/SqlEditor.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useRef } from 'react';
|
||||
import Editor, { Monaco, loader } from '@monaco-editor/react';
|
||||
import { databasesApi } from '@/services/api';
|
||||
import * as monacoEditor from 'monaco-editor';
|
||||
|
||||
// Configure loader to use local Monaco
|
||||
loader.config({ monaco: monacoEditor });
|
||||
|
||||
interface SqlEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
databaseId?: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
export default function SqlEditor({ value, onChange, databaseId, height = '400px' }: SqlEditorProps) {
|
||||
const editorRef = useRef<any>(null);
|
||||
const monacoRef = useRef<Monaco | null>(null);
|
||||
|
||||
const handleEditorDidMount = (editor: any, monaco: Monaco) => {
|
||||
editorRef.current = editor;
|
||||
monacoRef.current = monaco;
|
||||
|
||||
// Configure SQL language features
|
||||
monaco.languages.registerCompletionItemProvider('sql', {
|
||||
provideCompletionItems: async (model, position) => {
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn,
|
||||
};
|
||||
|
||||
let suggestions: any[] = [
|
||||
// SQL Keywords
|
||||
{ label: 'SELECT', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'SELECT ', range },
|
||||
{ label: 'FROM', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'FROM ', range },
|
||||
{ label: 'WHERE', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'WHERE ', range },
|
||||
{ label: 'JOIN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'JOIN ', range },
|
||||
{ label: 'LEFT JOIN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'LEFT JOIN ', range },
|
||||
{ label: 'INNER JOIN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'INNER JOIN ', range },
|
||||
{ label: 'GROUP BY', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'GROUP BY ', range },
|
||||
{ label: 'ORDER BY', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'ORDER BY ', range },
|
||||
{ label: 'HAVING', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'HAVING ', range },
|
||||
{ label: 'LIMIT', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'LIMIT ', range },
|
||||
{ label: 'OFFSET', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'OFFSET ', range },
|
||||
{ label: 'AND', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'AND ', range },
|
||||
{ label: 'OR', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'OR ', range },
|
||||
{ label: 'NOT', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'NOT ', range },
|
||||
{ label: 'IN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'IN ', range },
|
||||
{ label: 'LIKE', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'LIKE ', range },
|
||||
{ label: 'COUNT', kind: monaco.languages.CompletionItemKind.Function, insertText: 'COUNT()', range },
|
||||
{ label: 'SUM', kind: monaco.languages.CompletionItemKind.Function, insertText: 'SUM()', range },
|
||||
{ label: 'AVG', kind: monaco.languages.CompletionItemKind.Function, insertText: 'AVG()', range },
|
||||
{ label: 'MAX', kind: monaco.languages.CompletionItemKind.Function, insertText: 'MAX()', range },
|
||||
{ label: 'MIN', kind: monaco.languages.CompletionItemKind.Function, insertText: 'MIN()', range },
|
||||
];
|
||||
|
||||
// Fetch table names from database if databaseId is provided
|
||||
if (databaseId) {
|
||||
try {
|
||||
const { data } = await databasesApi.getTables(databaseId);
|
||||
const tableSuggestions = data.tables.map(table => ({
|
||||
label: table,
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText: table,
|
||||
detail: 'Table',
|
||||
range,
|
||||
}));
|
||||
suggestions = [...suggestions, ...tableSuggestions];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch table names:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return { suggestions };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-gray-300 rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height={height}
|
||||
defaultLanguage="sql"
|
||||
value={value}
|
||||
onChange={(value) => onChange(value || '')}
|
||||
onMount={handleEditorDidMount}
|
||||
theme="vs-light"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
wordWrap: 'on',
|
||||
formatOnPaste: true,
|
||||
formatOnType: true,
|
||||
suggestOnTriggerCharacters: true,
|
||||
quickSuggestions: true,
|
||||
acceptSuggestionOnEnter: 'on',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user