Reflex/src/components/EditProfileForm.vue
2025-05-22 01:12:13 +07:00

533 lines
22 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 class="edit-profile-form mt-3">
<h4>Редактировать профиль</h4>
<form @submit.prevent="handleSubmit">
<div class="mb-3">
<label for="editName" class="form-label">Имя:</label>
<input type="text" class="form-control" id="editName" v-model="formData.name" :disabled="profileLoading" />
</div>
<div class="mb-3">
<label for="editBio" class="form-label">О себе:</label>
<textarea class="form-control" id="editBio" rows="3" v-model="formData.bio" :disabled="profileLoading"></textarea>
</div>
<div class="mb-3">
<label for="editDateOfBirth" class="form-label">Дата рождения:</label>
<input type="date" class="form-control" id="editDateOfBirth" v-model="formData.dateOfBirth" :disabled="profileLoading" />
</div>
<div class="mb-3">
<label for="editGender" class="form-label">Пол:</label>
<select class="form-select" id="editGender" v-model="formData.gender" :disabled="profileLoading">
<option value="">Не выбрано</option>
<option value="male">Мужской</option>
<option value="female">Женский</option>
<option value="other">Другой</option>
</select>
</div>
<!-- Улучшенное поле для выбора города -->
<div class="mb-3 city-select-container">
<label for="editCity" class="form-label">Город:</label>
<div class="input-group">
<input
type="text"
class="form-control"
id="editCity"
v-model="citySearchQuery"
placeholder="Начните вводить название города..."
@focus="showCityList = true"
@input="onCitySearch"
:disabled="profileLoading"
/>
<span class="input-group-text" @click="clearCitySelection" v-if="formData.location.city">
<i class="bi bi-x-circle"></i>
</span>
</div>
<div v-if="showCityList && filteredCities.length" class="city-dropdown">
<div class="search-results-info" v-if="filteredCities.length">
Найдено городов: {{ filteredCities.length }}
</div>
<div class="city-list-container">
<div
v-for="city in filteredCities"
:key="city.name"
class="city-item"
@click="selectCity(city)"
>
<div class="city-name">{{ city.name }}</div>
<div class="city-region">{{ city.subject }}</div>
</div>
</div>
</div>
<div v-else-if="showCityList && citySearchQuery && !filteredCities.length" class="no-results">
Городов не найдено
</div>
<div v-if="formData.location.city" class="selected-city mt-2">
<small>Выбранный город: <strong>{{ formData.location.city }}</strong></small>
</div>
<input type="hidden" v-model="formData.location.city" />
</div>
<!-- Конец улучшенного поля для выбора города -->
<div v-if="profileSuccessMessage" class="alert alert-success">{{ profileSuccessMessage }}</div>
<div v-if="profileErrorMessage" class="alert alert-danger">{{ profileErrorMessage }}</div>
<button type="submit" class="btn btn-primary" :disabled="profileLoading">
<span v-if="profileLoading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
{{ profileLoading ? 'Сохранение...' : 'Сохранить изменения' }}
</button>
</form>
<hr class="my-4">
<h4>Загрузить фото профиля</h4>
<form @submit.prevent="handleMultiplePhotoUpload" class="mt-3">
<div class="mb-3">
<label for="profilePhotos" class="form-label">Выберите фото:</label>
<input type="file" class="form-control" id="profilePhotos" @change="onFilesSelected" accept="image/*" multiple :disabled="photoLoading">
</div>
<div v-if="previewUrl" class="mb-3">
<p>Предпросмотр:</p>
<img :src="previewUrl" alt="Предпросмотр фото" class="img-thumbnail" style="max-width: 200px; max-height: 200px;" />
</div>
<div v-if="photoUploadSuccessMessage" class="alert alert-success">{{ photoUploadSuccessMessage }}</div>
<div v-if="photoUploadErrorMessage" class="alert alert-danger">{{ photoUploadErrorMessage }}</div>
<div v-for="(file, index) in selectedFiles" :key="index" class="upload-message" :class="file.status">
{{ file.message }}
</div>
<button type="submit" class="btn btn-secondary" :disabled="photoLoading || selectedFiles.length === 0">
<span v-if="photoLoading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
{{ photoLoading ? 'Загрузка...' : 'Загрузить фото' }}
</button>
</form>
</div>
</template>
<script setup>
import { ref, onMounted, watch, computed, nextTick } from 'vue';
import { useAuth } from '@/auth';
import api from '@/services/api';
import imageCompression from 'browser-image-compression';
import russianCities from '@/assets/russian-cities.json'; // Импортируем список городов
const { user, fetchUser } = useAuth();
const formData = ref({
name: '',
bio: '',
dateOfBirth: '',
gender: '',
location: {
city: '',
country: 'Россия'
}
});
// Для выбора города
const allCities = ref([]);
const citySearchQuery = ref('');
const showCityList = ref(false);
const selectedCity = ref(null);
const searchTimeout = ref(null);
const profileLoading = ref(false);
const profileErrorMessage = ref('');
const profileSuccessMessage = ref('');
const photoLoading = ref(false);
const photoUploadErrorMessage = ref('');
const photoUploadSuccessMessage = ref('');
const selectedFile = ref(null); // Это будет использоваться для одиночной загрузки, если мы решим ее оставить
const previewUrl = ref(null); // И это
// Новые refs для множественной загрузки
const selectedFiles = ref([]); // Массив выбранных файлов
const photoUploadMessages = ref([]); // Массив сообщений о статусе загрузки каждого файла
// Улучшенный поиск с дебаунсингом
const onCitySearch = () => {
if (searchTimeout.value) {
clearTimeout(searchTimeout.value);
}
// Если поле поиска пустое и нет выбранного города, сбрасываем город
if (!citySearchQuery.value && formData.value.location.city) {
formData.value.location.city = '';
}
// Показываем список только если есть текст для поиска
if (citySearchQuery.value) {
showCityList.value = true;
}
// Добавляем небольшую задержку для улучшения производительности
searchTimeout.value = setTimeout(() => {
// Если значение в поле поиска не соответствует выбранному городу,
// считаем, что пользователь начал новый поиск
if (selectedCity.value && citySearchQuery.value !== selectedCity.value.name) {
selectedCity.value = null;
}
}, 300);
};
const filteredCities = computed(() => {
if (!citySearchQuery.value) {
return allCities.value.slice(0, 10); // Возвращаем первые 10 городов, если поле пустое
}
const query = citySearchQuery.value.toLowerCase().trim();
// Сначала находим города, начинающиеся с запроса
const startsWithMatch = allCities.value.filter(city =>
city.name.toLowerCase().startsWith(query)
);
// Затем города, содержащие запрос где-то в середине
const containsMatch = allCities.value.filter(city =>
!city.name.toLowerCase().startsWith(query) &&
city.name.toLowerCase().includes(query)
);
// Объединяем результаты, сначала точные совпадения, затем частичные
return [...startsWithMatch, ...containsMatch].slice(0, 15);
});
const selectCity = (city) => {
selectedCity.value = city;
formData.value.location.city = city.name;
citySearchQuery.value = city.name;
showCityList.value = false;
};
const clearCitySelection = () => {
selectedCity.value = null;
formData.value.location.city = '';
citySearchQuery.value = '';
};
// Улучшенная логика обработки кликов вне списка
const handleClickOutsideCityList = (event) => {
const cityInput = document.getElementById('editCity');
const cityDropdown = document.querySelector('.city-dropdown');
if (!cityInput || !event.target) return;
const clickedOnInput = cityInput.contains(event.target);
const clickedOnDropdown = cityDropdown && cityDropdown.contains(event.target);
if (showCityList.value && !clickedOnInput && !clickedOnDropdown) {
// Если есть выбранный город, восстанавливаем его имя в поле поиска
if (selectedCity.value) {
citySearchQuery.value = selectedCity.value.name;
}
showCityList.value = false;
}
};
const initializeFormData = (currentUser) => {
console.log('[EditProfileForm] Инициализация формы с данными:', currentUser);
if (currentUser) {
formData.value.name = currentUser.name || '';
formData.value.bio = currentUser.bio || '';
formData.value.dateOfBirth = currentUser.dateOfBirth ? new Date(currentUser.dateOfBirth).toISOString().split('T')[0] : '';
formData.value.gender = currentUser.gender || '';
if (currentUser.location) {
formData.value.location.city = currentUser.location.city || '';
citySearchQuery.value = currentUser.location.city || '';
formData.value.location.country = currentUser.location.country || 'Россия';
// Если есть выбранный город, находим его в списке
if (currentUser.location.city) {
selectedCity.value = allCities.value.find(city =>
city.name === currentUser.location.city
) || null;
}
}
}
};
onMounted(async () => {
console.log('[EditProfileForm] Компонент смонтирован');
// Загружаем список городов и сортируем по имени
allCities.value = russianCities.sort((a, b) => a.name.localeCompare(b.name));
// Инициализируем данные формы
nextTick(() => {
initializeFormData(user.value);
});
document.addEventListener('click', handleClickOutsideCityList);
});
// Добавляем onUnmounted для удаления слушателя
import { onUnmounted } from 'vue';
onUnmounted(() => {
document.removeEventListener('click', handleClickOutsideCityList);
});
watch(user, (newUser) => {
console.log('[EditProfileForm] Пользователь изменился:', newUser);
initializeFormData(newUser);
}, { deep: true });
const handleSubmit = async () => {
console.log('[EditProfileForm] Отправка формы профиля...');
profileLoading.value = true;
profileErrorMessage.value = '';
profileSuccessMessage.value = '';
try {
const dataToUpdate = {
name: formData.value.name,
bio: formData.value.bio,
dateOfBirth: formData.value.dateOfBirth || null,
gender: formData.value.gender || null,
location: {
city: formData.value.location.city,
country: formData.value.location.country
}
};
console.log('[EditProfileForm] Данные для обновления профиля:', dataToUpdate);
const response = await api.updateUserProfile(dataToUpdate);
console.log('[EditProfileForm] Ответ от сервера (профиль):', response.data);
profileSuccessMessage.value = response.data.message || 'Профиль успешно обновлен!';
await fetchUser();
console.log('[EditProfileForm] Данные пользователя обновлены (профиль)');
} catch (err) {
console.error('[EditProfileForm] Ошибка при обновлении профиля:', err);
profileErrorMessage.value = (err.response && err.response.data && err.response.data.message)
? err.response.data.message
: 'Ошибка при обновлении профиля.';
} finally {
profileLoading.value = false;
}
};
// Вместо onFileSelected для одного файла, создадим onFilesSelected для нескольких
const onFilesSelected = (event) => {
const files = event.target.files;
if (files && files.length > 0) {
selectedFiles.value = Array.from(files); // Сохраняем массив файлов
photoUploadMessages.value = selectedFiles.value.map(file => ({ // Инициализируем сообщения для каждого файла
name: file.name,
status: 'pending', // pending, uploading, success, error
message: 'Ожидает загрузки...'
}));
// Очищаем старые одиночные состояния, если они были
selectedFile.value = null;
previewUrl.value = null;
photoUploadErrorMessage.value = '';
photoUploadSuccessMessage.value = '';
} else {
selectedFiles.value = [];
photoUploadMessages.value = [];
}
};
// Обновляем handlePhotoUpload для загрузки нескольких файлов последовательно
const handleMultiplePhotoUpload = async () => {
if (selectedFiles.value.length === 0) {
photoUploadErrorMessage.value = 'Пожалуйста, выберите файлы для загрузки.';
// Очистим индивидуальные сообщения, если они были
photoUploadMessages.value.forEach(msg => {
if (msg.status === 'pending') msg.status = 'cancelled';
});
return;
}
photoLoading.value = true; // Общий индикатор загрузки
photoUploadErrorMessage.value = ''; // Сбрасываем общую ошибку
for (let i = 0; i < selectedFiles.value.length; i++) {
const originalFile = selectedFiles.value[i];
const fileMessageIndex = photoUploadMessages.value.findIndex(m => m.name === originalFile.name && m.status !== 'success');
if (fileMessageIndex === -1) continue;
photoUploadMessages.value[fileMessageIndex].status = 'uploading';
photoUploadMessages.value[fileMessageIndex].message = 'Сжатие и загрузка...';
try {
// Опции сжатия
const options = {
maxSizeMB: 1, // Максимальный размер файла в MB
maxWidthOrHeight: 1920, // Максимальная ширина или высота
useWebWorker: true, // Использовать Web Worker для лучшей производительности
// Дополнительные опции можно найти в документации библиотеки
// Например, initialQuality для JPEG
}
console.log(`[EditProfileForm] Сжатие файла ${originalFile.name}...`);
const compressedFile = await imageCompression(originalFile, options);
console.log(`[EditProfileForm] Файл ${originalFile.name} сжат. Оригинальный размер: ${(originalFile.size / 1024 / 1024).toFixed(2)} MB, Новый размер: ${(compressedFile.size / 1024 / 1024).toFixed(2)} MB`);
const fd = new FormData();
// Важно: сервер ожидает имя файла, поэтому передаем его как третий аргумент
fd.append('profilePhoto', compressedFile, originalFile.name);
console.log(`[EditProfileForm] Отправка файла ${originalFile.name} (сжатого) на сервер...`);
const response = await api.uploadUserProfilePhoto(fd);
console.log(`[EditProfileForm] Ответ от сервера (фото ${originalFile.name}):`, response.data);
photoUploadMessages.value[fileMessageIndex].status = 'success';
photoUploadMessages.value[fileMessageIndex].message = response.data.message || 'Фото успешно загружено!';
await fetchUser();
console.log(`[EditProfileForm] Данные пользователя обновлены (фото ${originalFile.name})`);
} catch (err) {
console.error(`[EditProfileForm] Ошибка при сжатии или загрузке фото ${originalFile.name}:`, err);
photoUploadMessages.value[fileMessageIndex].status = 'error';
// Проверяем, является ли ошибка ошибкой сжатия
if (err instanceof Error && err.message.includes('compression')) {
photoUploadMessages.value[fileMessageIndex].message = `Ошибка при сжатии фото ${originalFile.name}.`;
} else {
photoUploadMessages.value[fileMessageIndex].message = (err.response && err.response.data && err.response.data.message)
? err.response.data.message
: `Ошибка при загрузке фото ${originalFile.name}.`;
}
}
}
// После завершения всех загрузок
selectedFiles.value = []; // Очищаем выбранные файлы
// Не очищаем photoInput.value здесь, т.к. input type="file" multiple сам управляет списком
// Можно сбросить значение инпута, чтобы пользователь мог выбрать те же файлы снова, если захочет
const photoInput = document.getElementById('profilePhotos'); // Убедимся, что ID правильный
if (photoInput) {
photoInput.value = '';
}
photoLoading.value = false; // Выключаем общий индикатор
};
</script>
<style scoped>
@import url('https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css');
.edit-profile-form {
background-color: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
/* Стили для выбора города */
.city-select-container {
position: relative;
}
.city-dropdown {
position: absolute;
top: calc(100% + 1px); /* Расположение сразу под полем ввода */
left: 0;
right: 0;
z-index: 1050; /* Повышаем z-index, чтобы список был всегда поверх других элементов */
background-color: white;
border: 1px solid #dee2e6;
border-radius: 0 0 5px 5px;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
max-height: 350px;
overflow: hidden;
display: flex;
flex-direction: column;
width: 100%; /* Гарантируем полную ширину */
}
.no-results {
position: absolute;
top: calc(100% + 1px); /* Расположение сразу под полем ввода */
left: 0;
right: 0;
z-index: 1050;
padding: 10px 15px;
color: #dc3545;
text-align: center;
border: 1px solid #dee2e6;
border-top: none;
border-radius: 0 0 5px 5px;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
background-color: white;
width: 100%; /* Гарантируем полную ширину */
}
.search-results-info {
padding: 8px 12px;
font-size: 0.9em;
color: #6c757d;
border-bottom: 1px solid #dee2e6;
background-color: #f8f9fa;
}
.city-list-container {
overflow-y: auto;
max-height: 300px;
padding: 5px 0;
}
.city-item {
padding: 8px 15px;
cursor: pointer;
transition: background-color 0.2s;
display: flex;
justify-content: space-between;
align-items: center;
}
.city-item:hover {
background-color: #e9ecef;
}
.city-name {
font-weight: 500;
}
.city-region {
font-size: 0.85em;
color: #6c757d;
}
.selected-city {
color: #495057;
}
/* Кнопка очистки */
.input-group-text {
cursor: pointer;
background-color: transparent;
}
.input-group-text:hover {
background-color: #f0f0f0;
}
.img-thumbnail {
border: 1px solid #dee2e6;
padding: 0.25rem;
background-color: #fff;
border-radius: 0.25rem;
}
.upload-message {
font-size: 0.9em;
}
.upload-message.success {
color: green;
}
.upload-message.error {
color: red;
}
.upload-message.pending, .upload-message.uploading {
color: orange;
}
</style>