modified: begushiybashkir/bbvue/src/views/Reviews.vue

modified:   serv_nginx/api_bb/internal/repository/review_repository.go
fix null pointer execption in getStats repo function=method
This commit is contained in:
2025-10-15 04:57:23 +05:00
parent f545f5d71b
commit a200099f79
2 changed files with 359 additions and 84 deletions
+331 -59
View File
@@ -1,4 +1,3 @@
<!-- Reviews.vue (обновленная версия) -->
<template> <template>
<div class="reviews-page"> <div class="reviews-page">
<!-- Герой-секция --> <!-- Герой-секция -->
@@ -230,7 +229,7 @@
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="!isFormValid || submitting"> <button type="submit" class="btn btn-primary" :disabled="!isFormValid || submitting">
<span v-if="submitting">📤 Отправка...</span> <span v-if="submitting">📤 Отправка...</span>
<span v-else>📝 Опубликовать отзыв</span> <span v-else>📝 {{ editingReview ? 'Обновить отзыв' : 'Опубликовать отзыв' }}</span>
</button> </button>
<button type="button" class="btn btn-outline" @click="resetForm"> <button type="button" class="btn btn-outline" @click="resetForm">
🗑 Очистить 🗑 Очистить
@@ -329,12 +328,14 @@ export default {
currentPage: 1, currentPage: 1,
reviewsPerPage: 6, reviewsPerPage: 6,
totalPages: 1, totalPages: 1,
totalItems: 0,
submitting: false, submitting: false,
editingReview: null,
stats: { stats: {
total_reviews: 0, total_reviews: 0,
average_rating: 0, average_rating: 0,
success_stories: 0, success_stories: 0,
rating_distribution: {} rating_distribution: {5: 0, 4: 0, 3: 0, 2: 0, 1: 0}
}, },
ratingFilters: [ ratingFilters: [
{ value: 'all', label: 'Все оценки' }, { value: 'all', label: 'Все оценки' },
@@ -381,16 +382,21 @@ export default {
filter: this.activeRatingFilter === 'all' ? '' : this.activeRatingFilter filter: this.activeRatingFilter === 'all' ? '' : this.activeRatingFilter
} }
// Удаляем пустые параметры
Object.keys(params).forEach(key => {
if (params[key] === '' || params[key] === null || params[key] === undefined) {
delete params[key]
}
})
const response = await apiClient.get('/reviews', { params }) const response = await apiClient.get('/reviews', { params })
this.reviews = response.data.reviews this.reviews = response.data.reviews || []
this.totalPages = response.data.total_pages this.totalPages = response.data.total_pages || 1
this.totalItems = response.data.total_items || 0
this.currentPage = response.data.current_page || 1
} catch (error) { } catch (error) {
console.error('Ошибка при загрузке отзывов:', error) console.error('Ошибка при загрузке отзывов:', error)
this.$notify({ this.showNotification('error', 'Ошибка', 'Не удалось загрузить отзывы')
type: 'error',
title: 'Ошибка',
text: 'Не удалось загрузить отзывы'
})
} finally { } finally {
this.loading = false this.loading = false
} }
@@ -402,34 +408,47 @@ export default {
this.stats = response.data this.stats = response.data
} catch (error) { } catch (error) {
console.error('Ошибка при загрузке статистики:', error) console.error('Ошибка при загрузке статистики:', error)
// Устанавливаем значения по умолчанию при ошибке
this.stats = {
total_reviews: 0,
average_rating: 0,
success_stories: 0,
rating_distribution: {5: 0, 4: 0, 3: 0, 2: 0, 1: 0}
}
// Не показываем ошибку пользователю для статистики
} }
}, },
async checkAuth() { async checkAuth() {
try { try {
const token = localStorage.getItem('auth_token') // Используем auth_token вместо token const token = localStorage.getItem('auth_token')
if (token) { if (token) {
// Пробуем получить профиль для проверки токена
try { try {
// Пытаемся получить профиль пользователя для проверки авторизации
const response = await apiClient.get('/user/profile') const response = await apiClient.get('/user/profile')
this.isAuthenticated = true this.isAuthenticated = true
this.currentUser = response.data this.currentUser = response.data
} catch (error) { } catch (error) {
// Если запрос профиля失敗, считаем неавторизованным
console.error('Токен невалиден:', error) console.error('Токен невалиден:', error)
this.isAuthenticated = false this.handleAuthError()
localStorage.removeItem('auth_token')
} }
} else { } else {
this.isAuthenticated = false this.isAuthenticated = false
this.currentUser = null
} }
} catch (error) { } catch (error) {
console.error('Ошибка проверки авторизации:', error) console.error('Ошибка проверки авторизации:', error)
this.isAuthenticated = false this.handleAuthError()
localStorage.removeItem('auth_token')
} }
}, },
handleAuthError() {
this.isAuthenticated = false
this.currentUser = null
localStorage.removeItem('auth_token')
// НЕ перенаправляем на login, чтобы пользователь мог просматривать отзывы
},
getInitials(author) { getInitials(author) {
if (!author) return '?' if (!author) return '?'
const firstName = author.first_name || '' const firstName = author.first_name || ''
@@ -442,12 +461,17 @@ export default {
}, },
formatDate(dateString) { formatDate(dateString) {
const date = new Date(dateString) try {
return date.toLocaleDateString('ru-RU', { const date = new Date(dateString)
day: 'numeric', return date.toLocaleDateString('ru-RU', {
month: 'long', day: 'numeric',
year: 'numeric' month: 'long',
}) year: 'numeric'
})
} catch (error) {
console.log(error)
return 'Неизвестная дата'
}
}, },
filterByRating(rating) { filterByRating(rating) {
@@ -457,9 +481,11 @@ export default {
}, },
changePage(page) { changePage(page) {
this.currentPage = page if (page >= 1 && page <= this.totalPages) {
this.loadReviews() this.currentPage = page
window.scrollTo({ top: 0, behavior: 'smooth' }) this.loadReviews()
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}, },
getRatingCount(rating) { getRatingCount(rating) {
@@ -473,12 +499,17 @@ export default {
}, },
isMyReview(review) { isMyReview(review) {
// В реальном приложении сравнивать с ID текущего пользователя return this.isAuthenticated &&
return this.isAuthenticated && review.author && review.author.id === this.currentUser?.id this.currentUser &&
review.author &&
review.author.id === this.currentUser.id
}, },
async submitReview() { async submitReview() {
if (!this.isFormValid) return if (!this.isFormValid) {
this.showNotification('error', 'Ошибка', 'Пожалуйста, заполните все обязательные поля правильно')
return
}
this.submitting = true this.submitting = true
@@ -492,33 +523,55 @@ export default {
trainings: this.newReview.trainings trainings: this.newReview.trainings
} }
await apiClient.post('/reviews', reviewData) if (this.editingReview) {
// Редактирование существующего отзыва
await apiClient.put(`/reviews/${this.editingReview.id}`, reviewData)
this.showNotification('success', 'Успех', 'Отзыв успешно обновлен!')
this.editingReview = null
} else {
// Создание нового отзыва
await apiClient.post('/reviews', reviewData)
this.showNotification('success', 'Успех', 'Спасибо за ваш отзыв! После проверки модератором он будет опубликован.')
}
this.resetForm() this.resetForm()
this.loadReviews() // Перезагружаем список отзывов this.loadReviews()
this.loadStats() // Обновляем статистику this.loadStats()
this.$notify({
type: 'success',
title: 'Успех',
text: 'Спасибо за ваш отзыв! После проверки модератором он будет опубликован.'
})
} catch (error) { } catch (error) {
console.error('Ошибка при отправке отзыва:', error) console.error('Ошибка при отправке отзыва:', error)
this.$notify({ let errorMessage = 'Произошла ошибка при отправке отзыва. Попробуйте еще раз.'
type: 'error',
title: 'Ошибка', if (error.response?.status === 401) {
text: 'Произошла ошибка при отправке отзыва. Попробуйте еще раз.' errorMessage = 'Необходимо авторизоваться для отправки отзыва'
}) this.handleAuthError()
} else if (error.response?.status === 403) {
errorMessage = 'У вас нет прав для выполнения этого действия'
} else if (error.response?.data?.message) {
errorMessage = error.response.data.message
}
this.showNotification('error', 'Ошибка', errorMessage)
} finally { } finally {
this.submitting = false this.submitting = false
} }
}, },
async editReview(review) { async editReview(review) {
// Реализация редактирования отзыва this.editingReview = review
console.log('Редактирование отзыва:', review) this.newReview = {
rating: review.rating,
text: review.text,
achievement: review.achievement || '',
distance: review.distance || '',
improvement: review.improvement || '',
trainings: review.trainings || 0
}
// Прокручиваем к форме
document.getElementById('add-review').scrollIntoView({
behavior: 'smooth'
})
}, },
async deleteReview(reviewId) { async deleteReview(reviewId) {
@@ -529,22 +582,25 @@ export default {
try { try {
await apiClient.delete(`/reviews/${reviewId}`) await apiClient.delete(`/reviews/${reviewId}`)
this.loadReviews() // Перезагружаем список отзывов this.loadReviews()
this.loadStats() // Обновляем статистику this.loadStats()
this.$notify({ this.showNotification('success', 'Успех', 'Отзыв успешно удален')
type: 'success',
title: 'Успех',
text: 'Отзыв успешно удален'
})
} catch (error) { } catch (error) {
console.error('Ошибка при удалении отзыва:', error) console.error('Ошибка при удалении отзыва:', error)
this.$notify({ let errorMessage = 'Не удалось удалить отзыв'
type: 'error',
title: 'Ошибка', if (error.response?.status === 403) {
text: 'Не удалось удалить отзыв' errorMessage = 'Вы можете удалять только свои отзывы'
}) } else if (error.response?.status === 401) {
errorMessage = 'Необходимо авторизоваться'
this.handleAuthError()
} else if (error.response?.data?.message) {
errorMessage = error.response.data.message
}
this.showNotification('error', 'Ошибка', errorMessage)
} }
}, },
@@ -557,6 +613,33 @@ export default {
improvement: '', improvement: '',
trainings: 0 trainings: 0
} }
this.editingReview = null
},
showNotification(type, title, text) {
// Используем существующую систему уведомлений или создаем простую альтернативу
if (this.$notify) {
this.$notify({
type: type,
title: title,
text: text
})
} else {
// Простая альтернатива, если $notify не доступен
const notification = document.createElement('div')
notification.className = `notification notification-${type}`
notification.innerHTML = `
<strong>${title}</strong>
<p>${text}</p>
`
document.body.appendChild(notification)
setTimeout(() => {
if (notification.parentNode) {
document.body.removeChild(notification)
}
}, 5000)
}
} }
}, },
mounted() { mounted() {
@@ -566,6 +649,11 @@ export default {
}, },
watch: { watch: {
sortBy() { sortBy() {
this.currentPage = 1
this.loadReviews()
},
activeRatingFilter() {
this.currentPage = 1
this.loadReviews() this.loadReviews()
} }
} }
@@ -573,9 +661,193 @@ export default {
</script> </script>
<style scoped> <style scoped>
/* Стили остаются такими же как в вашем файле */ /* Стили остаются без изменений, как в вашем исходном файле */
/* Добавим только несколько новых стилей */ /* Добавляем только стили для уведомлений */
.notification {
position: fixed;
top: 20px;
right: 20px;
padding: 1rem 1.5rem;
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
z-index: 1000;
animation: slideInRight 0.3s ease;
max-width: 400px;
}
.notification-success {
background: #27ae60;
color: white;
}
.notification-error {
background: #e74c3c;
color: white;
}
.notification-warning {
background: #f39c12;
color: white;
}
.notification-info {
background: #3498db;
color: white;
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Остальные стили остаются такими же как в вашем файле */
.loading {
text-align: center;
padding: 3rem;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #2e8b57;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 2s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.no-reviews {
text-align: center;
padding: 3rem;
color: #666;
}
.btn-edit,
.btn-delete {
background: none;
border: none;
color: #2e8b57;
cursor: pointer;
font-size: 0.8rem;
margin-right: 1rem;
}
.btn-delete {
color: #e74c3c;
}
.btn-edit:hover {
text-decoration: underline;
}
.btn-delete:hover {
text-decoration: underline;
}
.notification {
position: fixed;
top: 20px;
right: 20px;
padding: 1rem 1.5rem;
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
z-index: 1000;
animation: slideInRight 0.3s ease;
max-width: 400px;
}
.notification-success {
background: #27ae60;
color: white;
}
.notification-error {
background: #e74c3c;
color: white;
}
.notification-warning {
background: #f39c12;
color: white;
}
.notification-info {
background: #3498db;
color: white;
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Остальные стили остаются такими же как в вашем файле */
.loading {
text-align: center;
padding: 3rem;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #2e8b57;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 2s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.no-reviews {
text-align: center;
padding: 3rem;
color: #666;
}
.btn-edit,
.btn-delete {
background: none;
border: none;
color: #2e8b57;
cursor: pointer;
font-size: 0.8rem;
margin-right: 1rem;
}
.btn-delete {
color: #e74c3c;
}
.btn-edit:hover {
text-decoration: underline;
}
.btn-delete:hover {
text-decoration: underline;
}
.loading { .loading {
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
@@ -3,7 +3,8 @@ package repository
import ( import (
"api_bb/internal/models" "api_bb/internal/models"
"math"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -88,39 +89,41 @@ func (r *reviewRepository) Delete(id uint) error {
} }
func (r *reviewRepository) GetStats() (*models.ReviewsStatsResponse, error) { func (r *reviewRepository) GetStats() (*models.ReviewsStatsResponse, error) {
var totalReviews int64 var stats models.ReviewsStatsResponse
var averageRating float64
var successStories int64
// Общее количество отзывов // Один запрос для всей основной статистики
if err := r.db.Model(&models.Review{}).Count(&totalReviews).Error; err != nil { query := `
SELECT
COUNT(*) as total_reviews,
COALESCE(AVG(rating), 0) as average_rating,
COUNT(CASE WHEN rating >= 4 AND achievement != '' THEN 1 END) as success_stories
FROM reviews
WHERE deleted_at IS NULL
`
type StatsResult struct {
TotalReviews int `gorm:"column:total_reviews"`
AverageRating float64 `gorm:"column:average_rating"`
SuccessStories int `gorm:"column:success_stories"`
}
var result StatsResult
if err := r.db.Raw(query).Scan(&result).Error; err != nil {
return nil, err return nil, err
} }
// Средний рейтинг stats.TotalReviews = result.TotalReviews
if err := r.db.Model(&models.Review{}).Select("AVG(rating)").Row().Scan(&averageRating); err != nil { stats.AverageRating = math.Round(result.AverageRating*100) / 100 // Округляем до 2 знаков
return nil, err stats.SuccessStories = result.SuccessStories
}
// Количество успешных историй (отзывы с рейтингом >= 4 и достижениями) // Распределение по рейтингам (оставляем ваш существующий метод)
if err := r.db.Model(&models.Review{}).
Where("rating >= ? AND achievement != ?", 4, "").
Count(&successStories).Error; err != nil {
return nil, err
}
// Распределение по рейтингам
ratingDistribution, err := r.GetRatingDistribution() ratingDistribution, err := r.GetRatingDistribution()
if err != nil { if err != nil {
return nil, err return nil, err
} }
stats.RatingDistribution = ratingDistribution
return &models.ReviewsStatsResponse{ return &stats, nil
TotalReviews: int(totalReviews),
AverageRating: averageRating,
SuccessStories: int(successStories),
RatingDistribution: ratingDistribution,
}, nil
} }
func (r *reviewRepository) GetRatingDistribution() (map[int]int, error) { func (r *reviewRepository) GetRatingDistribution() (map[int]int, error) {
@@ -152,4 +155,4 @@ func (r *reviewRepository) GetRatingDistribution() (map[int]int, error) {
} }
return distribution, nil return distribution, nil
} }