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:
GEgorov
2025-10-07 00:04:04 +03:00
commit 8943f5a070
79 changed files with 17032 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}