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:
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user