336 lines
9.8 KiB
Vue
336 lines
9.8 KiB
Vue
<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">
|
||
<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>
|
||
</nav>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { useAuth } from '@/auth';
|
||
import { ref, watch, onMounted, computed, onUnmounted } from 'vue';
|
||
import api from '@/services/api';
|
||
import { getSocket, connectSocket } from '@/services/socketService';
|
||
|
||
const { user, isAuthenticated, logout, fetchUser } = useAuth();
|
||
|
||
// Используем локальные переменные для отслеживания состояния
|
||
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);
|
||
});
|
||
|
||
let socket = null;
|
||
|
||
// Получение списка диалогов для отображения счетчика непрочитанных сообщений
|
||
const fetchConversations = async () => {
|
||
if (!isAuthenticated.value) return;
|
||
|
||
try {
|
||
const response = await api.getUserConversations();
|
||
conversations.value = response.data;
|
||
console.log('[App] Загружено диалогов для счетчика уведомлений:', conversations.value.length);
|
||
} catch (err) {
|
||
console.error('[App] Ошибка при загрузке диалогов:', err);
|
||
}
|
||
};
|
||
|
||
// Обработчик новых сообщений
|
||
const handleNewMessage = (message) => {
|
||
// Если сообщение от другого пользователя, увеличиваем счетчик
|
||
if (message.sender !== user.value?._id) {
|
||
const conversation = conversations.value.find(c => c._id === message.conversationId);
|
||
if (conversation) {
|
||
conversation.unreadCount = (conversation.unreadCount || 0) + 1;
|
||
} else {
|
||
// Если диалога нет в списке, обновим весь список
|
||
fetchConversations();
|
||
}
|
||
}
|
||
};
|
||
|
||
// Обработчик прочитанных сообщений
|
||
const handleMessagesRead = ({ conversationId, readerId }) => {
|
||
// Если текущий пользователь прочитал сообщения
|
||
if (readerId === user.value?._id) {
|
||
const conversation = conversations.value.find(c => c._id === conversationId);
|
||
if (conversation) {
|
||
conversation.unreadCount = 0;
|
||
}
|
||
}
|
||
};
|
||
|
||
// Обновляем локальные переменные при изменении состояния аутентификации
|
||
watch(() => isAuthenticated.value, (newVal) => {
|
||
console.log('[App] isAuthenticated изменился:', newVal);
|
||
authState.value = newVal;
|
||
|
||
if (newVal) {
|
||
fetchConversations();
|
||
setupSocketConnection();
|
||
}
|
||
});
|
||
|
||
// Обновляем локальную копию данных пользователя
|
||
watch(() => user.value, (newVal) => {
|
||
console.log('[App] Данные пользователя изменились:', newVal);
|
||
userData.value = newVal;
|
||
});
|
||
|
||
// Настройка сокет-соединения
|
||
const setupSocketConnection = () => {
|
||
socket = getSocket();
|
||
if (!socket) {
|
||
socket = connectSocket();
|
||
}
|
||
|
||
if (socket) {
|
||
socket.on('getMessage', handleNewMessage);
|
||
socket.on('messagesRead', handleMessagesRead);
|
||
|
||
// Присоединение к комнате для уведомлений
|
||
if (user.value?._id) {
|
||
socket.emit('joinNotificationRoom', user.value._id);
|
||
}
|
||
}
|
||
};
|
||
|
||
// При монтировании компонента синхронизируем состояние
|
||
onMounted(() => {
|
||
console.log('[App] App.vue монтирован, проверка состояния аутентификации');
|
||
authState.value = isAuthenticated.value;
|
||
userData.value = user.value;
|
||
|
||
// Убедимся, что у нас есть актуальные данные пользователя
|
||
if (localStorage.getItem('userToken') && !isAuthenticated.value) {
|
||
fetchUser().then(() => {
|
||
if (isAuthenticated.value) {
|
||
fetchConversations();
|
||
setupSocketConnection();
|
||
}
|
||
});
|
||
} else if (isAuthenticated.value) {
|
||
fetchConversations();
|
||
setupSocketConnection();
|
||
}
|
||
});
|
||
|
||
// При размонтировании компонента очищаем обработчики событий
|
||
onUnmounted(() => {
|
||
if (socket) {
|
||
socket.off('getMessage', handleNewMessage);
|
||
socket.off('messagesRead', handleMessagesRead);
|
||
|
||
if (user.value?._id) {
|
||
socket.emit('leaveNotificationRoom', 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; /* Ускорение для анимаций */
|
||
}
|
||
|
||
#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-text {
|
||
font-size: 0.7rem;
|
||
font-weight: 500;
|
||
/* Критическое изменение: добавляем отступ чтобы поднять текст выше home indicator */
|
||
transform: translateY(-10px);
|
||
display: block;
|
||
}
|
||
|
||
/* 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;
|
||
}
|
||
|
||
/* Важное изменение - переместить этот селектор за пределы @supports */
|
||
.app-content {
|
||
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> |