fix frontend

This commit is contained in:
2026-04-24 01:44:03 +03:00
parent ff46a37956
commit 1c7e05f6a3
10 changed files with 260 additions and 209 deletions

View File

@@ -191,6 +191,18 @@
<slot /> <slot />
</div> </div>
</main> </main>
<!-- Notification Toast -->
<Transition name="slide">
<div v-if="notification.show" class="fixed bottom-4 right-4 z-50 flex items-center space-x-2 px-4 py-3 rounded-lg shadow-lg" :class="notification.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'">
<svg v-if="notification.type === 'success'" 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" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ notification.message }}</span>
</div>
</Transition>
</div> </div>
</template> </template>
@@ -200,7 +212,9 @@ import { useRoute, useRouter } from 'vue-router'
import { useSettingsStore } from '../../stores/settings' import { useSettingsStore } from '../../stores/settings'
import { useUserStore } from '../../stores/user' import { useUserStore } from '../../stores/user'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useNotification } from '../../composables/useNotification'
const { notification } = useNotification()
const settings = useSettingsStore() const settings = useSettingsStore()
const userStore = useUserStore() const userStore = useUserStore()
const route = useRoute() const route = useRoute()
@@ -237,6 +251,7 @@ async function toggleLanguage() {
locale.value = newLang locale.value = newLang
localStorage.setItem('locale', newLang) localStorage.setItem('locale', newLang)
} else { } else {
showNotification('profile.updateError', 'error');
// В случае ошибки всё равно меняем локаль, но не сохраняем в БД // В случае ошибки всё равно меняем локаль, но не сохраняем в БД
locale.value = newLang locale.value = newLang
localStorage.setItem('locale', newLang) localStorage.setItem('locale', newLang)
@@ -248,3 +263,15 @@ async function toggleLanguage() {
} }
} }
</script> </script>
<style scoped>
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-enter-from,
.slide-leave-to {
transform: translateX(100%);
opacity: 0;
}
</style>

View File

@@ -0,0 +1,43 @@
import { ref, readonly } from 'vue'
import { useI18n } from 'vue-i18n'
type NotificationType = 'success' | 'error'
interface Notification {
show: boolean
type: NotificationType
message: string
}
const notification = ref<Notification>({
show: false,
type: 'success',
message: ''
})
let timeoutId: number | null = null
export function useNotification() {
const { t } = useI18n()
const showNotification = (messageKey: string, type: NotificationType = 'success', params?: Record<string, any>) => {
const message = params ? t(messageKey, params) : t(messageKey)
// Очищаем предыдущий таймер, чтобы уведомление не закрылось раньше времени
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}
notification.value = { show: true, type, message }
timeoutId = window.setTimeout(() => {
notification.value.show = false
timeoutId = null
}, 3000)
}
return {
notification: readonly(notification),
showNotification
}
}

View File

