This commit is contained in:
2026-05-04 13:22:25 +03:00
parent f39d9ff11e
commit a61c527ef9
36 changed files with 794 additions and 29 deletions

View File

@@ -0,0 +1,384 @@
<template>
<AppLayout>
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-900">{{ t('dbConnections.pageName') }}</h1>
<button @click="openModal('create')" class="btn-primary flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{{ t('dbConnections.add') }}
</button>
</div>
<div class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.id') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.name') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.type') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.host') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.port') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.database') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('dbConnections.user') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.created') }}</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<tr v-for="conn in connections" :key="conn.id" class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ conn.id }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ conn.name }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span :class="getTypeBadgeClass(conn.type)" class="px-2 py-1 rounded-full text-xs font-medium">
{{ getTypeLabel(conn.type) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.host }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.port }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.database }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ conn.user }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(conn.created) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end space-x-3">
<button @click="testConnection(conn)" :disabled="conn.testing" class="text-green-600 hover:text-green-800 transition-colors disabled:opacity-50" :title="t('dbConnections.test')">
<svg v-if="!conn.testing" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-else class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</button>
<button @click="openModal('edit', conn)" class="text-blue-600 hover:text-blue-800 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button @click="confirmDelete(conn.id)" class="text-red-600 hover:text-red-800 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<span v-if="conn.testResult" class="text-xs px-2 py-1 rounded-full bg-green-100 text-green-700 ml-1 whitespace-nowrap">
{{ conn.testResult }}
</span>
</div>
</td>
</tr>
<tr v-if="connections.length === 0">
<td colspan="9" class="px-6 py-12 text-center text-gray-500">{{ t('dbConnections.noConnections') }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Модальное окно создания/редактирования -->
<Transition name="fade">
<div v-if="modalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeModal">
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<div class="flex items-center justify-center min-h-screen p-4">
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full transform transition-all">
<div class="flex justify-between items-center p-6 border-b">
<h2 class="text-xl font-bold text-gray-900">{{ modalTitle }}</h2>
<button @click="closeModal" class="text-gray-400 hover:text-gray-600 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form @submit.prevent="submitConnection" class="p-6 space-y-5">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.name') }} *</label>
<input v-model="form.name" type="text" required class="input-field" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.type') }} *</label>
<select v-model="form.type" required class="input-field">
<option value="mysql">MySQL</option>
<option value="postgres">PostgreSQL</option>
<option value="clickhouse">ClickHouse</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.host') }} *</label>
<input v-model="form.host" type="text" required class="input-field" placeholder="localhost or IP" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.port') }} *</label>
<input v-model="form.port" type="number" required class="input-field" placeholder="3306, 5432, 8123..." />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.database') }} *</label>
<input v-model="form.database" type="text" required class="input-field" placeholder="database name" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('dbConnections.user') }} *</label>
<input v-model="form.user" type="text" required class="input-field" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.password') }}</label>
<input
v-model="form.password"
:required="modalMode === 'create'"
type="password"
class="input-field"
autocomplete="new-password"
/>
<p v-if="modalMode === 'edit'" class="text-xs text-gray-500 mt-1">{{ t('common.leavePasswordBlank') }}</p>
</div>
<div class="flex justify-end space-x-3 pt-2">
<button type="button" @click="closeModal" class="btn-secondary">{{ t('app.cancel') }}</button>
<button type="submit" class="btn-primary">{{ t('app.save') }}</button>
</div>
</form>
</div>
</div>
</div>
</Transition>
<!-- Модальное окно подтверждения удаления -->
<Transition name="fade">
<div v-if="deleteConfirm.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="deleteConfirm.show = false">
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<div class="flex items-center justify-center min-h-screen p-4">
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full">
<div class="p-6 text-center">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ t('dbConnections.delete') }}</h3>
<p class="text-sm text-gray-500 mb-6">{{ t('dbConnections.deleteConfirmation') }}</p>
<div class="flex justify-center space-x-3">
<button @click="deleteConfirm.show = false" class="btn-secondary">{{ t('app.cancel') }}</button>
<button @click="deleteConnection(deleteConfirm.id)" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">{{ t('app.delete') }}</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import AppLayout from '@/components/Layout/AppLayout.vue';
import { useI18n } from 'vue-i18n';
import { useNotification } from '@/composables/useNotification';
const { t } = useI18n();
const { showNotification } = useNotification();
type Connection = {
id: number;
name: string;
type: 'mysql' | 'postgres' | 'clickhouse';
host: string;
port: number;
database: string;
user: string;
created: string;
testing?: boolean;
testResult?: string | null;
};
const connections = ref<Connection[]>([]);
const modalOpen = ref(false);
const modalMode = ref<'create' | 'edit'>('create');
const form = ref({
id: null as number | null,
name: '',
type: 'mysql' as 'mysql' | 'postgres' | 'clickhouse',
host: '',
port: 3306,
database: '',
user: '',
password: ''
});
const modalTitle = ref('');
const deleteConfirm = ref({ show: false, id: null as number | null });
// Загрузка списка подключений
async function loadConnections() {
try {
const res = await fetch('/api/admin/database-connections');
if (!res.ok) throw new Error();
const data = await res.json();
connections.value = data.map((c: any) => ({
...c,
testing: false,
testResult: null
}));
} catch (e) {
showNotification('dbConnections.loadError', 'error');
}
}
function formatDate(dateStr: string) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString();
}
// Тестирование соединения
async function testConnection(conn: Connection) {
conn.testing = true;
conn.testResult = null;
try {
const response = await fetch(`/api/admin/database-connections/${conn.id}/test`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (data.success) {
conn.testResult = `${data.latency_ms} ms`;
showNotification('dbConnections.testSuccess', 'success', { latency: data.latency_ms });
} else {
const errorText = data.error || t('dbConnections.testUnknownError');
showNotification('dbConnections.testError', 'error', { error: errorText });
}
} catch (error: any) {
showNotification('dbConnections.testNetworkError', 'error', { error: error.message });
} finally {
conn.testing = false;
}
}
// Вспомогательные функции для отображения типа
function getTypeLabel(type: string) {
const labels: Record<string, string> = {
mysql: 'MySQL',
postgres: 'PostgreSQL',
clickhouse: 'ClickHouse'
};
return labels[type] || type;
}
function getTypeBadgeClass(type: string) {
const classes: Record<string, string> = {
mysql: 'bg-blue-100 text-blue-800',
postgres: 'bg-indigo-100 text-indigo-800',
clickhouse: 'bg-amber-100 text-amber-800'
};
return classes[type] || 'bg-gray-100 text-gray-800';
}
function openModal(mode: 'create' | 'edit', conn: Connection | null = null) {
modalMode.value = mode;
if (mode === 'create') {
form.value = {
id: null,
name: '',
type: 'mysql',
host: '',
port: 3306,
database: '',
user: '',
password: ''
};
modalTitle.value = t('dbConnections.add');
} else if (conn) {
form.value = {
id: conn.id,
name: conn.name,
type: conn.type,
host: conn.host,
port: conn.port,
database: conn.database,
user: conn.user,
password: ''
};
modalTitle.value = t('dbConnections.edit');
}
modalOpen.value = true;
}
function closeModal() {
modalOpen.value = false;
}
async function submitConnection() {
if (modalMode.value === 'create' && !form.value.password) {
showNotification('dbConnections.passwordRequired', 'error');
return;
}
try {
const payload: any = {
name: form.value.name,
type: form.value.type,
host: form.value.host,
port: form.value.port,
database: form.value.database,
user: form.value.user,
};
if (form.value.password) {
payload.password = form.value.password;
}
if (modalMode.value === 'create') {
const res = await fetch('/api/admin/database-connections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error();
showNotification('dbConnections.createSuccess', 'success');
} else {
const res = await fetch(`/api/admin/database-connections/${form.value.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error();
showNotification('dbConnections.updateSuccess', 'success');
}
await loadConnections();
closeModal();
} catch (e) {
showNotification(modalMode.value === 'create' ? 'dbConnections.createError' : 'dbConnections.updateError', 'error');
}
}
function confirmDelete(id: number) {
deleteConfirm.value = { show: true, id };
}
async function deleteConnection(id: number) {
try {
const res = await fetch(`/api/admin/database-connections/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error();
showNotification('dbConnections.deleteSuccess', 'success');
await loadConnections();
} catch (e) {
showNotification('dbConnections.deleteError', 'error');
} finally {
deleteConfirm.value.show = false;
}
}
onMounted(loadConnections);
</script>
<style scoped>
@keyframes spin {
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>