From 5b2b8ad28880d2cdec2cfee773f9a2de42acb768 Mon Sep 17 00:00:00 2001 From: Professional Date: Sun, 25 May 2025 00:57:08 +0700 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B1=D0=B0=D0=B7=D0=BE=D0=B2=D1=8B=D1=85?= =?UTF-8?q?=20=D0=B0=D0=BB=D0=B3=D0=BE=D1=80=D0=B8=D1=82=D0=BC=D0=BE=D0=B2?= =?UTF-8?q?=20=D1=80=D0=B5=D0=BA=D0=BE=D0=BC=D0=B5=D0=BD=D0=B4=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/userController.js | 187 ++++++- backend/models/User.js | 5 + src/router/index.js | 6 + src/views/PreferencesView.vue | 708 ++++++++++++++++++++++++++ src/views/ProfileView.vue | 497 +++++++++--------- 5 files changed, 1125 insertions(+), 278 deletions(-) create mode 100644 src/views/PreferencesView.vue diff --git a/backend/controllers/userController.js b/backend/controllers/userController.js index 38d6134..63c83e2 100644 --- a/backend/controllers/userController.js +++ b/backend/controllers/userController.js @@ -54,6 +54,15 @@ const updateUserProfile = async (req, res, next) => { user.preferences.ageRange.min = req.body.preferences.ageRange.min || user.preferences.ageRange.min; user.preferences.ageRange.max = req.body.preferences.ageRange.max || user.preferences.ageRange.max; } + // Обновление предпочтений по городу + if (req.body.preferences.cityPreferences) { + if (typeof req.body.preferences.cityPreferences.sameCity === 'boolean') { + user.preferences.cityPreferences.sameCity = req.body.preferences.cityPreferences.sameCity; + } + if (Array.isArray(req.body.preferences.cityPreferences.allowedCities)) { + user.preferences.cityPreferences.allowedCities = req.body.preferences.cityPreferences.allowedCities; + } + } } console.log('Данные пользователя перед сохранением:', user); @@ -99,16 +108,124 @@ const updateUserProfile = async (req, res, next) => { const getUsersForSwiping = async (req, res, next) => { try { const currentUserId = req.user._id; + + // Получаем данные текущего пользователя для фильтрации рекомендаций + const currentUser = await User.findById(currentUserId) + .select('preferences location dateOfBirth liked passed'); - // TODO: Более сложная логика фильтрации и сортировки - const users = await User.find({ - _id: { $ne: currentUserId }, - // Можно добавить условие, чтобы у пользователя было хотя бы одно фото - // 'photos.0': { $exists: true } // Если нужно показывать только тех, у кого есть фото - }) - .select('name dateOfBirth gender bio photos preferences.ageRange'); // photos все еще нужны для выбора главной + if (!currentUser) { + const error = new Error('Пользователь не найден.'); + error.statusCode = 404; + return next(error); + } - const usersWithAgeAndPhoto = users.map(user => { + // Вычисляем возраст текущего пользователя + let currentUserAge = null; + if (currentUser.dateOfBirth) { + const birthDate = new Date(currentUser.dateOfBirth); + const today = new Date(); + currentUserAge = today.getFullYear() - birthDate.getFullYear(); + const m = today.getMonth() - birthDate.getMonth(); + if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) { + currentUserAge--; + } + } + + // Автоматически устанавливаем возрастные предпочтения ±2 года, если они не заданы + let ageRangeMin = 18; + let ageRangeMax = 99; + + if (currentUser.preferences?.ageRange?.min && currentUser.preferences?.ageRange?.max) { + // Используем пользовательские настройки + ageRangeMin = currentUser.preferences.ageRange.min; + ageRangeMax = currentUser.preferences.ageRange.max; + } else if (currentUserAge) { + // Устанавливаем ±2 года от возраста пользователя по умолчанию + ageRangeMin = Math.max(18, currentUserAge - 2); + ageRangeMax = Math.min(99, currentUserAge + 2); + console.log(`[USER_CTRL] Автоматически установлен возрастной диапазон: ${ageRangeMin}-${ageRangeMax} для пользователя ${currentUserAge} лет`); + } + + // Строим базовый фильтр + let matchFilter = { + _id: { + $ne: currentUserId, + $nin: [...(currentUser.liked || []), ...(currentUser.passed || [])] // Исключаем уже просмотренных + }, + isActive: true // Только активные пользователи + }; + + // Фильтрация по полу согласно предпочтениям + if (currentUser.preferences?.gender && currentUser.preferences.gender !== 'any') { + matchFilter.gender = currentUser.preferences.gender; + } + + // Улучшенная фильтрация по городу + const cityFilter = []; + + // Если включена настройка "только мой город" и город указан + if (currentUser.preferences?.cityPreferences?.sameCity !== false && currentUser.location?.city) { + cityFilter.push(currentUser.location.city); + } + + // Добавляем дополнительные разрешенные города + if (currentUser.preferences?.cityPreferences?.allowedCities?.length > 0) { + cityFilter.push(...currentUser.preferences.cityPreferences.allowedCities); + } + + // Применяем фильтр по городу только если есть что фильтровать + if (cityFilter.length > 0) { + // Убираем дубликаты + const uniqueCities = [...new Set(cityFilter)]; + matchFilter['location.city'] = { $in: uniqueCities }; + console.log(`[USER_CTRL] Фильтрация по городам: ${uniqueCities.join(', ')}`); + } + + // Получаем пользователей с базовой фильтрацией + const users = await User.find(matchFilter) + .select('name dateOfBirth gender bio photos location preferences.ageRange preferences.gender'); + + // Дополнительная фильтрация по возрасту и взаимным предпочтениям + const filteredUsers = users.filter(user => { + // Вычисляем возраст пользователя + let userAge = null; + if (user.dateOfBirth) { + const birthDate = new Date(user.dateOfBirth); + const today = new Date(); + userAge = today.getFullYear() - birthDate.getFullYear(); + const m = today.getMonth() - birthDate.getMonth(); + if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) { + userAge--; + } + } + + // Проверяем возрастные предпочтения текущего пользователя + if (userAge) { + if (userAge < ageRangeMin || userAge > ageRangeMax) { + return false; + } + } + + // Проверяем взаимные предпочтения по полу + if (user.preferences?.gender && user.preferences.gender !== 'any' && currentUser.gender) { + if (user.preferences.gender !== currentUser.gender) { + return false; + } + } + + // Проверяем взаимные возрастные предпочтения + if (currentUserAge && user.preferences?.ageRange) { + const { min = 18, max = 99 } = user.preferences.ageRange; + if (currentUserAge < min || currentUserAge > max) { + return false; + } + } + + return true; + }); + + // Формируем ответ с дополнительными данными + const usersWithAgeAndPhoto = filteredUsers.map(user => { let age = null; if (user.dateOfBirth) { const birthDate = new Date(user.dateOfBirth); @@ -120,19 +237,12 @@ const getUsersForSwiping = async (req, res, next) => { } } - // --- Логика выбора главной фотографии --- + // Логика выбора главной фотографии let mainPhotoUrl = null; if (user.photos && user.photos.length > 0) { - // Ищем фото, помеченное как главное const profilePic = user.photos.find(photo => photo.isProfilePhoto === true); - if (profilePic) { - mainPhotoUrl = profilePic.url; - } else { - // Если нет явно помеченного главного фото, берем первое из массива - mainPhotoUrl = user.photos[0].url; - } + mainPhotoUrl = profilePic ? profilePic.url : user.photos[0].url; } - // ----------------------------------------- return { _id: user._id, @@ -140,12 +250,49 @@ const getUsersForSwiping = async (req, res, next) => { age: age, gender: user.gender, bio: user.bio, - mainPhotoUrl: mainPhotoUrl, // <--- Добавляем URL главной фотографии - photos: user.photos, // Возвращаем все фото для карусели + mainPhotoUrl: mainPhotoUrl, + photos: user.photos, + location: user.location }; }); + + // Улучшенная сортировка пользователей + const sortedUsers = usersWithAgeAndPhoto.sort((a, b) => { + // 1. Приоритет пользователям из того же города + const aInSameCity = a.location?.city === currentUser.location?.city; + const bInSameCity = b.location?.city === currentUser.location?.city; + + if (aInSameCity && !bInSameCity) return -1; + if (!aInSameCity && bInSameCity) return 1; + + // 2. Если оба из одного города или оба из разных, сортируем по близости возраста + if (currentUserAge && a.age && b.age) { + const aDiff = Math.abs(a.age - currentUserAge); + const bDiff = Math.abs(b.age - currentUserAge); + if (aDiff !== bDiff) { + return aDiff - bDiff; + } + } + + // 3. Приоритет пользователям с фотографиями + const aHasPhotos = a.photos && a.photos.length > 0; + const bHasPhotos = b.photos && b.photos.length > 0; + + if (aHasPhotos && !bHasPhotos) return -1; + if (!aHasPhotos && bHasPhotos) return 1; + + // 4. Приоритет пользователям с био + const aHasBio = a.bio && a.bio.trim().length > 0; + const bHasBio = b.bio && b.bio.trim().length > 0; + + if (aHasBio && !bHasBio) return -1; + if (!aHasBio && bHasBio) return 1; + + return 0; + }); - res.status(200).json(usersWithAgeAndPhoto); + console.log(`[USER_CTRL] Найдено ${sortedUsers.length} пользователей для рекомендаций (возраст: ${ageRangeMin}-${ageRangeMax})`); + res.status(200).json(sortedUsers); } catch (error) { console.error('Ошибка при получении пользователей для свайпа:', error.message); diff --git a/backend/models/User.js b/backend/models/User.js index 21b3ecc..a5f43f3 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -54,6 +54,11 @@ const userSchema = new mongoose.Schema( min: { type: Number, default: 18 }, max: { type: Number, default: 99 }, }, + // Добавляем предпочтения по городу + cityPreferences: { + sameCity: { type: Boolean, default: true }, // Показывать только из того же города + allowedCities: [{ type: String }] // Дополнительные разрешенные города + } }, isActive: { type: Boolean, diff --git a/src/router/index.js b/src/router/index.js index bcbd0ff..b2b8248 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -50,6 +50,12 @@ const routes = [ component: () => import('../views/UserProfileView.vue'), meta: { requiresAuth: true }, props: true // Позволяет передавать :userId как пропс в компонент + }, + { + path: '/preferences', // Настройки предпочтений поиска + name: 'Preferences', + component: () => import('../views/PreferencesView.vue'), + meta: { requiresAuth: true } } // ... здесь будут другие маршруты: /profile, /swipe, /chat/:id и т.д. ]; diff --git a/src/views/PreferencesView.vue b/src/views/PreferencesView.vue new file mode 100644 index 0000000..8f3560c --- /dev/null +++ b/src/views/PreferencesView.vue @@ -0,0 +1,708 @@ + + + + + \ No newline at end of file diff --git a/src/views/ProfileView.vue b/src/views/ProfileView.vue index e08658f..b79c12f 100644 --- a/src/views/ProfileView.vue +++ b/src/views/ProfileView.vue @@ -112,6 +112,14 @@ Фотографии {{ profileData.photos.length }} + + @@ -380,6 +388,96 @@ + + +
+
+
+