@@ -45,7 +45,8 @@
"leavePasswordBlank": "Leave blank to keep current password", "leavePasswordBlank": "Leave blank to keep current password",
"deleteConfirmation": "Are you sure you want to delete this item? This action cannot be undone.", "deleteConfirmation": "Are you sure you want to delete this item? This action cannot be undone.",
"operationSuccess": "Operation completed successfully", "operationSuccess": "Operation completed successfully",
"operationFailed": "Operation failed" "operationFailed": "Operation failed",
"networkError": "Network error"
}, },
"dashboard": { "dashboard": {
"totalUsers": "Total Users", "totalUsers": "Total Users",
@@ -68,7 +69,8 @@
"new": "New", "new": "New",
"today": "Today", "today": "Today",
"yesterday": "Yesterday", "yesterday": "Yesterday",
"daysAgo": "{count} days ago" "daysAgo": "{count} days ago",
"loadError": "Failed to load dashboard data"
}, },
"users": { "users": {
"pageName": "Users Management", "pageName": "Users Management",
@@ -76,9 +78,21 @@
"edit": "Edit User", "edit": "Edit User",
"delete": "Delete User", "delete": "Delete User",
"you": "(You)", "you": "(You)",
"cannotChangeOwnRole": "You cannot change your own role",
"confirmDelete": "Delete User", "confirmDelete": "Delete User",
"deleteConfirmation": "Are you sure you want to delete this user? This action cannot be undone." "deleteConfirmation": "Are you sure you want to delete this user? This action cannot be undone.",
"statusUpdated": "User status updated",
"statusUpdateError": "Failed to update user status",
"passwordRequired": "Password is required for new user",
"createSuccess": "User created successfully",
"createError": "Failed to create user",
"updateSuccess": "User updated successfully",
"updateError": "Failed to update user",
"deleteSuccess": "User deleted",
"deleteError": "Failed to delete user",
"cannotChangeOwnRole": "You cannot change your own role",
"noUsers": "No users found",
"loadError": "Failed to load users",
"loadCurrentError": "Failed to load current user info"
}, },
"restaurants": { "restaurants": {
"pageName": "Restaurants", "pageName": "Restaurants",
@@ -92,7 +106,7 @@
"noRestaurants": "No restaurants found. Click \"Add Restaurant\" to create one.", "noRestaurants": "No restaurants found. Click \"Add Restaurant\" to create one.",
"deleteConfirmation": "Are you sure you want to delete this restaurant? This action cannot be undone.", "deleteConfirmation": "Are you sure you want to delete this restaurant? This action cannot be undone.",
"check": "Check connection", "check": "Check connection",
"checkError": "Error", "checkError": "Check failed: {error}",
"loadError": "Failed to load restaurants", "loadError": "Failed to load restaurants",
"createSuccess": "Restaurant created successfully", "createSuccess": "Restaurant created successfully",
"updateSuccess": "Restaurant updated successfully", "updateSuccess": "Restaurant updated successfully",
@@ -109,7 +123,10 @@
"saved": "Settings saved successfully", "saved": "Settings saved successfully",
"saveFailed": "Failed to save settings", "saveFailed": "Failed to save settings",
"loadFailed": "Failed to load settings metadata", "loadFailed": "Failed to load settings metadata",
"enabled": "Enabled" "enabled": "Enabled",
"saveSuccess": "Settings saved successfully",
"saveError": "Failed to save settings",
"loadMetaError": "Failed to load settings metadata"
}, },
"profile": { "profile": {
"title": "My Profile", "title": "My Profile",
@@ -122,9 +139,9 @@
"save": "Save Changes", "save": "Save Changes",
"reset": "Reset", "reset": "Reset",
"role": "Role", "role": "Role",
"passwordMismatch": "Passwords do not match", "passwordsMismatch": "Passwords do not match",
"updateSuccess": "Profile updated successfully", "updateSuccess": "Profile updated successfully",
"updateFailed": "Failed to update profile" "updateError": "Failed to update profile"
}, },
"login": { "login": {
"title": "Welcome Back", "title": "Welcome Back",

View File

@@ -45,7 +45,8 @@
"leavePasswordBlank": "Оставьте пустым, чтобы оставить текущий пароль", "leavePasswordBlank": "Оставьте пустым, чтобы оставить текущий пароль",
"deleteConfirmation": "Вы уверены, что хотите удалить этот элемент? Это действие необратимо.", "deleteConfirmation": "Вы уверены, что хотите удалить этот элемент? Это действие необратимо.",
"operationSuccess": "Операция выполнена успешно", "operationSuccess": "Операция выполнена успешно",
"operationFailed": "Операция не удалась" "operationFailed": "Операция не удалась",
"networkError": "Ошибка сети"
}, },
"dashboard": { "dashboard": {
"totalUsers": "Всего пользователей", "totalUsers": "Всего пользователей",
@@ -68,7 +69,8 @@
"new": "Новый", "new": "Новый",
"today": "Сегодня", "today": "Сегодня",
"yesterday": "Вчера", "yesterday": "Вчера",
"daysAgo": "дн. назад" "daysAgo": "дн. назад",
"loadError": "Ошибка загрузки данных дашборда"
}, },
"users": { "users": {
"pageName": "Управление пользователями", "pageName": "Управление пользователями",
@@ -76,9 +78,21 @@
"edit": "Редактировать пользователя", "edit": "Редактировать пользователя",
"delete": "Удалить пользователя", "delete": "Удалить пользователя",
"you": "(Вы)", "you": "(Вы)",
"cannotChangeOwnRole": "Вы не можете изменить свою собственную роль",
"confirmDelete": "Удалить пользователя", "confirmDelete": "Удалить пользователя",
"deleteConfirmation": "Вы уверены, что хотите удалить этого пользователя? Это действие необратимо." "deleteConfirmation": "Вы уверены, что хотите удалить этого пользователя? Это действие необратимо.",
"statusUpdated": "Статус пользователя обновлён",
"statusUpdateError": "Не удалось обновить статус",
"passwordRequired": "Пароль обязателен для нового пользователя",
"createSuccess": "Пользователь создан",
"createError": "Ошибка создания пользователя",
"updateSuccess": "Пользователь обновлён",
"updateError": "Ошибка обновления пользователя",
"deleteSuccess": "Пользователь удалён",
"deleteError": "Ошибка удаления пользователя",
"cannotChangeOwnRole": "Вы не можете изменить свою роль",
"noUsers": "Пользователи не найдены",
"loadError": "Ошибка загрузки списка пользователей",
"loadCurrentError": "Ошибка загрузки информации о текущем пользователе"
}, },
"restaurants": { "restaurants": {
"pageName": "Рестораны", "pageName": "Рестораны",
@@ -92,7 +106,7 @@
"noRestaurants": "Ресторанов не найдено. Нажмите \"Добавить ресторан\", чтобы создать его.", "noRestaurants": "Ресторанов не найдено. Нажмите \"Добавить ресторан\", чтобы создать его.",
"deleteConfirmation": "Вы уверены, что хотите удалить этот ресторан? Это действие необратимо.", "deleteConfirmation": "Вы уверены, что хотите удалить этот ресторан? Это действие необратимо.",
"check": "Проверить подключение", "check": "Проверить подключение",
"checkError": "Ошибка", "checkError": "Ошибка проверки: {error}",
"loadError": "Ошибка загрузки списка ресторанов", "loadError": "Ошибка загрузки списка ресторанов",
"createSuccess": "Ресторан успешно создан", "createSuccess": "Ресторан успешно создан",
"updateSuccess": "Ресторан успешно обновлён", "updateSuccess": "Ресторан успешно обновлён",
@@ -109,7 +123,10 @@
"saved": "Настройки успешно сохранены", "saved": "Настройки успешно сохранены",
"saveFailed": "Не удалось сохранить настройки", "saveFailed": "Не удалось сохранить настройки",
"loadFailed": "Не удалось загрузить метаданные настроек", "loadFailed": "Не удалось загрузить метаданные настроек",
"enabled": "Включено" "enabled": "Включено",
"saveSuccess": "Настройки сохранены",
"saveError": "Ошибка сохранения настроек",
"loadMetaError": "Ошибка загрузки метаданных"
}, },
"profile": { "profile": {
"title": "Мой профиль", "title": "Мой профиль",
@@ -122,9 +139,9 @@
"save": "Сохранить изменения", "save": "Сохранить изменения",
"reset": "Сбросить", "reset": "Сбросить",
"role": "Роль", "role": "Роль",
"passwordMismatch": "Пароли не совпадают", "passwordsMismatch": "Пароли не совпадают",
"updateSuccess": "Профиль успешно обновлен", "updateSuccess": "Профиль обновлён",
"updateFailed": "Не удалось обновить профиль" "updateError": "Ошибка обновления профиля"
}, },
"login": { "login": {
"title": "С возвращением", "title": "С возвращением",

View File

@@ -70,7 +70,9 @@
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import AppLayout from '../components/Layout/AppLayout.vue'; import AppLayout from '../components/Layout/AppLayout.vue';
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useNotification } from '../composables/useNotification'
const { showNotification } = useNotification()
const { t, locale } = useI18n() const { t, locale } = useI18n()
interface FieldMeta { interface FieldMeta {
key: string; key: string;
@@ -92,7 +94,7 @@ async function loadMeta() {
if (res.ok) { if (res.ok) {
meta.value = await res.json(); meta.value = await res.json();
} else { } else {
showMessage('Failed to load settings metadata', 'bg-red-50 text-red-800'); showNotification('settings.loadMetaError', 'error');
} }
} }
@@ -101,7 +103,7 @@ async function loadValues() {
if (res.ok) { if (res.ok) {
values.value = await res.json(); values.value = await res.json();
} else { } else {
showMessage('Failed to load settings values', 'bg-red-50 text-red-800'); showNotification('settings.loadMetaError', 'error');
} }
} }
@@ -117,12 +119,12 @@ async function saveSettings() {
body: JSON.stringify(values.value), body: JSON.stringify(values.value),
}); });
if (res.ok) { if (res.ok) {
showMessage('Settings saved successfully', 'bg-green-50 text-green-800'); showNotification('settings.saveSuccess', 'success');
} else { } else {
showMessage('Failed to save settings', 'bg-red-50 text-red-800'); showNotification('settings.saveError', 'error');
} }
} catch (e) { } catch (e) {
showMessage('Network error', 'bg-red-50 text-red-800'); showNotification('common.networkError', 'error');
} }
} }

