Reflex/src/App.vue
Professional 71b48b5d25 фикс
2025-05-26 16:41:09 +07:00

498 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div id="app-container">
<!-- Основная область с содержимым -->
<main class="app-content">
<router-view v-slot="{ Component }">
<transition name="page-transition" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
<!-- Мобильная навигация внизу экрана -->
<nav v-if="authState" class="mobile-nav">
<!-- Вкладки для администратора -->
<template v-if="currentUserIsAdmin">
<router-link to="/admin/users" class="nav-item" active-class="active">
<div class="nav-icon">
<i class="bi-people"></i>
</div>
<span class="nav-text">Пользователи</span>
</router-link>
<router-link to="/admin/conversations" class="nav-item" active-class="active">
<div class="nav-icon">
<i class="bi-chat-square-dots"></i>
</div>
<span class="nav-text">Диалоги</span>
</router-link>
<router-link to="/admin/reports" class="nav-item" active-class="active">
<div class="nav-icon">
<i class="bi-exclamation-octagon"></i>
</div>
<span class="nav-text">Жалобы</span>
</router-link>
<router-link to="/admin/statistics" class="nav-item" active-class="active">
<div class="nav-icon">
<i class="bi-bar-chart-line"></i>
</div>
<span class="nav-text">Статистика</span>
</router-link>
</template>
<!-- Вкладки для обычного пользователя (не показываются в админ-панели, если isAdminRoute true) -->
<template v-else-if="!isAdminRoute">
<router-link to="/swipe" class="nav-item" active-class="active">
<div class="nav-icon">
<i class="bi-search"></i>
</div>
<span class="nav-text">Поиск</span>
</router-link>
<router-link to="/chats" class="nav-item" active-class="active">
<div class="nav-icon">
<i class="bi-chat-dots"></i>
<span v-if="totalUnread > 0" class="unread-badge">
{{ totalUnread < 100 ? totalUnread : '99+' }}
</span>
</div>
<span class="nav-text">Чаты</span>
</router-link>
<router-link to="/profile" class="nav-item" active-class="active">
<div class="nav-icon">
<i class="bi-person"></i>
</div>
<span class="nav-text">Профиль</span>
</router-link>
</template>
</nav>
<!-- Контейнер для уведомлений -->
<div class="toast-list">
<Toast
v-for="toast in toasts"
:key="toast.id"
:message="toast.message"
:type="toast.type"
:duration="toast.duration"
@close="removeToast(toast.id)"
/>
</div>
</div>
</template>
<script setup>
import { useAuth } from '@/auth';
import { ref, watch, onMounted, computed, onUnmounted } from 'vue';
import { useRoute } from 'vue-router';
import api from '@/services/api';
import { getSocket, connectSocket } from '@/services/socketService';
import Toast from '@/components/Toast.vue';
import toastService from '@/services/toastService';
const { user, isAuthenticated, logout, fetchUser } = useAuth();
const route = useRoute();
// Импортируем уведомления из сервиса
const toasts = toastService.toasts;
const removeToast = toastService.remove;
const userData = ref(null);
const authState = ref(false);
const conversations = ref([]);
const totalUnread = computed(() => {
return conversations.value.reduce((sum, conv) => sum + (conv.unreadCount || 0), 0);
});
const currentUserIsAdmin = computed(() => {
return !!user.value && !!user.value.isAdmin;
});
const isAdminRoute = computed(() => {
return route.path.startsWith('/admin');
});
let socket = null;
const fetchConversations = async () => {
if (!isAuthenticated.value || currentUserIsAdmin.value) return;
try {
const response = await api.getUserConversations();
conversations.value = response.data;
} catch (err) {
console.error('[App] Ошибка при загрузке диалогов:', err);
}
};
const handleNewMessage = (message) => {
if (currentUserIsAdmin.value || !user.value) return;
const senderId = typeof message.sender === 'object' ? message.sender._id : message.sender;
if (senderId !== user.value._id) {
setTimeout(() => {
const conversation = conversations.value.find(c => c._id === message.conversationId);
if (conversation) {
const currentRoute = route.path;
const isInThisChat = currentRoute === `/chat/${message.conversationId}`;
if (!isInThisChat) {
conversation.unreadCount = (conversation.unreadCount || 0) + 1;
}
} else {
fetchConversations();
}
}, 100);
}
};
const handleMessagesRead = ({ conversationId, readerId }) => {
if (currentUserIsAdmin.value || !user.value) return;
if (readerId === user.value._id) {
const conversation = conversations.value.find(c => c._id === conversationId);
if (conversation) {
conversation.unreadCount = 0;
}
}
};
watch(() => isAuthenticated.value, (newAuthStatus, oldAuthStatus) => {
authState.value = newAuthStatus;
if (newAuthStatus) {
console.log('[App.vue watch isAuthenticated] Became authenticated.');
// Initial setup for authenticated users is handled by onMounted or watch(user.value)
} else if (oldAuthStatus && !newAuthStatus) {
// User is now unauthenticated (logged out).
console.log('[App.vue watch isAuthenticated] Became unauthenticated. Cleaning up.');
conversations.value = [];
if (socket) {
socket.off('getMessage', handleNewMessage);
socket.off('messagesRead', handleMessagesRead);
// Use userData.value for _id as user.value is null on logout
// userData.value holds the *previous* user data here
if (userData.value?._id && !(userData.value?.isAdmin)) {
socket.emit('leaveNotificationRoom', userData.value._id);
}
}
userData.value = null; // Clear local userData as well
}
});
watch(() => user.value, (newUser, oldUser) => {
userData.value = newUser;
if (isAuthenticated.value) {
console.log('[App.vue watch user.value] User data changed and authenticated. Initializing/Re-initializing user state.');
initializeUserState();
} else if (oldUser && !newUser) {
console.log('[App.vue watch user.value] User logged out (user.value became null).');
// Cleanup is primarily handled by watch(isAuthenticated.value)
}
}, { deep: true });
watch(() => route.path, (newPath, oldPath) => {
if (isAuthenticated.value && !currentUserIsAdmin.value) {
if (newPath.startsWith('/chat/') || oldPath?.startsWith('/chat/')) {
setTimeout(() => {
fetchConversations();
}, 500);
}
}
});
const setupSocketConnection = () => {
if (currentUserIsAdmin.value || !user.value?._id) return;
socket = getSocket();
if (!socket) {
socket = connectSocket();
}
if (socket) {
socket.off('getMessage', handleNewMessage);
socket.off('messagesRead', handleMessagesRead);
socket.on('getMessage', handleNewMessage);
socket.on('messagesRead', handleMessagesRead);
socket.emit('joinNotificationRoom', user.value._id);
console.log('[App.vue setupSocketConnection] Socket connection configured for user:', user.value._id);
}
};
const initializeUserState = () => {
if (isAuthenticated.value) {
if (!currentUserIsAdmin.value) {
console.log('[App.vue initializeUserState] Initializing for regular user.');
fetchConversations();
setupSocketConnection();
} else {
console.log('[App.vue initializeUserState] User is Admin. Clearing any regular user socket listeners.');
if (socket) { // Ensure any previous user socket listeners are off for admin
socket.off('getMessage', handleNewMessage);
socket.off('messagesRead', handleMessagesRead);
}
}
}
};
onMounted(async () => {
authState.value = isAuthenticated.value;
userData.value = user.value;
if (localStorage.getItem('userToken') && !user.value) {
console.log('[App.vue onMounted] Token found, user data missing. Fetching user...');
await fetchUser();
console.log('[App.vue onMounted] User fetched. isAuthenticated:', isAuthenticated.value, 'isAdmin:', currentUserIsAdmin.value);
}
authState.value = isAuthenticated.value;
userData.value = user.value;
if (isAuthenticated.value) {
if (currentUserIsAdmin.value) {
console.log('[App.vue onMounted] Authenticated user is Admin.');
initializeUserState(); // Handles admin case (clears user listeners)
} else {
console.log('[App.vue onMounted] Authenticated user is a regular user. Initializing user state.');
initializeUserState();
}
} else {
console.log('[App.vue onMounted] User is not authenticated.');
}
});
onUnmounted(() => {
if (socket) {
socket.off('getMessage', handleNewMessage);
socket.off('messagesRead', handleMessagesRead);
if (user.value?._id && !currentUserIsAdmin.value) {
socket.emit('leaveNotificationRoom', user.value._id);
console.log('[App.vue onUnmounted] Left notification room for user:', user.value._id);
}
}
});
</script>
<style>
/* Глобальные стили */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
width: 100%;
overflow-x: hidden;
-webkit-tap-highlight-color: transparent; /* Убирает фон при касании на мобильных */
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: #212529;
background-color: #f8f9fa;
overscroll-behavior: none; /* Предотвращает "bounce" эффект на iOS */
overflow: hidden;
will-change: transform; /* Ускорение для анимаций */
}
/* Контейнер для уведомлений */
.toast-list {
position: fixed;
top: 20px;
right: 20px;
z-index: 2000;
display: flex;
flex-direction: column;
gap: 10px;
pointer-events: none;
}
.toast-list > * {
pointer-events: auto;
}
#app-container {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
position: relative;
overflow: hidden;
background-color: #f8f9fa;
}
/* Основная область содержимого */
.app-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch; /* Плавная прокрутка на iOS */
width: 100%;
height: calc(100% - var(--nav-height, 60px)); /* Высота за вычетом навбара */
position: absolute;
top: 0;
left: 0;
right: 0;
}
/* Анимация переходов между страницами */
.page-transition-enter-active,
.page-transition-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.page-transition-enter-from,
.page-transition-leave-to {
opacity: 0;
transform: translateY(10px);
}
/* Мобильная навигация */
.mobile-nav {
display: flex;
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: var(--nav-height, 60px);
background: white;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
.nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-decoration: none;
color: #6c757d;
transition: all 0.2s ease;
/* Удаляем отступы, они будут применляться по-другому */
padding: 0;
position: relative;
}
.nav-icon {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 3px;
height: 28px;
}
/* Стили для индикатора непрочитанных сообщений в навигации */
.nav-icon .unread-badge {
position: absolute;
top: -8px;
right: -8px;
background: linear-gradient(135deg, #ff6b6b, #ff5252);
color: white;
font-size: 0.65rem;
font-weight: 700;
border-radius: 10px;
min-width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 5px;
box-shadow: 0 2px 8px rgba(255, 107, 107, 0.4);
border: 2px solid white;
animation: pulse-notification 2s ease-in-out infinite;
z-index: 1;
}
/* Анимация пульсации для привлечения внимания */
@keyframes pulse-notification {
0%, 100% {
transform: scale(1);
box-shadow: 0 2px 8px rgba(255, 107, 107, 0.4);
}
50% {
transform: scale(1.1);
box-shadow: 0 3px 12px rgba(255, 107, 107, 0.6);
}
}
/* Оборачиваем текст для лучшего управления */
.nav-text {
font-size: 0.7rem;
font-weight: 500;
/* Критическое изменение: добавляем отступ чтобы поднять текст выше home indicator */
transform: translateY(-10px);
display: block;
}
/* Активная вкладка */
.nav-item.active {
color: #667eea;
}
.nav-item.active .nav-icon i {
color: #667eea;
transform: translateY(-2px);
}
/* CSS переменные для динамической настройки */
:root {
--nav-height: 60px;
--header-height: 56px;
--safe-area-bottom: 0px;
}
/* Обновленная поддержка для устройств с вырезом (iPhone X и новее) */
@supports (padding: env(safe-area-inset-bottom)) {
:root {
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
}
.mobile-nav {
height: calc(var(--nav-height) + var(--safe-area-bottom));
padding-bottom: var(--safe-area-bottom);
}
.nav-item {
/* Элемент занимает всю высоту панели, включая safe area */
height: 100%;
/* Добавляем флекс для размещения иконки и текста */
display: flex;
flex-direction: column;
justify-content: flex-start;
padding-top: 8px;
}
.app-content { /* Это правило изменяет высоту .app-content, если есть safe-area */
height: calc(100% - (var(--nav-height) + var(--safe-area-bottom)));
}
}
/* Адаптивные настройки для различных устройств */
@media (max-width: 576px) {
:root {
--nav-height: 56px;
}
.nav-item i {
font-size: 1.3rem;
}
}
/* Маленькие телефоны */
@media (max-width: 375px) {
.nav-item span {
font-size: 0.65rem;
}
}
/* Ландшафтная ориентация на мобильных */
@media (max-height: 450px) and (orientation: landscape) {
:root {
--nav-height: 50px;
}
.nav-item {
flex-direction: row;
gap: 5px;
}
.nav-icon {
margin-bottom: 0;
}
}
</style>