Уведомления

+
+
+

Здесь вы можете управлять настройками уведомлений.

+ +
+
+
+ + +
+
+
+

+ + Настройки поиска +

+ + + Настроить + +
+
+
+
+
+ + Пол партнера +
+
+ {{ getGenderPreferenceText(profileData.preferences.gender) }} +
+
+ +
+
+ + Возрастной диапазон +
+
+ {{ profileData.preferences.ageRange?.min || 18 }} - {{ profileData.preferences.ageRange?.max || 99 }} лет +
+
+ +
+
+ + Местоположение +
+
+ + Только мой город + + ({{ profileData.location.city }}) + + + + Любой город + + + {{ profileData.preferences.cityPreferences.allowedCities.length }} дополнительных + + +
+
+ +
+ + Настройки влияют на то, кого вы увидите в рекомендациях +
+
+ +
+
+ +
+

Настройки не заданы

+

Настройте предпочтения поиска для получения более релевантных рекомендаций

+ + + Настроить предпочтения + +
+
+
+
@@ -1156,6 +1254,17 @@ const clearGenderSelection = () => { showGenderList.value = false; }; +// Функция для отображения предпочтений по полу +const getGenderPreferenceText = (genderPreference) => { + const genderMap = { + 'male': 'Мужской', + 'female': 'Женский', + 'other': 'Другой', + 'any': 'Любой' + }; + return genderMap[genderPreference] || 'Не указано'; +}; + // Закрытие выпадающих списков при клике вне их const handleClickOutside = (event) => { if (!event.target.closest('.city-input-wrapper')) { @@ -1785,7 +1894,27 @@ onUnmounted(() => { } .action-btn.primary { - background: rgba(102, 126, 234, 0.9); + background: linear-gradient(45deg, #667eea, #764ba2); + color: white; + border: none; + padding: 0.6rem 1.2rem; + border-radius: 25px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: 0.5rem; + text-decoration: none; + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); +} + +.action-btn.primary:hover { + background: linear-gradient(45deg, #5a6abf, #6a4190); + transform: translateY(-2px); + box-shadow: 0 6px 15px rgba(102, 126, 234, 0.4); + text-decoration: none; color: white; } @@ -1794,10 +1923,6 @@ onUnmounted(() => { color: white; } -.action-btn.primary:hover { - background: #5a6abf; -} - .action-btn.danger:hover { background: #c82333; } @@ -2075,6 +2200,11 @@ onUnmounted(() => { border: 1px solid transparent; } + + + + + .logout-btn:hover { color: #c82333; background: rgba(220, 53, 69, 0.1); @@ -2099,7 +2229,7 @@ onUnmounted(() => { .upload-zone:hover { border-color: #667eea; - background: rgba(102, 126, 234, 0.05); + background: rgba(102, 126, 234, 0.05); transform: translateY(-2px); } @@ -2250,265 +2380,116 @@ onUnmounted(() => { color: #6c757d; } -/* Адаптивные стили */ -@media (min-width: 768px) { - /* Desktop styles */ - .profile-header-content { - flex-direction: row; - justify-content: space-between; - } - - .avatar-section { - flex: 1; - justify-content: flex-start; - margin-bottom: 0; - } - - .stats-section { - width: 320px; - margin-top: 0; - } - - - .info-grid { - grid-template-columns: repeat(2, 1fr); - } - - .photo-grid { - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - } - - .tabs-content { - padding: 0; - } +/* Preferences Tab Styles */ +.preferences-card .card-header { + display: flex; + justify-content: space-between; + align-items: center; } -@media (max-width: 767px) { - /* Tablet and mobile styles */ - .profile-header-content { - flex-direction: column; - - - } - - .avatar-section { - flex-wrap: wrap; - } - - .avatar-container { - margin: 0 auto; - } - - .user-meta { - text-align: center; - width: 100%; - } - - .user-badges { - justify-content: center; - } - - .header-actions { - margin-top: 0.8rem; - justify-content: center; - width: 100%; - } - - .stats-section { - flex-wrap: wrap; - gap: 0.5rem; - } - - .stat-item { - flex: 1; - justify-content: center; - min-width: 100px; - } - - .info-grid { - grid-template-columns: 1fr; - } - - .photo-grid { - grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); - } - - .tab-btn span { - display: none; - } - - .tab-btn i { - font-size: 1.2rem; - } +.preferences-summary { + display: flex; + flex-direction: column; + gap: 1rem; } -@media (max-width: 480px) { - /* Small mobile styles */ - .container { - padding: 0 0.75rem; - } - - .profile-header-card, - .profile-tabs, - .tabs-content { - padding: 1rem; - border-radius: 12px; - } - - .card-header, - .card-content { - padding: 1rem; - } - - .photo-grid { - grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); - gap: 0.5rem; - } - - .avatar-container { - width: 70px; - height: 70px; - } - - .user-name { - font-size: 1.2rem; - } - - .stat-item { - min-width: 80px; - } - - .upload-features { - gap: 1rem; - } - - .upload-icon-bg { - width: 60px; - height: 60px; - } - - .upload-pulse { - width: 60px; - height: 60px; - } - - .upload-icon { - font-size: 1.7rem; - } +.preference-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + background: rgba(248, 249, 250, 0.5); + border-radius: 8px; + border: 1px solid rgba(0, 0, 0, 0.05); } -/* Темная тема заменена на светлую */ -@media (prefers-color-scheme: dark) { - .profile-header-card, - .profile-tabs, - .tabs-content, - .info-card { - background: rgba(255, 255, 255, 0.9); - border-color: rgba(0, 0, 0, 0.05); - } - - .user-name, - .stat-value, - .tab-btn.active, - .card-header h3 { - color: #333; - } - - .user-email, - .stat-label, - .tab-btn, - .bio-text, - .info-item span { - color: #6c757d; - } - - .info-item label { - color: #6c757d; - } - - .card-header, - .stats-section { - background: rgba(248, 249, 250, 0.8); - } - - .form-input, .form-textarea { - background: #ffffff; - border-color: #ced4da; - color: #333; - } - - .form-input:focus, .form-textarea:focus { - border-color: #667eea; - box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2); - } - - .form-input:disabled, .form-textarea:disabled { - background: #f8f9fa; - } - - .upload-zone { - background: rgba(248, 249, 250, 0.5); - border-color: #dee2e6; - } - - .upload-title { - color: #333; - } - - .upload-description, - .upload-hint small, - .feature-item span { - color: #6c757d; - } - - .delete-modal, - .dropdown { - background: #ffffff; - } - - .dropdown-option:hover { - background: rgba(102, 126, 234, 0.1); - } - - .modal-header, - .modal-actions { - background: #f8f9fa; - } - - .action-btn.secondary { - background: #f8f9fa; - border-color: #ced4da; - color: #333; - } - - .action-btn.secondary:hover { - background: #e9ecef; - } - - .main-badge { - background: rgba(255, 255, 255, 0.85); - color: #333; - } +.preference-label { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; + font-weight: 500; + color: #333; } -/* Print Styles */ -@media print { - .profile-header, - .header-actions, - .action-btn, - .photo-actions, - .modal-overlay { - display: none !important; - } - - .profile-content { - padding: 0; - } - - .profile-card, - .info-card { - box-shadow: none; - border: 1px solid #000; - } +.preference-label i { + color: #667eea; + font-size: 1rem; +} + +.preference-value { + font-size: 0.9rem; + color: #6c757d; + font-weight: 500; +} + +.city-name { + font-size: 0.8rem; + color: #667eea; +} + +.allowed-cities { + font-size: 0.8rem; + color: #667eea; +} + +.preferences-tip { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + background: rgba(102, 126, 234, 0.1); + border-radius: 8px; + margin-top: 1rem; + border: 1px solid rgba(102, 126, 234, 0.2); +} + +.preferences-tip i { + color: #667eea; + font-size: 1rem; +} + +.preferences-tip span { + font-size: 0.85rem; + color: #667eea; + font-weight: 500; +} + +.empty-preferences { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 2rem 1rem; +} + +.empty-icon { + width: 60px; + height: 60px; + border-radius: 50%; + background: rgba(102, 126, 234, 0.1); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1rem; +} + +.empty-icon i { + font-size: 1.5rem; + color: #667eea; +} + +.empty-preferences h4 { + margin: 0 0 0.5rem; + font-size: 1.1rem; + color: #333; + font-weight: 600; +} + +.empty-preferences p { + margin: 0 0 1.5rem; + font-size: 0.9rem; + color: #6c757d; + max-width: 300px; }