View File

@@ -178,7 +178,9 @@ import { ref, onMounted, onUnmounted } from 'vue';
import AppLayout from '../components/Layout/AppLayout.vue'; import AppLayout from '../components/Layout/AppLayout.vue';
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const { t } = useI18n() const { t } = useI18n()
import { useNotification } from '../composables/useNotification'
const { showNotification } = useNotification()
const stats = ref({ totalUsers: 0, activeSessions: 0, systemHealth: 100, uptime: '99.9%' }); const stats = ref({ totalUsers: 0, activeSessions: 0, systemHealth: 100, uptime: '99.9%' });
const userGrowth = ref(12); const userGrowth = ref(12);
const sessionGrowth = ref(5); const sessionGrowth = ref(5);
@@ -222,6 +224,7 @@ async function loadDashboardData() {
})); }));
} }
} catch (e) { } catch (e) {
showNotification('dashboard.loadError', 'error');
console.error('Failed to load dashboard data', e); console.error('Failed to load dashboard data', e);
} }
} }

View File

@@ -77,19 +77,6 @@
</div> </div>
</div> </div>
</form> </form>
<!-- Notification Toast -->
<Transition name="slide">
<div v-if="notification.show" class="fixed bottom-4 right-4 z-50 flex items-center space-x-2 px-4 py-3 rounded-lg shadow-lg" :class="notification.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'">
<svg v-if="notification.type === 'success'" 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" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ notification.message }}</span>
</div>
</Transition>
</div> </div>
</div> </div>
</AppLayout> </AppLayout>
@@ -112,18 +99,10 @@ const form = reactive({
}); });
const loading = ref(false); const loading = ref(false);
const notification = ref({ show: false, type: 'success', message: '' });
const userInitials = computed(() => (userStore.login[0] || 'U').toUpperCase()); const userInitials = computed(() => (userStore.login[0] || 'U').toUpperCase());
const passwordMismatch = computed(() => !!form.password && form.password !== form.confirmPassword); const passwordMismatch = computed(() => !!form.password && form.password !== form.confirmPassword);
function showNotification(message: string, type: 'success' | 'error') {
notification.value = { show: true, type, message };
setTimeout(() => {
notification.value.show = false;
}, 3000);
}
function resetForm() { function resetForm() {
form.email = userStore.email; form.email = userStore.email;
form.password = ''; form.password = '';
@@ -133,7 +112,7 @@ function resetForm() {
async function saveProfile() { async function saveProfile() {
if (form.password && form.password !== form.confirmPassword) { if (form.password && form.password !== form.confirmPassword) {
showNotification('Passwords do not match', 'error'); showNotification('profile.passwordsMismatch', 'error');
return; return;
} }
@@ -149,10 +128,10 @@ async function saveProfile() {
if (ok) { if (ok) {
locale.value = form.language; locale.value = form.language;
showNotification('Profile updated successfully', 'success'); showNotification('profile.updateSuccess', 'success');
resetForm(); // очищаем поля пароля resetForm(); // очищаем поля пароля
} else { } else {
showNotification('Failed to update profile', 'error'); showNotification('profile.updateError', 'error');
} }
} }
@@ -161,15 +140,3 @@ onMounted(() => {
form.language = userStore.language; form.language = userStore.language;
}); });
</script> </script>
<style scoped>
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-enter-from,
.slide-leave-to {
transform: translateX(100%);
opacity: 0;
}
</style>

View File

@@ -31,12 +31,7 @@
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ rest.host }}</td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ rest.host }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm"> <td class="px-6 py-4 whitespace-nowrap text-sm">
<label class="relative inline-flex items-center cursor-pointer"> <label class="relative inline-flex items-center cursor-pointer">
<input <input type="checkbox" :checked="rest.https" @change="toggleHttps(rest)" class="sr-only peer" />
type="checkbox"
:checked="rest.https"
@change="toggleHttps(rest)"
class="sr-only peer"
/>
<div class="w-9 h-5 bg-gray-200 rounded-full peer peer-checked:bg-primary-600 transition-colors"></div> <div class="w-9 h-5 bg-gray-200 rounded-full peer peer-checked:bg-primary-600 transition-colors"></div>
<div class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div> <div class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div>
</label> </label>
@@ -45,13 +40,7 @@
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(rest.created) }}</td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(rest.created) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end space-x-3"> <div class="flex items-center justify-end space-x-3">
<!-- Кнопка проверки --> <button @click="checkRestaurant(rest)" :disabled="rest.checking" class="text-green-600 hover:text-green-800 transition-colors disabled:opacity-50">
<button
@click="checkRestaurant(rest)"
:disabled="rest.checking"
class="text-green-600 hover:text-green-800 transition-colors disabled:opacity-50"
:title="t('restaurants.check')"
>
<svg v-if="!rest.checking" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg v-if="!rest.checking" 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" /> <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>
@@ -60,20 +49,17 @@
<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> <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> </svg>
</button> </button>
<!-- Кнопка редактирования -->
<button @click="openModal('edit', rest)" class="text-blue-600 hover:text-blue-800 transition-colors"> <button @click="openModal('edit', rest)" 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"> <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" /> <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> </svg>
</button> </button>
<!-- Кнопка удаления -->
<button @click="confirmDelete(rest.id)" class="text-red-600 hover:text-red-800 transition-colors"> <button @click="confirmDelete(rest.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"> <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" /> <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> </svg>
</button> </button>
<!-- Результат проверки только при успехе (ms) --> <span v-if="rest.checkResult" class="text-xs px-2 py-1 rounded-full bg-green-100 text-green-700 ml-1 whitespace-nowrap">
<span v-if="rest.checkResult" class="text-xs text-gray-500 ml-1 whitespace-nowrap">
{{ rest.checkResult }} {{ rest.checkResult }}
</span> </span>
</div> </div>
@@ -162,28 +148,17 @@
</div> </div>
</div> </div>
</Transition> </Transition>
<!-- Уведомления (toast) -->
<Transition name="slide">
<div v-if="notification.show" class="fixed bottom-4 right-4 z-50 flex items-center space-x-2 px-4 py-3 rounded-lg shadow-lg" :class="notification.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'">
<svg v-if="notification.type === 'success'" 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" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ notification.message }}</span>
</div>
</Transition>
</AppLayout> </AppLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import AppLayout from '../components/Layout/AppLayout.vue'; import AppLayout from '../components/Layout/AppLayout.vue';
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n';
import { useNotification } from '../composables/useNotification';
const { t } = useI18n() const { t } = useI18n();
const { showNotification } = useNotification();
type Restaurant = { type Restaurant = {
id: number; id: number;
@@ -203,29 +178,18 @@ const form = ref({ id: null, name: '', login: '', password: '', host: '', https:
const modalTitle = ref(''); const modalTitle = ref('');
const deleteConfirm = ref({ show: false, id: null }); const deleteConfirm = ref({ show: false, id: null });
// Уведомления
const notification = ref({ show: false, type: 'success' as 'success' | 'error', message: '' });
function showNotification(message: string, type: 'success' | 'error') {
notification.value = { show: true, type, message };
setTimeout(() => {
notification.value.show = false;
}, 3000);
}
async function loadRestaurants() { async function loadRestaurants() {
try { try {
const res = await fetch('/api/admin/restaurants'); const res = await fetch('/api/admin/restaurants');
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error();
const data = await res.json(); const data = await res.json();
restaurants.value = data.map((r: any) => ({ restaurants.value = data.map((r: any) => ({
...r, ...r,
checking: false, checking: false,
checkResult: null checkResult: null
})); }));
} catch (e: any) { } catch (e) {
showNotification(t('restaurants.loadError'), 'error'); showNotification('restaurants.loadError', 'error');
restaurants.value = [];
} }
} }
@@ -236,53 +200,25 @@ function formatDate(dateStr: string) {
async function checkRestaurant(rest: Restaurant) { async function checkRestaurant(rest: Restaurant) {
rest.checking = true; rest.checking = true;
rest.checkResult = null; // сбрасываем rest.checkResult = null;
try { try {
const response = await fetch(`/api/admin/restaurants/${rest.id}/check`); const response = await fetch(`/api/admin/restaurants/${rest.id}/check`);
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
rest.checkResult = `${data.latency_ms} ms`; // показываем в таблице rest.checkResult = `${data.latency_ms} ms`;
} else { } else {
const errorText = data.error || 'Unknown error'; const errorText = data.error || 'Unknown error';
showNotification(`${t('restaurants.checkError')}: ${errorText}`, 'error'); showNotification('restaurants.checkError', 'error', { error: errorText });
// rest.checkResult остаётся null -> ничего не показываем
} }
} catch (error: any) { } catch (error: any) {
const msg = t('restaurants.checkNetworkError'); showNotification('restaurants.checkNetworkError', 'error', { error: error.message });
showNotification(`${msg}: ${error.message}`, 'error');
// rest.checkResult остаётся null
} finally { } finally {
rest.checking = false; rest.checking = false;
} }
} }
function openModal(mode: 'create' | 'edit', rest: Restaurant | null = null) {
modalMode.value = mode;
if (mode === 'create') {
form.value = { id: null, name: '', login: '', password: '', host: '', https: false };
modalTitle.value = t('restaurants.add');
} else {
if (rest) {
form.value = {
id: rest.id,
name: rest.name,
login: rest.login,
password: '',
host: rest.host,
https: rest.https || false
};
modalTitle.value = t('restaurants.edit');
}
}
modalOpen.value = true;
}
function closeModal() {
modalOpen.value = false;
}
async function toggleHttps(rest: Restaurant) { async function toggleHttps(rest: Restaurant) {
const newHttps = !rest.https; const newHttps = !rest.https;
const payload = { const payload = {
@@ -299,19 +235,41 @@ async function toggleHttps(rest: Restaurant) {
}); });
if (res.ok) { if (res.ok) {
rest.https = newHttps; rest.https = newHttps;
showNotification(t('restaurants.httpsUpdateSuccess'), 'success'); showNotification('restaurants.httpsUpdateSuccess', 'success');
} else { } else {
const errText = await res.text(); showNotification('restaurants.httpsUpdateError', 'error');
showNotification(`${t('restaurants.httpsUpdateError')}: ${errText}`, 'error');
} }
} catch (e: any) { } catch (e) {
showNotification(`${t('restaurants.httpsUpdateError')}: ${e.message}`, 'error'); showNotification('restaurants.httpsUpdateError', 'error');
} }
} }
function openModal(mode: 'create' | 'edit', rest: Restaurant | null = null) {
modalMode.value = mode;
if (mode === 'create') {
form.value = { id: null, name: '', login: '', password: '', host: '', https: false };
modalTitle.value = t('restaurants.add');
} else if (rest) {
form.value = {
id: rest.id,
name: rest.name,
login: rest.login,
password: '',
host: rest.host,
https: rest.https || false
};
modalTitle.value = t('restaurants.edit');
}
modalOpen.value = true;
}
function closeModal() {
modalOpen.value = false;
}
async function submitRestaurant() { async function submitRestaurant() {
if (modalMode.value === 'create' && !form.value.password) { if (modalMode.value === 'create' && !form.value.password) {
showNotification(t('restaurants.passwordRequired'), 'error'); showNotification('restaurants.passwordRequired', 'error');
return; return;
} }
@@ -323,28 +281,27 @@ async function submitRestaurant() {
login: form.value.login, login: form.value.login,
...(form.value.password ? { password: form.value.password } : {}) ...(form.value.password ? { password: form.value.password } : {})
}; };
let response;
if (modalMode.value === 'create') { if (modalMode.value === 'create') {
response = await fetch('/api/admin/restaurants', { const res = await fetch('/api/admin/restaurants', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
if (!response.ok) throw new Error('Create failed'); if (!res.ok) throw new Error();
showNotification(t('restaurants.createSuccess'), 'success'); showNotification('restaurants.createSuccess', 'success');
} else { } else {
response = await fetch(`/api/admin/restaurants/${form.value.id}`, { const res = await fetch(`/api/admin/restaurants/${form.value.id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
if (!response.ok) throw new Error('Update failed'); if (!res.ok) throw new Error();
showNotification(t('restaurants.updateSuccess'), 'success'); showNotification('restaurants.updateSuccess', 'success');
} }
await loadRestaurants(); await loadRestaurants();
closeModal(); closeModal();
} catch (e: any) { } catch (e) {
showNotification(e.message, 'error'); showNotification(modalMode.value === 'create' ? 'restaurants.createError' : 'restaurants.updateError', 'error');
} }
} }
@@ -355,11 +312,11 @@ function confirmDelete(id: number) {
async function deleteRestaurant(id: number) { async function deleteRestaurant(id: number) {
try { try {
const res = await fetch(`/api/admin/restaurants/${id}`, { method: 'DELETE' }); const res = await fetch(`/api/admin/restaurants/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Delete failed'); if (!res.ok) throw new Error();
showNotification(t('restaurants.deleteSuccess'), 'success'); showNotification('restaurants.deleteSuccess', 'success');
await loadRestaurants(); await loadRestaurants();
} catch (e: any) { } catch (e) {
showNotification(e.message, 'error'); showNotification('restaurants.deleteError', 'error');
} finally { } finally {
deleteConfirm.value.show = false; deleteConfirm.value.show = false;
} }
@@ -383,13 +340,4 @@ onMounted(loadRestaurants);
.fade-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
} }
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-enter-from,
.slide-leave-to {
transform: translateX(100%);
opacity: 0;
}
</style> </style>

View File

@@ -68,19 +68,17 @@
</td> </td>
</tr> </tr>
<tr v-if="users.length === 0"> <tr v-if="users.length === 0">
<td colspan="8" class="px-6 py-12 text-center text-gray-500">No users found. Click "Add User" to create one.</td> <td colspan="8" class="px-6 py-12 text-center text-gray-500">{{ t('users.noUsers') }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
<!-- Модалка создания/редактирования пользователя --> <!-- Modal for create/edit user -->
<Transition name="fade"> <Transition name="fade">
<div v-if="modalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeModal"> <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="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<div class="flex items-center justify-center min-h-screen p-4"> <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="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"> <div class="flex justify-between items-center p-6 border-b">
@@ -129,7 +127,7 @@
</div> </div>
</Transition> </Transition>
<!-- Модалка подтверждения удаления --> <!-- Delete confirmation modal -->
<Transition name="fade"> <Transition name="fade">
<div v-if="deleteConfirm.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="deleteConfirm.show = false"> <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="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
@@ -159,26 +157,15 @@
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted, computed } from 'vue';
import AppLayout from '../components/Layout/AppLayout.vue'; import AppLayout from '../components/Layout/AppLayout.vue';
import { useUserStore } from '../stores/user'; import { useUserStore } from '../stores/user';
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n';
import { useNotification } from '../composables/useNotification';
const { t, locale } = useI18n()
const { t } = useI18n();
const { showNotification } = useNotification();
const userStore = useUserStore(); const userStore = useUserStore();
const currentUserId = ref<number | null>(null); const currentUserId = ref<number | null>(null);
const users = ref<any[]>([]);
async function loadCurrentUser() {
try {
const res = await fetch('/api/admin/me');
if (res.ok) {
const data = await res.json();
currentUserId.value = data.id;
}
} catch (e) {
console.error('Failed to load current user', e);
}
}
const users = ref([]);
const modalOpen = ref(false); const modalOpen = ref(false);
const modalMode = ref<'create' | 'edit'>('create'); const modalMode = ref<'create' | 'edit'>('create');
const form = ref({ id: null, login: '', email: '', password: '', role: 'user' }); const form = ref({ id: null, login: '', email: '', password: '', role: 'user' });
@@ -189,9 +176,28 @@ const isEditingSelf = computed(() => {
return modalMode.value === 'edit' && form.value.id === currentUserId.value; return modalMode.value === 'edit' && form.value.id === currentUserId.value;
}); });
async function loadCurrentUser() {
try {
const res = await fetch('/api/admin/me');
if (res.ok) {
const data = await res.json();
currentUserId.value = data.id;
} else {
showNotification('users.loadCurrentError', 'error');
}
} catch (e) {
showNotification('common.networkError', 'error');
}
}
async function loadUsers() { async function loadUsers() {
try {
const res = await fetch('/api/admin/users'); const res = await fetch('/api/admin/users');
if (!res.ok) throw new Error();
users.value = await res.json(); users.value = await res.json();
} catch (e) {
showNotification('users.loadError', 'error');
}
} }
function formatDate(dateStr: string) { function formatDate(dateStr: string) {
@@ -200,8 +206,14 @@ function formatDate(dateStr: string) {
} }
async function toggleActive(user: any) { async function toggleActive(user: any) {
await fetch(`/api/admin/users/${user.id}/activate?active=${!user.active}`, { method: 'PUT' }); try {
const res = await fetch(`/api/admin/users/${user.id}/activate?active=${!user.active}`, { method: 'PUT' });
if (!res.ok) throw new Error();
await loadUsers(); await loadUsers();
showNotification('users.statusUpdated', 'success');
} catch (e) {
showNotification('users.statusUpdateError', 'error');
}
} }
function openModal(mode: 'create' | 'edit', user: any = null) { function openModal(mode: 'create' | 'edit', user: any = null) {
@@ -222,7 +234,12 @@ function closeModal() {
async function submitUser() { async function submitUser() {
if (isEditingSelf.value && form.value.role !== userStore.role) { if (isEditingSelf.value && form.value.role !== userStore.role) {
alert('You cannot change your own role'); showNotification('users.cannotChangeOwnRole', 'error');
return;
}
if (modalMode.value === 'create' && !form.value.password) {
showNotification('users.passwordRequired', 'error');
return; return;
} }
@@ -235,29 +252,30 @@ async function submitUser() {
if (form.value.password) { if (form.value.password) {
payload.password = form.value.password; payload.password = form.value.password;
} }
let response;
if (modalMode.value === 'create') { if (modalMode.value === 'create') {
if (!form.value.password) { response = await fetch('/api/admin/users', {
alert('Password is required');
return;
}
const res = await fetch('/api/admin/users', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!res.ok) throw new Error('Create failed'); if (!response.ok) throw new Error();
showNotification('users.createSuccess', 'success');
} else { } else {
const res = await fetch(`/api/admin/users/${form.value.id}`, { response = await fetch(`/api/admin/users/${form.value.id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!res.ok) throw new Error('Update failed'); if (!response.ok) throw new Error();
showNotification('users.updateSuccess', 'success');
} }
await loadUsers(); await loadUsers();
closeModal(); closeModal();
} catch (e) { } catch (e) {
alert('Operation failed: ' + e.message); showNotification(modalMode.value === 'create' ? 'users.createError' : 'users.updateError', 'error');
} }
} }
@@ -266,10 +284,17 @@ function confirmDelete(id: number) {
} }
async function deleteUser(id: number) { async function deleteUser(id: number) {
await fetch(`/api/admin/users/${id}`, { method: 'DELETE' }); try {
const res = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error();
showNotification('users.deleteSuccess', 'success');
await loadUsers(); await loadUsers();
} catch (e) {
showNotification('users.deleteError', 'error');
} finally {
deleteConfirm.value.show = false; deleteConfirm.value.show = false;
} }
}
onMounted(async () => { onMounted(async () => {
await loadCurrentUser(); await loadCurrentUser();

View File

@@ -42,7 +42,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location /phpmyadmin { location /phpmyadmin/ {
allow 80.68.9.83; allow 80.68.9.83;
allow 185.51.125.202; allow 185.51.125.202;
@@ -64,10 +64,12 @@ server {
deny all; deny all;
proxy_pass http://127.0.0.1:7102; proxy_pass http://127.0.0.1:7102/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection keep-alive;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_cache_bypass $http_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }