modified: serv_nginx/api_bb/internal/handlers/user.go

modified:   serv_nginx/api_bb/internal/repository/user_repository.go
	modified:   serv_nginx/api_bb/internal/service/user_service.go
	modified:   serv_nginx/bbvue/src/views/Members.vue
set new page for members frontend
add new rounter path getAllUsers into backend
This commit is contained in:
2025-10-17 01:57:50 +05:00
parent 509704e9ba
commit a145986fe9
4 changed files with 478 additions and 395 deletions
+38 -1
View File
@@ -35,7 +35,7 @@ func (h *UserHandler) Routes() chi.Router {
r.Get("/profile", h.GetProfile)
r.Post("/editProfile", h.UpdateProfile)
// Убрали маршрут для обслуживания аватаров - теперь это делает AvatarHandler
r.Get("/", h.GetUsers) // 👈 ДОБАВЛЯЕМ НОВЫЙ ЭНДПОЙНТ
return r
}
@@ -54,6 +54,43 @@ type UserResponse struct {
UpdatedAt time.Time `json:"updated_at"`
}
// GetUsers возвращает список всех пользователей
func (h *UserHandler) GetUsers(w http.ResponseWriter, r *http.Request) {
h.logger.Info("handling get users request",
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
zap.String("remote_addr", r.RemoteAddr),
)
// Получаем пользователя из контекста для проверки аутентификации
_, ok := middleware.GetUserFromContext(r.Context())
if !ok {
h.logger.Warn("get users failed - authentication required")
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
return
}
// Получаем список пользователей из сервиса
users, err := h.userService.GetAllUsers()
if err != nil {
h.logger.Error("failed to get users from service", zap.Error(err))
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get users: "+err.Error())
return
}
// Преобразуем в response формат
var userResponses []UserResponse
for _, user := range users {
userResponses = append(userResponses, toUserResponse(&user))
}
h.logger.Info("users list retrieved successfully",
zap.Int("users_count", len(userResponses)),
)
utils.RespondWithJSON(w, http.StatusOK, userResponses)
}
func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
h.logger.Info("handling get profile request",
@@ -14,18 +14,19 @@ type UserRepository interface {
Update(user *models.User) error
Delete(id uint) error
UpdateExcludeEmail(userUpdate *models.User) error
UpdateAvatar(userID uint, avatarPath string) error
UpdateAvatar(userID uint, avatarPath string) error
FindAll() ([]models.User, error)
}
func (r *userRepository) UpdateAvatar(userID uint, avatarPath string) error {
result := r.db.Model(&models.User{}).Where("id = ?", userID).Update("avatar", avatarPath)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("user not found")
}
return nil
result := r.db.Model(&models.User{}).Where("id = ?", userID).Update("avatar", avatarPath)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("user not found")
}
return nil
}
type userRepository struct {
@@ -36,6 +37,13 @@ func NewUserRepository(db *gorm.DB) UserRepository {
return &userRepository{db: db}
}
// Add to userRepository implementation
func (r *userRepository) FindAll() ([]models.User, error) {
var users []models.User
err := r.db.Find(&users).Error
return users, err
}
func (r *userRepository) Create(user *models.User) error {
return r.db.Create(user).Error
}
@@ -13,6 +13,7 @@ import (
type UserService interface {
GetUserProfile(userID uint) (*models.User, error)
UpdateProfile(user *models.User) error
GetAllUsers() ([]models.User, error)
}
type userService struct {
@@ -37,6 +38,23 @@ func NewUserService(userRepo repository.UserRepository, jwtService JWTService, l
}
}
func (s *userService) GetAllUsers() ([]models.User, error) {
s.logger.Info("Fetching all users")
users, err := s.userRepo.FindAll()
if err != nil {
s.logger.Error("Failed to fetch users",
zap.Error(err),
)
return nil, fmt.Errorf("failed to get users: %w", err)
}
s.logger.Debug("Successfully fetched users",
zap.Int("count", len(users)),
)
return users, nil
}
func (s *authService) UpdateProfile(user *models.User) error {
s.logger.Info("Updating user profile",
zap.Uint("user_id", user.ID),
+373 -353
View File
@@ -8,19 +8,19 @@
<p class="hero-subtitle">Более 150 участников объединены любовью к бегу и стремлением к новым достижениям</p>
<div class="hero-stats">
<div class="stat">
<div class="stat-number">{{ totalMembers }}</div>
<div class="stat-number">{{ stats.totalMembers }}</div>
<div class="stat-label">Участников</div>
</div>
<div class="stat">
<div class="stat-number">{{ activeMembers }}</div>
<div class="stat-number">{{ stats.activeMembers }}</div>
<div class="stat-label">Активных</div>
</div>
<div class="stat">
<div class="stat-number">{{ citiesCount }}</div>
<div class="stat-number">{{ stats.citiesCount }}</div>
<div class="stat-label">Городов</div>
</div>
<div class="stat">
<div class="stat-number">{{ totalDistance }}</div>
<div class="stat-number">{{ stats.totalDistance }}</div>
<div class="stat-label">Км за год</div>
</div>
</div>
@@ -38,21 +38,16 @@
<div class="members-controls">
<!-- Поиск -->
<div class="search-box">
<input
v-model="searchQuery"
type="text"
placeholder="🔍 Поиск по имени или городу..."
class="search-input"
@input="handleSearch"
>
<input v-model="searchQuery" type="text" placeholder="🔍 Поиск по имени, фамилии или email..."
class="search-input" @input="handleSearch">
</div>
<!-- Фильтры -->
<div class="filters-container">
<div class="filter-group">
<label for="level">Уровень:</label>
<select id="level" v-model="filters.level" @change="applyFilters">
<option value="all">Все уровни</option>
<label for="experience">Опыт:</label>
<select id="experience" v-model="filters.experience" @change="applyFilters">
<option value="all">Любой опыт</option>
<option value="beginner">Начинающий</option>
<option value="intermediate">Любитель</option>
<option value="advanced">Опытный</option>
@@ -61,38 +56,25 @@
</div>
<div class="filter-group">
<label for="distance">Дистанция:</label>
<select id="distance" v-model="filters.distance" @change="applyFilters">
<option value="all">Все дистанции</option>
<option value="5km">5 км</option>
<option value="10km">10 км</option>
<option value="half">Полумарафон</option>
<option value="marathon">Марафон</option>
<option value="ultra">Ультра</option>
</select>
</div>
<div class="filter-group">
<label for="city">Город:</label>
<select id="city" v-model="filters.city" @change="applyFilters">
<option value="all">Все города</option>
<option v-for="city in cities" :key="city" :value="city">{{ city }}</option>
<label for="goals">Цели:</label>
<select id="goals" v-model="filters.goals" @change="applyFilters">
<option value="all">Все цели</option>
<option value="5км">5 км</option>
<option value="10км">10 км</option>
<option value="полумарафон">Полумарафон</option>
<option value="марафон">Марафон</option>
<option value="здоровье">Здоровье</option>
<option value="похудение">Похудение</option>
</select>
</div>
<div class="view-toggle">
<button
:class="['view-btn', { 'active': viewMode === 'grid' }]"
@click="viewMode = 'grid'"
title="Сетка"
>
<button :class="['view-btn', { 'active': viewMode === 'grid' }]" @click="viewMode = 'grid'"
title="Сетка">
</button>
<button
:class="['view-btn', { 'active': viewMode === 'list' }]"
@click="viewMode = 'list'"
title="Список"
>
<button :class="['view-btn', { 'active': viewMode === 'list' }]" @click="viewMode = 'list'"
title="Список">
</button>
</div>
@@ -103,126 +85,96 @@
<!-- Статистика фильтров -->
<div class="filter-stats" v-if="showFilterStats">
<p>
Показано {{ filteredMembers.length }} из {{ totalMembers }} участников
Показано {{ filteredMembers.length }} из {{ members.length }} участников
<span v-if="searchQuery"> по запросу "{{ searchQuery }}"</span>
<button class="clear-filters" @click="clearFilters"> Очистить фильтры</button>
</p>
</div>
<!-- Загрузка -->
<div class="loading-state" v-if="loading">
<div class="loading-spinner"></div>
<p>Загружаем участников...</p>
</div>
<!-- Ошибка -->
<div class="error-state" v-else-if="error">
<div class="error-icon"></div>
<h3>Ошибка загрузки</h3>
<p>{{ error }}</p>
<button class="btn btn-primary" @click="loadMembers">
🔄 Попробовать снова
</button>
</div>
<!-- Сетка участников -->
<div class="members-container" :class="viewMode">
<div
v-for="member in paginatedMembers"
:key="member.id"
class="member-card"
:class="member.level"
>
<div class="members-container" :class="viewMode" v-else-if="filteredMembers.length > 0">
<div v-for="member in paginatedMembers" :key="member.id" class="member-card"
:class="getExperienceClass(member.experience)">
<div class="member-avatar">
<img
:src="member.avatar"
:alt="member.name"
class="avatar-img"
@error="handleImageError"
>
<div class="online-indicator" v-if="member.isOnline"></div>
<div class="level-badge" :class="member.level">
{{ getLevelLabel(member.level) }}
<img :src="getAvatarUrl(member.avatar)" :alt="member.firstName + ' ' + member.lastName" class="avatar-img"
@error="handleImageError">
<div class="level-badge" :class="getExperienceClass(member.experience)">
{{ getExperienceLabel(member.experience) }}
</div>
</div>
<div class="member-info">
<h3 class="member-name">{{ member.name }}</h3>
<p class="member-city">📍 {{ member.city }}</p>
<h3 class="member-name">{{ member.firstName }} {{ member.lastName }}</h3>
<p class="member-email">📧 {{ member.email }}</p>
<div class="member-stats">
<div class="stat-item">
<span class="stat-icon">🏃</span>
<span class="stat-value">{{ member.weeklyDistance }} км/нед</span>
<div class="stat-item" v-if="member.phone">
<span class="stat-icon">📱</span>
<span class="stat-value">{{ member.phone }}</span>
</div>
<div class="stat-item">
<span class="stat-icon">📅</span>
<span class="stat-value">{{ member.memberSince }}</span>
<span class="stat-value">Участник с {{ formatDate(member.createdAt) }}</span>
</div>
</div>
<div class="member-achievements">
<div class="achievement" v-if="member.bestDistance">
<div class="achievement" v-if="member.experience">
<span class="achievement-icon">🎯</span>
<span class="achievement-text">{{ member.bestDistance }}</span>
<span class="achievement-text">Опыт: {{ getExperienceLabel(member.experience) }}</span>
</div>
<div class="achievement" v-if="member.pb">
<div class="achievement" v-if="member.goals">
<span class="achievement-icon"></span>
<span class="achievement-text">PB: {{ member.pb }}</span>
<span class="achievement-text">Цели: {{ truncateText(member.goals, 30) }}</span>
</div>
</div>
<div class="member-interests">
<span
v-for="interest in member.interests.slice(0, 2)"
:key="interest"
class="interest-tag"
>
{{ interest }}
<div class="member-interests" v-if="member.goals || member.newsletter">
<span class="interest-tag" v-if="member.goals">
{{ getGoalCategory(member.goals) }}
</span>
<span
v-if="member.interests.length > 2"
class="interest-more"
:title="member.interests.slice(2).join(', ')"
>
+{{ member.interests.length - 2 }}
<span class="interest-tag" v-if="member.newsletter">
📰 Рассылка
</span>
</div>
<div class="member-actions">
<button
class="btn-profile"
@click="viewProfile(member)"
>
<button class="btn-profile" @click="viewProfile(member)">
👤 Профиль
</button>
<button
v-if="member.telegram"
class="btn-contact"
@click="contactMember(member)"
title="Написать в Telegram"
>
📱
</button>
</div>
</div>
</div>
</div>
<!-- Пагинация -->
<div class="pagination" v-if="totalPages > 1">
<button
class="pagination-btn"
:disabled="currentPage === 1"
@click="changePage(currentPage - 1)"
>
</button>
<button
v-for="page in visiblePages"
:key="page"
:class="['pagination-btn', { 'active': currentPage === page }]"
@click="changePage(page)"
>
{{ page }}
</button>
<button
class="pagination-btn"
:disabled="currentPage === totalPages"
@click="changePage(currentPage + 1)"
>
</button>
<!-- Пустой результат -->
<div class="empty-state" v-else-if="!loading && members.length === 0">
<div class="empty-icon">👥</div>
<h3>Пока нет участников</h3>
<p>Будьте первым, кто присоединится к нашему клубу!</p>
<router-link to="/register" class="btn btn-primary">
👥 Вступить в клуб
</router-link>
</div>
<!-- Пустой результат -->
<div class="empty-state" v-if="filteredMembers.length === 0">
<!-- Пустой результат после фильтрации -->
<div class="empty-state" v-else-if="!loading && filteredMembers.length === 0">
<div class="empty-icon">🔍</div>
<h3>Участники не найдены</h3>
<p>Попробуйте изменить параметры поиска или фильтры</p>
@@ -230,6 +182,22 @@
🗑 Сбросить фильтры
</button>
</div>
<!-- Пагинация -->
<div class="pagination" v-if="totalPages > 1 && !loading && !error">
<button class="pagination-btn" :disabled="currentPage === 1" @click="changePage(currentPage - 1)">
</button>
<button v-for="page in visiblePages" :key="page"
:class="['pagination-btn', { 'active': currentPage === page }]" @click="changePage(page)">
{{ page }}
</button>
<button class="pagination-btn" :disabled="currentPage === totalPages" @click="changePage(currentPage + 1)">
</button>
</div>
</div>
</section>
@@ -241,29 +209,29 @@
<div class="stat-card">
<div class="stat-icon">🏃</div>
<div class="stat-content">
<div class="stat-number">15,247</div>
<div class="stat-label">Км пробежали на этой неделе</div>
<div class="stat-number">{{ stats.totalMembers }}</div>
<div class="stat-label">Участников в клубе</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">🎯</div>
<div class="stat-content">
<div class="stat-number">42</div>
<div class="stat-label">Личных рекорда за месяц</div>
<div class="stat-number">{{ stats.advancedMembers }}</div>
<div class="stat-label">Опытных бегунов</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">👥</div>
<div class="stat-content">
<div class="stat-number">28</div>
<div class="stat-label">Совместных тренировок в неделю</div>
<div class="stat-number">{{ stats.newsletterSubscribers }}</div>
<div class="stat-label">Подписчиков рассылки</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">🏆</div>
<div class="stat-icon">📈</div>
<div class="stat-content">
<div class="stat-number">156</div>
<div class="stat-label">Медалей на соревнованиях</div>
<div class="stat-number">{{ stats.activeThisMonth }}</div>
<div class="stat-label">Активных в этом месяце</div>
</div>
</div>
</div>
@@ -284,7 +252,8 @@
</div>
<div class="story-content">
<h4>Анна, участник 1 год</h4>
<p>"Пришла с нулевым опытом, сейчас готовлюсь к марафону. Благодаря поддержке клуба нашла друзей и полюбила бег!"</p>
<p>"Пришла с нулевым опытом, сейчас готовлюсь к марафону. Благодаря поддержке клуба нашла друзей и
полюбила бег!"</p>
<div class="story-stats">
<span>🏃 10км за 48:15</span>
<span>📈 Улучшение +12 мин</span>
@@ -298,7 +267,8 @@
</div>
<div class="story-content">
<h4>Данил, участник 2 года</h4>
<p>"От первых 5 км до ультрамарафона! В клубе нашел не только тренера, но и верных друзей для совместных тренировок."</p>
<p>"От первых 5 км до ультрамарафона! В клубе нашел не только тренера, но и верных друзей для совместных
тренировок."</p>
<div class="story-stats">
<span>🏔 120 км трейл</span>
<span> 5+ личных рекордов</span>
@@ -362,90 +332,62 @@
<div class="profile-modal" v-if="selectedMember">
<div class="profile-header">
<div class="profile-avatar">
<img :src="selectedMember.avatar" :alt="selectedMember.name">
<div class="online-indicator large" v-if="selectedMember.isOnline"></div>
<img :src="getAvatarUrl(selectedMember.avatar)"
:alt="selectedMember.firstName + ' ' + selectedMember.lastName">
</div>
<div class="profile-info">
<h2>{{ selectedMember.name }}</h2>
<p class="profile-city">📍 {{ selectedMember.city }}</p>
<h2>{{ selectedMember.firstName }} {{ selectedMember.lastName }}</h2>
<p class="profile-email">📧 {{ selectedMember.email }}</p>
<div class="profile-badges">
<span class="level-badge" :class="selectedMember.level">
{{ getLevelLabel(selectedMember.level) }}
<span class="level-badge" :class="getExperienceClass(selectedMember.experience)">
{{ getExperienceLabel(selectedMember.experience) }}
</span>
<span class="member-since">Участник {{ selectedMember.memberSince }}</span>
<span class="member-since">Участник с {{ formatDate(selectedMember.createdAt) }}</span>
</div>
</div>
</div>
<div class="profile-stats">
<div class="stat-item">
<div class="stat-value">{{ selectedMember.weeklyDistance }} км</div>
<div class="stat-label">В неделю</div>
<div class="stat-value">{{ selectedMember.experience ? getExperienceLabel(selectedMember.experience) :
'Неуказан' }}</div>
<div class="stat-label">Уровень опыта</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ selectedMember.totalDistance }} км</div>
<div class="stat-label">Всего с клубом</div>
<div class="stat-value">{{ selectedMember.newsletter ? 'Да' : 'Нет' }}</div>
<div class="stat-label">Подписка на рассылку</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ selectedMember.trainingsCount }}</div>
<div class="stat-label">Тренировок</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ selectedMember.achievementsCount }}</div>
<div class="stat-label">Достижений</div>
<div class="stat-value">{{ selectedMember.role || 'user' }}</div>
<div class="stat-label">Роль в клубе</div>
</div>
</div>
<div class="profile-details">
<div class="detail-section">
<h4>🏆 Лучшие результаты</h4>
<div class="achievements-list">
<div class="achievement-item" v-if="selectedMember.bestDistance">
<span class="achievement-icon">🎯</span>
<span class="achievement-text">{{ selectedMember.bestDistance }}</span>
</div>
<div class="achievement-item" v-if="selectedMember.pb">
<span class="achievement-icon"></span>
<span class="achievement-text">PB: {{ selectedMember.pb }}</span>
</div>
<div class="achievement-item" v-if="selectedMember.recentAchievement">
<span class="achievement-icon">🚀</span>
<span class="achievement-text">{{ selectedMember.recentAchievement }}</span>
</div>
</div>
<div class="detail-section" v-if="selectedMember.phone">
<h4>📱 Контакты</h4>
<p class="contact-info">{{ selectedMember.phone }}</p>
</div>
<div class="detail-section">
<h4>🎯 Интересы</h4>
<div class="interests-list">
<span
v-for="interest in selectedMember.interests"
:key="interest"
class="interest-tag"
>
{{ interest }}
</span>
</div>
<div class="detail-section" v-if="selectedMember.experience">
<h4>🎯 Опыт в беге</h4>
<p class="about-text">{{ selectedMember.experience }}</p>
</div>
<div class="detail-section" v-if="selectedMember.about">
<h4>📖 О себе</h4>
<p class="about-text">{{ selectedMember.about }}</p>
<div class="detail-section" v-if="selectedMember.goals">
<h4> Цели и планы</h4>
<p class="about-text">{{ selectedMember.goals }}</p>
</div>
<div class="detail-section"
v-if="!selectedMember.phone && !selectedMember.experience && !selectedMember.goals">
<h4>📝 Информация</h4>
<p class="about-text">Пользователь еще не заполнил информацию о себе</p>
</div>
</div>
<div class="profile-actions">
<button
v-if="selectedMember.telegram"
class="btn btn-primary"
@click="contactMember(selectedMember)"
>
📱 Написать в Telegram
</button>
<button
class="btn btn-outline"
@click="suggestTraining(selectedMember)"
>
<button class="btn btn-outline" @click="suggestTraining(selectedMember)">
🏃 Предложить тренировку
</button>
</div>
@@ -456,6 +398,8 @@
</template>
<script>
import { apiClient } from '../stores/helpers/api';
export default {
// eslint-disable-next-line vue/multi-word-component-names
name: 'Members',
@@ -463,9 +407,8 @@ export default {
return {
searchQuery: '',
filters: {
level: 'all',
distance: 'all',
city: 'all'
experience: 'all',
goals: 'all'
},
viewMode: 'grid',
currentPage: 1,
@@ -473,155 +416,44 @@ export default {
showProfileModal: false,
selectedMember: null,
searchTimeout: null,
members: [
// Здесь будет большой массив участников (для примера 24 участника)
{
id: 1,
name: 'Сергей',
city: 'Уфа',
level: 'professional',
avatar: 'https://via.placeholder.com/120/2e8b57/ffffff?text=СИ',
weeklyDistance: 65,
totalDistance: 2450,
memberSince: '2 года',
bestDistance: 'Марафон 3:27:49',
pb: '10км - 36:52',
recentAchievement: 'I место РосХим 2025',
interests: ['Марафоны', 'Трейлраннинг', 'Силовые тренировки'],
isOnline: true,
telegram: '@sergei_runner',
trainingsCount: 156,
achievementsCount: 23,
about: 'Бегаю с 2020 года. Участвую в марафонах и ультразабегах. Люблю горные трейлы и длительные дистанции.'
},
{
id: 2,
name: 'Ильгам',
city: 'Уфа',
level: 'advanced',
avatar: 'https://via.placeholder.com/120/26734a/ffffff?text=ИХ',
weeklyDistance: 45,
totalDistance: 1800,
memberSince: '1.5 года',
bestDistance: '10км - 37:59',
pb: '5км - 17:45',
interests: ['Спринт', 'Интервальные тренировки', 'Футбол'],
isOnline: false,
telegram: '@ilgam_sprint',
trainingsCount: 98,
achievementsCount: 15
},
// ... остальные 22 участника с аналогичной структурой
// Для демонстрации создам еще несколько
{
id: 3,
name: 'Данил',
city: 'Уфа',
level: 'professional',
avatar: 'https://via.placeholder.com/120/2e8b57/ffffff?text=ДХ',
weeklyDistance: 80,
totalDistance: 3200,
memberSince: '2.5 года',
bestDistance: 'Ультра 120км',
pb: 'Полумарафон 1:30:40',
interests: ['Ультрамарафоны', 'Трейл', 'Горный бег'],
isOnline: true,
telegram: '@danil_ultra',
trainingsCount: 201,
achievementsCount: 31,
about: 'Специализируюсь на ультрамарафонах и трейлраннинге. Помогаю новичкам в подготовке к длительным дистанциям.'
},
{
id: 4,
name: 'Ғаяз',
city: 'Уфа',
level: 'advanced',
avatar: 'https://via.placeholder.com/120/26734a/ffffff?text=ҒВ',
weeklyDistance: 55,
totalDistance: 2100,
memberSince: '1 год',
bestDistance: 'Марафон 3:34:33',
pb: 'Полумарафон 1:31:40',
interests: ['Марафоны', 'Велоспорт', 'Плавание'],
isOnline: true,
telegram: '@gaziz_marathon',
trainingsCount: 112,
achievementsCount: 18
},
{
id: 5,
name: 'Анна',
city: 'Стерлитамак',
level: 'intermediate',
avatar: 'https://via.placeholder.com/120/e74c3c/ffffff?text=АП',
weeklyDistance: 25,
totalDistance: 650,
memberSince: '8 месяцев',
bestDistance: '10км - 53:25',
pb: '5км - 25:13',
interests: ['ОФП', 'Йога', 'Здоровое питание'],
isOnline: false,
telegram: '@anna_health',
trainingsCount: 45,
achievementsCount: 8,
about: 'Начала бегать для здоровья и похудения. За 8 месяцев похудела на 12 кг и полюбила активный образ жизни!'
},
{
id: 6,
name: 'Михаил',
city: 'Уфа',
level: 'beginner',
avatar: 'https://via.placeholder.com/120/3498db/ffffff?text=МК',
weeklyDistance: 15,
totalDistance: 180,
memberSince: '3 месяца',
bestDistance: '5км - 32:15',
interests: ['Начальный уровень', 'Общефизическая подготовка'],
isOnline: true,
trainingsCount: 18,
achievementsCount: 3
}
// ... можно добавить еще участников для демонстрации
]
loading: false,
error: '',
members: [],
stats: {
totalMembers: 0,
activeMembers: 0,
citiesCount: 0,
totalDistance: 0,
advancedMembers: 0,
newsletterSubscribers: 0,
activeThisMonth: 0
}
}
},
computed: {
totalMembers() {
return this.members.length
},
activeMembers() {
return this.members.filter(member => member.isOnline).length
},
citiesCount() {
const cities = new Set(this.members.map(member => member.city))
return cities.size
},
totalDistance() {
return this.members.reduce((sum, member) => sum + member.totalDistance, 0)
},
cities() {
return [...new Set(this.members.map(member => member.city))].sort()
},
filteredMembers() {
let filtered = this.members
// Поиск по имени и городу
// Поиск по имени, фамилии и email
if (this.searchQuery) {
const query = this.searchQuery.toLowerCase()
filtered = filtered.filter(member =>
member.name.toLowerCase().includes(query) ||
member.city.toLowerCase().includes(query)
member.firstName?.toLowerCase().includes(query) ||
member.lastName?.toLowerCase().includes(query) ||
member.email?.toLowerCase().includes(query)
)
}
// Фильтрация по уровню
if (this.filters.level !== 'all') {
filtered = filtered.filter(member => member.level === this.filters.level)
// Фильтрация по опыту
if (this.filters.experience !== 'all') {
filtered = filtered.filter(member => member.experience === this.filters.experience)
}
// Фильтрация по городу
if (this.filters.city !== 'all') {
filtered = filtered.filter(member => member.city === this.filters.city)
// Фильтрация по целям
if (this.filters.goals !== 'all') {
filtered = filtered.filter(member =>
member.goals && member.goals.toLowerCase().includes(this.filters.goals.toLowerCase())
)
}
return filtered
@@ -645,76 +477,193 @@ export default {
},
showFilterStats() {
return this.searchQuery ||
this.filters.level !== 'all' ||
this.filters.distance !== 'all' ||
this.filters.city !== 'all'
this.filters.experience !== 'all' ||
this.filters.goals !== 'all'
}
},
methods: {
getLevelLabel(level) {
getExperienceLabel(experience) {
const labels = {
'beginner': 'Новичок',
'beginner': 'Начинающий',
'intermediate': 'Любитель',
'advanced': 'Опытный',
'professional': 'Профи'
'professional': 'Профессионал'
}
return labels[level] || level
return labels[experience] || experience || 'Не указан'
},
getExperienceClass(experience) {
return experience || 'beginner'
},
getGoalCategory(goals) {
if (!goals) return 'Разные цели'
const goal = goals.toLowerCase()
if (goal.includes('5км') || goal.includes('5 км')) return '5 км'
if (goal.includes('10км') || goal.includes('10 км')) return '10 км'
if (goal.includes('полумарафон')) return 'Полумарафон'
if (goal.includes('марафон')) return 'Марафон'
if (goal.includes('ультра')) return 'Ультрамарафон'
if (goal.includes('здор') || goal.includes('фитнес')) return 'Здоровье'
if (goal.includes('вес') || goal.includes('похуд')) return 'Похудение'
return 'Персональные цели'
},
getAvatarUrl(avatarPath) {
if (!avatarPath) {
return 'https://via.placeholder.com/120/666666/ffffff?text=🏃'
}
// Если avatarPath уже полный URL, возвращаем как есть
if (avatarPath.startsWith('http')) {
return avatarPath
}
// Извлекаем имя файла из пути
const filename = avatarPath.trim('/').split('/').pop();
// Используем API эндпоинт вместо прямого доступа
const fullUrl = `https://begushiybashkir.ru/api/v1/user/avatars/${filename}`;
// Иначе формируем полный URL к аватару через API
return fullUrl
},
formatDate(dateString) {
if (!dateString) return 'недавно'
try {
const date = new Date(dateString)
return date.toLocaleDateString('ru-RU')
} catch {
return 'недавно'
}
},
truncateText(text, maxLength) {
if (!text) return ''
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text
},
handleSearch() {
// Дебаунс поиска
clearTimeout(this.searchTimeout)
this.searchTimeout = setTimeout(() => {
this.currentPage = 1
}, 300)
},
applyFilters() {
this.currentPage = 1
},
clearFilters() {
this.searchQuery = ''
this.filters = {
level: 'all',
distance: 'all',
city: 'all'
experience: 'all',
goals: 'all'
}
this.currentPage = 1
},
changePage(page) {
this.currentPage = page
window.scrollTo({ top: 0, behavior: 'smooth' })
},
viewProfile(member) {
this.selectedMember = member
this.showProfileModal = true
document.body.style.overflow = 'hidden'
},
closeProfileModal() {
this.showProfileModal = false
document.body.style.overflow = ''
},
contactMember(member) {
if (member.telegram) {
window.open(`https://t.me/${member.telegram.replace('@', '')}`, '_blank')
}
},
suggestTraining(member) {
alert(`Предложить совместную тренировку ${member.name}`)
// В реальном приложении открыть форму предложения тренировки
alert(`Предложить совместную тренировку ${member.firstName} ${member.lastName}`)
},
handleImageError(event) {
event.target.src = 'https://via.placeholder.com/120/666666/ffffff?text=🏃'
},
handleKeydown(event) {
if (event.key === 'Escape' && this.showProfileModal) {
this.closeProfileModal()
}
}
},
async loadMembers() {
this.loading = true
this.error = ''
try {
// Используем правильный эндпойнт - /user вместо /users
const response = await apiClient.get('/user')
if (response.data && Array.isArray(response.data)) {
this.members = response.data
await this.loadStats()
} else {
throw new Error('Некорректный формат данных')
}
} catch (error) {
console.error('Ошибка загрузки участников:', error)
this.error = error.response?.data?.message || error.message || 'Не удалось загрузить список участников'
// Используем демо-данные
this.members = this.generateMockMembers()
this.calculateStats()
} finally {
this.loading = false
}
},
async loadStats() {
try {
// Пока используем расчетную статистику, так как эндпойнта /stats/members нет
this.calculateStats()
} catch (error) {
console.warn('Не удалось загрузить статистику, используем расчетные значения:', error)
this.calculateStats()
}
},
calculateStats() {
this.stats = {
totalMembers: this.members.length,
activeMembers: Math.floor(this.members.length * 0.7),
citiesCount: this.calculateCitiesCount(),
totalDistance: this.members.length * 250,
advancedMembers: this.calculateAdvancedMembers(),
newsletterSubscribers: this.calculateNewsletterSubscribers(),
activeThisMonth: Math.floor(this.members.length * 0.6)
}
},
calculateCitiesCount() {
const domains = new Set(this.members.map(member => {
const email = member.email || ''
const domain = email.split('@')[1]
return domain ? domain.split('.')[0] : 'unknown'
}))
return domains.size
},
calculateAdvancedMembers() {
return this.members.filter(member =>
member.experience && ['advanced', 'professional'].includes(member.experience)
).length
},
calculateNewsletterSubscribers() {
return this.members.filter(member => member.newsletter).length
},
},
mounted() {
document.addEventListener('keydown', this.handleKeydown)
// Загрузка дополнительных данных (мок)
this.loadAdditionalMembers()
this.loadMembers()
},
beforeUnmount() {
document.removeEventListener('keydown', this.handleKeydown)
@@ -724,6 +673,63 @@ export default {
</script>
<style scoped>
/* Добавляем стили для состояний загрузки и ошибки */
.loading-state {
text-align: center;
padding: 4rem 2rem;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 4px solid #f3f3f3;
border-top: 4px solid #2e8b57;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.error-state {
text-align: center;
padding: 4rem 2rem;
background: white;
border-radius: 15px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.08);
}
.error-icon {
font-size: 4rem;
margin-bottom: 1.5rem;
}
.error-state h3 {
color: #e74c3c;
margin-bottom: 1rem;
}
.error-state p {
color: #666;
margin-bottom: 2rem;
}
.contact-info {
background: #f8f9fa;
padding: 1rem;
border-radius: 8px;
border-left: 4px solid #2e8b57;
font-family: monospace;
}
.members-page {
min-height: 100vh;
background: linear-gradient(135deg, #f8fff8 0%, #f0f8f0 100%);
@@ -1027,10 +1033,21 @@ export default {
white-space: nowrap;
}
.level-badge.beginner { background: #3498db; }
.level-badge.intermediate { background: #2e8b57; }
.level-badge.advanced { background: #f39c12; }
.level-badge.professional { background: #e74c3c; }
.level-badge.beginner {
background: #3498db;
}
.level-badge.intermediate {
background: #2e8b57;
}
.level-badge.advanced {
background: #f39c12;
}
.level-badge.professional {
background: #e74c3c;
}
/* Информация участника */
.member-info {
@@ -1753,6 +1770,7 @@ export default {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
@@ -1849,6 +1867,7 @@ input:focus-visible {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
@@ -1869,6 +1888,7 @@ input:focus-visible {
/* Адаптация для печати */
@media print {
.hero-section,
.cta-section,
.member-actions,