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.Get("/profile", h.GetProfile)
r.Post("/editProfile", h.UpdateProfile) r.Post("/editProfile", h.UpdateProfile)
// Убрали маршрут для обслуживания аватаров - теперь это делает AvatarHandler r.Get("/", h.GetUsers) // 👈 ДОБАВЛЯЕМ НОВЫЙ ЭНДПОЙНТ
return r return r
} }
@@ -54,6 +54,43 @@ type UserResponse struct {
UpdatedAt time.Time `json:"updated_at"` 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) { func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
h.logger.Info("handling get profile request", h.logger.Info("handling get profile request",
@@ -14,18 +14,19 @@ type UserRepository interface {
Update(user *models.User) error Update(user *models.User) error
Delete(id uint) error Delete(id uint) error
UpdateExcludeEmail(userUpdate *models.User) 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 { func (r *userRepository) UpdateAvatar(userID uint, avatarPath string) error {
result := r.db.Model(&models.User{}).Where("id = ?", userID).Update("avatar", avatarPath) result := r.db.Model(&models.User{}).Where("id = ?", userID).Update("avatar", avatarPath)
if result.Error != nil { if result.Error != nil {
return result.Error return result.Error
} }
if result.RowsAffected == 0 { if result.RowsAffected == 0 {
return fmt.Errorf("user not found") return fmt.Errorf("user not found")
} }
return nil return nil
} }
type userRepository struct { type userRepository struct {
@@ -36,6 +37,13 @@ func NewUserRepository(db *gorm.DB) UserRepository {
return &userRepository{db: db} 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 { func (r *userRepository) Create(user *models.User) error {
return r.db.Create(user).Error return r.db.Create(user).Error
} }
@@ -13,6 +13,7 @@ import (
type UserService interface { type UserService interface {
GetUserProfile(userID uint) (*models.User, error) GetUserProfile(userID uint) (*models.User, error)
UpdateProfile(user *models.User) error UpdateProfile(user *models.User) error
GetAllUsers() ([]models.User, error)
} }
type userService struct { 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 { func (s *authService) UpdateProfile(user *models.User) error {
s.logger.Info("Updating user profile", s.logger.Info("Updating user profile",
zap.Uint("user_id", user.ID), zap.Uint("user_id", user.ID),
+373 -353
View File
@@ -8,19 +8,19 @@
<p class="hero-subtitle">Более 150 участников объединены любовью к бегу и стремлением к новым достижениям</p> <p class="hero-subtitle">Более 150 участников объединены любовью к бегу и стремлением к новым достижениям</p>
<div class="hero-stats"> <div class="hero-stats">
<div class="stat"> <div class="stat">
<div class="stat-number">{{ totalMembers }}</div> <div class="stat-number">{{ stats.totalMembers }}</div>
<div class="stat-label">Участников</div> <div class="stat-label">Участников</div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="stat-number">{{ activeMembers }}</div> <div class="stat-number">{{ stats.activeMembers }}</div>
<div class="stat-label">Активных</div> <div class="stat-label">Активных</div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="stat-number">{{ citiesCount }}</div> <div class="stat-number">{{ stats.citiesCount }}</div>
<div class="stat-label">Городов</div> <div class="stat-label">Городов</div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="stat-number">{{ totalDistance }}</div> <div class="stat-number">{{ stats.totalDistance }}</div>
<div class="stat-label">Км за год</div> <div class="stat-label">Км за год</div>
</div> </div>
</div> </div>
@@ -38,21 +38,16 @@
<div class="members-controls"> <div class="members-controls">
<!-- Поиск --> <!-- Поиск -->
<div class="search-box"> <div class="search-box">
<input <input v-model="searchQuery" type="text" placeholder="🔍 Поиск по имени, фамилии или email..."
v-model="searchQuery" class="search-input" @input="handleSearch">
type="text"
placeholder="🔍 Поиск по имени или городу..."
class="search-input"
@input="handleSearch"
>
</div> </div>
<!-- Фильтры --> <!-- Фильтры -->
<div class="filters-container"> <div class="filters-container">
<div class="filter-group"> <div class="filter-group">
<label for="level">Уровень:</label> <label for="experience">Опыт:</label>
<select id="level" v-model="filters.level" @change="applyFilters"> <select id="experience" v-model="filters.experience" @change="applyFilters">
<option value="all">Все уровни</option> <option value="all">Любой опыт</option>
<option value="beginner">Начинающий</option> <option value="beginner">Начинающий</option>
<option value="intermediate">Любитель</option> <option value="intermediate">Любитель</option>
<option value="advanced">Опытный</option> <option value="advanced">Опытный</option>
@@ -61,38 +56,25 @@
</div> </div>
<div class="filter-group"> <div class="filter-group">
<label for="distance">Дистанция:</label> <label for="goals">Цели:</label>
<select id="distance" v-model="filters.distance" @change="applyFilters"> <select id="goals" v-model="filters.goals" @change="applyFilters">
<option value="all">Все дистанции</option> <option value="all">Все цели</option>
<option value="5km">5 км</option> <option value="5км">5 км</option>
<option value="10km">10 км</option> <option value="10км">10 км</option>
<option value="half">Полумарафон</option> <option value="полумарафон">Полумарафон</option>
<option value="marathon">Марафон</option> <option value="марафон">Марафон</option>
<option value="ultra">Ультра</option> <option value="здоровье">Здоровье</option>
</select> <option value="похудение">Похудение</option>
</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>
</select> </select>
</div> </div>
<div class="view-toggle"> <div class="view-toggle">
<button <button :class="['view-btn', { 'active': viewMode === 'grid' }]" @click="viewMode = 'grid'"
:class="['view-btn', { 'active': viewMode === 'grid' }]" title="Сетка">
@click="viewMode = 'grid'"
title="Сетка"
>
</button> </button>
<button <button :class="['view-btn', { 'active': viewMode === 'list' }]" @click="viewMode = 'list'"
:class="['view-btn', { 'active': viewMode === 'list' }]" title="Список">
@click="viewMode = 'list'"
title="Список"
>
</button> </button>
</div> </div>
@@ -103,126 +85,96 @@
<!-- Статистика фильтров --> <!-- Статистика фильтров -->
<div class="filter-stats" v-if="showFilterStats"> <div class="filter-stats" v-if="showFilterStats">
<p> <p>
Показано {{ filteredMembers.length }} из {{ totalMembers }} участников Показано {{ filteredMembers.length }} из {{ members.length }} участников
<span v-if="searchQuery"> по запросу "{{ searchQuery }}"</span> <span v-if="searchQuery"> по запросу "{{ searchQuery }}"</span>
<button class="clear-filters" @click="clearFilters"> Очистить фильтры</button> <button class="clear-filters" @click="clearFilters"> Очистить фильтры</button>
</p> </p>
</div> </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 class="members-container" :class="viewMode" v-else-if="filteredMembers.length > 0">
<div <div v-for="member in paginatedMembers" :key="member.id" class="member-card"
v-for="member in paginatedMembers" :class="getExperienceClass(member.experience)">
:key="member.id"
class="member-card"
:class="member.level"
>
<div class="member-avatar"> <div class="member-avatar">
<img <img :src="getAvatarUrl(member.avatar)" :alt="member.firstName + ' ' + member.lastName" class="avatar-img"
:src="member.avatar" @error="handleImageError">
:alt="member.name" <div class="level-badge" :class="getExperienceClass(member.experience)">
class="avatar-img" {{ getExperienceLabel(member.experience) }}
@error="handleImageError"
>
<div class="online-indicator" v-if="member.isOnline"></div>
<div class="level-badge" :class="member.level">
{{ getLevelLabel(member.level) }}
</div> </div>
</div> </div>
<div class="member-info"> <div class="member-info">
<h3 class="member-name">{{ member.name }}</h3> <h3 class="member-name">{{ member.firstName }} {{ member.lastName }}</h3>
<p class="member-city">📍 {{ member.city }}</p> <p class="member-email">📧 {{ member.email }}</p>
<div class="member-stats"> <div class="member-stats">
<div class="stat-item"> <div class="stat-item" v-if="member.phone">
<span class="stat-icon">🏃</span> <span class="stat-icon">📱</span>
<span class="stat-value">{{ member.weeklyDistance }} км/нед</span> <span class="stat-value">{{ member.phone }}</span>
</div> </div>
<div class="stat-item"> <div class="stat-item">
<span class="stat-icon">📅</span> <span class="stat-icon">📅</span>
<span class="stat-value">{{ member.memberSince }}</span> <span class="stat-value">Участник с {{ formatDate(member.createdAt) }}</span>
</div> </div>
</div> </div>
<div class="member-achievements"> <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-icon">🎯</span>
<span class="achievement-text">{{ member.bestDistance }}</span> <span class="achievement-text">Опыт: {{ getExperienceLabel(member.experience) }}</span>
</div> </div>
<div class="achievement" v-if="member.pb"> <div class="achievement" v-if="member.goals">
<span class="achievement-icon"></span> <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> </div>
<div class="member-interests"> <div class="member-interests" v-if="member.goals || member.newsletter">
<span <span class="interest-tag" v-if="member.goals">
v-for="interest in member.interests.slice(0, 2)" {{ getGoalCategory(member.goals) }}
:key="interest"
class="interest-tag"
>
{{ interest }}
</span> </span>
<span <span class="interest-tag" v-if="member.newsletter">
v-if="member.interests.length > 2" 📰 Рассылка
class="interest-more"
:title="member.interests.slice(2).join(', ')"
>
+{{ member.interests.length - 2 }}
</span> </span>
</div> </div>
<div class="member-actions"> <div class="member-actions">
<button <button class="btn-profile" @click="viewProfile(member)">
class="btn-profile"
@click="viewProfile(member)"
>
👤 Профиль 👤 Профиль
</button> </button>
<button
v-if="member.telegram"
class="btn-contact"
@click="contactMember(member)"
title="Написать в Telegram"
>
📱
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Пагинация --> <!-- Пустой результат -->
<div class="pagination" v-if="totalPages > 1"> <div class="empty-state" v-else-if="!loading && members.length === 0">
<button <div class="empty-icon">👥</div>
class="pagination-btn" <h3>Пока нет участников</h3>
:disabled="currentPage === 1" <p>Будьте первым, кто присоединится к нашему клубу!</p>
@click="changePage(currentPage - 1)" <router-link to="/register" class="btn btn-primary">
> 👥 Вступить в клуб
</router-link>
</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>
<!-- Пустой результат --> <!-- Пустой результат после фильтрации -->
<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> <div class="empty-icon">🔍</div>
<h3>Участники не найдены</h3> <h3>Участники не найдены</h3>
<p>Попробуйте изменить параметры поиска или фильтры</p> <p>Попробуйте изменить параметры поиска или фильтры</p>
@@ -230,6 +182,22 @@
🗑 Сбросить фильтры 🗑 Сбросить фильтры
</button> </button>
</div> </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> </div>
</section> </section>
@@ -241,29 +209,29 @@
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon">🏃</div> <div class="stat-icon">🏃</div>
<div class="stat-content"> <div class="stat-content">
<div class="stat-number">15,247</div> <div class="stat-number">{{ stats.totalMembers }}</div>
<div class="stat-label">Км пробежали на этой неделе</div> <div class="stat-label">Участников в клубе</div>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon">🎯</div> <div class="stat-icon">🎯</div>
<div class="stat-content"> <div class="stat-content">
<div class="stat-number">42</div> <div class="stat-number">{{ stats.advancedMembers }}</div>
<div class="stat-label">Личных рекорда за месяц</div> <div class="stat-label">Опытных бегунов</div>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon">👥</div> <div class="stat-icon">👥</div>
<div class="stat-content"> <div class="stat-content">
<div class="stat-number">28</div> <div class="stat-number">{{ stats.newsletterSubscribers }}</div>
<div class="stat-label">Совместных тренировок в неделю</div> <div class="stat-label">Подписчиков рассылки</div>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon">🏆</div> <div class="stat-icon">📈</div>
<div class="stat-content"> <div class="stat-content">
<div class="stat-number">156</div> <div class="stat-number">{{ stats.activeThisMonth }}</div>
<div class="stat-label">Медалей на соревнованиях</div> <div class="stat-label">Активных в этом месяце</div>
</div> </div>
</div> </div>
</div> </div>
@@ -284,7 +252,8 @@
</div> </div>
<div class="story-content"> <div class="story-content">
<h4>Анна, участник 1 год</h4> <h4>Анна, участник 1 год</h4>
<p>"Пришла с нулевым опытом, сейчас готовлюсь к марафону. Благодаря поддержке клуба нашла друзей и полюбила бег!"</p> <p>"Пришла с нулевым опытом, сейчас готовлюсь к марафону. Благодаря поддержке клуба нашла друзей и
полюбила бег!"</p>
<div class="story-stats"> <div class="story-stats">
<span>🏃 10км за 48:15</span> <span>🏃 10км за 48:15</span>
<span>📈 Улучшение +12 мин</span> <span>📈 Улучшение +12 мин</span>
@@ -298,7 +267,8 @@
</div> </div>
<div class="story-content"> <div class="story-content">
<h4>Данил, участник 2 года</h4> <h4>Данил, участник 2 года</h4>
<p>"От первых 5 км до ультрамарафона! В клубе нашел не только тренера, но и верных друзей для совместных тренировок."</p> <p>"От первых 5 км до ультрамарафона! В клубе нашел не только тренера, но и верных друзей для совместных
тренировок."</p>
<div class="story-stats"> <div class="story-stats">
<span>🏔 120 км трейл</span> <span>🏔 120 км трейл</span>
<span> 5+ личных рекордов</span> <span> 5+ личных рекордов</span>
@@ -362,90 +332,62 @@
<div class="profile-modal" v-if="selectedMember"> <div class="profile-modal" v-if="selectedMember">
<div class="profile-header"> <div class="profile-header">
<div class="profile-avatar"> <div class="profile-avatar">
<img :src="selectedMember.avatar" :alt="selectedMember.name"> <img :src="getAvatarUrl(selectedMember.avatar)"
<div class="online-indicator large" v-if="selectedMember.isOnline"></div> :alt="selectedMember.firstName + ' ' + selectedMember.lastName">
</div> </div>
<div class="profile-info"> <div class="profile-info">
<h2>{{ selectedMember.name }}</h2> <h2>{{ selectedMember.firstName }} {{ selectedMember.lastName }}</h2>
<p class="profile-city">📍 {{ selectedMember.city }}</p> <p class="profile-email">📧 {{ selectedMember.email }}</p>
<div class="profile-badges"> <div class="profile-badges">
<span class="level-badge" :class="selectedMember.level"> <span class="level-badge" :class="getExperienceClass(selectedMember.experience)">
{{ getLevelLabel(selectedMember.level) }} {{ getExperienceLabel(selectedMember.experience) }}
</span> </span>
<span class="member-since">Участник {{ selectedMember.memberSince }}</span> <span class="member-since">Участник с {{ formatDate(selectedMember.createdAt) }}</span>
</div> </div>
</div> </div>
</div> </div>
<div class="profile-stats"> <div class="profile-stats">
<div class="stat-item"> <div class="stat-item">
<div class="stat-value">{{ selectedMember.weeklyDistance }} км</div> <div class="stat-value">{{ selectedMember.experience ? getExperienceLabel(selectedMember.experience) :
<div class="stat-label">В неделю</div> 'Неуказан' }}</div>
<div class="stat-label">Уровень опыта</div>
</div> </div>
<div class="stat-item"> <div class="stat-item">
<div class="stat-value">{{ selectedMember.totalDistance }} км</div> <div class="stat-value">{{ selectedMember.newsletter ? 'Да' : 'Нет' }}</div>
<div class="stat-label">Всего с клубом</div> <div class="stat-label">Подписка на рассылку</div>
</div> </div>
<div class="stat-item"> <div class="stat-item">
<div class="stat-value">{{ selectedMember.trainingsCount }}</div> <div class="stat-value">{{ selectedMember.role || 'user' }}</div>
<div class="stat-label">Тренировок</div> <div class="stat-label">Роль в клубе</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ selectedMember.achievementsCount }}</div>
<div class="stat-label">Достижений</div>
</div> </div>
</div> </div>
<div class="profile-details"> <div class="profile-details">
<div class="detail-section"> <div class="detail-section" v-if="selectedMember.phone">
<h4>🏆 Лучшие результаты</h4> <h4>📱 Контакты</h4>
<div class="achievements-list"> <p class="contact-info">{{ selectedMember.phone }}</p>
<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> </div>
<div class="detail-section"> <div class="detail-section" v-if="selectedMember.experience">
<h4>🎯 Интересы</h4> <h4>🎯 Опыт в беге</h4>
<div class="interests-list"> <p class="about-text">{{ selectedMember.experience }}</p>
<span
v-for="interest in selectedMember.interests"
:key="interest"
class="interest-tag"
>
{{ interest }}
</span>
</div>
</div> </div>
<div class="detail-section" v-if="selectedMember.about"> <div class="detail-section" v-if="selectedMember.goals">
<h4>📖 О себе</h4> <h4> Цели и планы</h4>
<p class="about-text">{{ selectedMember.about }}</p> <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> </div>
<div class="profile-actions"> <div class="profile-actions">
<button <button class="btn btn-outline" @click="suggestTraining(selectedMember)">
v-if="selectedMember.telegram"
class="btn btn-primary"
@click="contactMember(selectedMember)"
>
📱 Написать в Telegram
</button>
<button
class="btn btn-outline"
@click="suggestTraining(selectedMember)"
>
🏃 Предложить тренировку 🏃 Предложить тренировку
</button> </button>
</div> </div>
@@ -456,6 +398,8 @@
</template> </template>
<script> <script>
import { apiClient } from '../stores/helpers/api';
export default { export default {
// eslint-disable-next-line vue/multi-word-component-names // eslint-disable-next-line vue/multi-word-component-names
name: 'Members', name: 'Members',
@@ -463,9 +407,8 @@ export default {
return { return {
searchQuery: '', searchQuery: '',
filters: { filters: {
level: 'all', experience: 'all',
distance: 'all', goals: 'all'
city: 'all'
}, },
viewMode: 'grid', viewMode: 'grid',
currentPage: 1, currentPage: 1,
@@ -473,155 +416,44 @@ export default {
showProfileModal: false, showProfileModal: false,
selectedMember: null, selectedMember: null,
searchTimeout: null, searchTimeout: null,
members: [ loading: false,
// Здесь будет большой массив участников (для примера 24 участника) error: '',
{ members: [],
id: 1, stats: {
name: 'Сергей', totalMembers: 0,
city: 'Уфа', activeMembers: 0,
level: 'professional', citiesCount: 0,
avatar: 'https://via.placeholder.com/120/2e8b57/ffffff?text=СИ', totalDistance: 0,
weeklyDistance: 65, advancedMembers: 0,
totalDistance: 2450, newsletterSubscribers: 0,
memberSince: '2 года', activeThisMonth: 0
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
}
// ... можно добавить еще участников для демонстрации
]
} }
}, },
computed: { 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() { filteredMembers() {
let filtered = this.members let filtered = this.members
// Поиск по имени и городу // Поиск по имени, фамилии и email
if (this.searchQuery) { if (this.searchQuery) {
const query = this.searchQuery.toLowerCase() const query = this.searchQuery.toLowerCase()
filtered = filtered.filter(member => filtered = filtered.filter(member =>
member.name.toLowerCase().includes(query) || member.firstName?.toLowerCase().includes(query) ||
member.city.toLowerCase().includes(query) member.lastName?.toLowerCase().includes(query) ||
member.email?.toLowerCase().includes(query)
) )
} }
// Фильтрация по уровню // Фильтрация по опыту
if (this.filters.level !== 'all') { if (this.filters.experience !== 'all') {
filtered = filtered.filter(member => member.level === this.filters.level) filtered = filtered.filter(member => member.experience === this.filters.experience)
} }
// Фильтрация по городу // Фильтрация по целям
if (this.filters.city !== 'all') { if (this.filters.goals !== 'all') {
filtered = filtered.filter(member => member.city === this.filters.city) filtered = filtered.filter(member =>
member.goals && member.goals.toLowerCase().includes(this.filters.goals.toLowerCase())
)
} }
return filtered return filtered
@@ -645,76 +477,193 @@ export default {
}, },
showFilterStats() { showFilterStats() {
return this.searchQuery || return this.searchQuery ||
this.filters.level !== 'all' || this.filters.experience !== 'all' ||
this.filters.distance !== 'all' || this.filters.goals !== 'all'
this.filters.city !== 'all'
} }
}, },
methods: { methods: {
getLevelLabel(level) { getExperienceLabel(experience) {
const labels = { const labels = {
'beginner': 'Новичок', 'beginner': 'Начинающий',
'intermediate': 'Любитель', 'intermediate': 'Любитель',
'advanced': 'Опытный', '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() { handleSearch() {
// Дебаунс поиска
clearTimeout(this.searchTimeout) clearTimeout(this.searchTimeout)
this.searchTimeout = setTimeout(() => { this.searchTimeout = setTimeout(() => {
this.currentPage = 1 this.currentPage = 1
}, 300) }, 300)
}, },
applyFilters() { applyFilters() {
this.currentPage = 1 this.currentPage = 1
}, },
clearFilters() { clearFilters() {
this.searchQuery = '' this.searchQuery = ''
this.filters = { this.filters = {
level: 'all', experience: 'all',
distance: 'all', goals: 'all'
city: 'all'
} }
this.currentPage = 1 this.currentPage = 1
}, },
changePage(page) { changePage(page) {
this.currentPage = page this.currentPage = page
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' })
}, },
viewProfile(member) { viewProfile(member) {
this.selectedMember = member this.selectedMember = member
this.showProfileModal = true this.showProfileModal = true
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
}, },
closeProfileModal() { closeProfileModal() {
this.showProfileModal = false this.showProfileModal = false
document.body.style.overflow = '' document.body.style.overflow = ''
}, },
contactMember(member) {
if (member.telegram) {
window.open(`https://t.me/${member.telegram.replace('@', '')}`, '_blank')
}
},
suggestTraining(member) { suggestTraining(member) {
alert(`Предложить совместную тренировку ${member.name}`) alert(`Предложить совместную тренировку ${member.firstName} ${member.lastName}`)
// В реальном приложении открыть форму предложения тренировки
}, },
handleImageError(event) { handleImageError(event) {
event.target.src = 'https://via.placeholder.com/120/666666/ffffff?text=🏃' event.target.src = 'https://via.placeholder.com/120/666666/ffffff?text=🏃'
}, },
handleKeydown(event) { handleKeydown(event) {
if (event.key === 'Escape' && this.showProfileModal) { if (event.key === 'Escape' && this.showProfileModal) {
this.closeProfileModal() 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() { mounted() {
document.addEventListener('keydown', this.handleKeydown) document.addEventListener('keydown', this.handleKeydown)
this.loadMembers()
// Загрузка дополнительных данных (мок)
this.loadAdditionalMembers()
}, },
beforeUnmount() { beforeUnmount() {
document.removeEventListener('keydown', this.handleKeydown) document.removeEventListener('keydown', this.handleKeydown)
@@ -724,6 +673,63 @@ export default {
</script> </script>
<style scoped> <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 { .members-page {
min-height: 100vh; min-height: 100vh;
background: linear-gradient(135deg, #f8fff8 0%, #f0f8f0 100%); background: linear-gradient(135deg, #f8fff8 0%, #f0f8f0 100%);
@@ -1027,10 +1033,21 @@ export default {
white-space: nowrap; white-space: nowrap;
} }
.level-badge.beginner { background: #3498db; } .level-badge.beginner {
.level-badge.intermediate { background: #2e8b57; } background: #3498db;
.level-badge.advanced { background: #f39c12; } }
.level-badge.professional { background: #e74c3c; }
.level-badge.intermediate {
background: #2e8b57;
}
.level-badge.advanced {
background: #f39c12;
}
.level-badge.professional {
background: #e74c3c;
}
/* Информация участника */ /* Информация участника */
.member-info { .member-info {
@@ -1753,6 +1770,7 @@ export default {
opacity: 0; opacity: 0;
transform: translateY(30px); transform: translateY(30px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
@@ -1849,6 +1867,7 @@ input:focus-visible {
0% { 0% {
background-position: 200% 0; background-position: 200% 0;
} }
100% { 100% {
background-position: -200% 0; background-position: -200% 0;
} }
@@ -1869,6 +1888,7 @@ input:focus-visible {
/* Адаптация для печати */ /* Адаптация для печати */
@media print { @media print {
.hero-section, .hero-section,
.cta-section, .cta-section,
.member-actions, .member-actions,