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:
@@ -1,4 +1,3 @@
|
||||
<!-- Reviews.vue (обновленная версия) -->
|
||||
<template>
|
||||
<div class="reviews-page">
|
||||
<!-- Герой-секция -->
|
||||
@@ -230,7 +229,7 @@
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="!isFormValid || submitting">
|
||||
<span v-if="submitting">📤 Отправка...</span>
|
||||
<span v-else>📝 Опубликовать отзыв</span>
|
||||
<span v-else>📝 {{ editingReview ? 'Обновить отзыв' : 'Опубликовать отзыв' }}</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline" @click="resetForm">
|
||||
🗑️ Очистить
|
||||
@@ -329,12 +328,14 @@ export default {
|
||||
currentPage: 1,
|
||||
reviewsPerPage: 6,
|
||||
totalPages: 1,
|
||||
totalItems: 0,
|
||||
submitting: false,
|
||||
editingReview: null,
|
||||
stats: {
|
||||
total_reviews: 0,
|
||||
average_rating: 0,
|
||||
success_stories: 0,
|
||||
rating_distribution: {}
|
||||
rating_distribution: {5: 0, 4: 0, 3: 0, 2: 0, 1: 0}
|
||||
},
|
||||
ratingFilters: [
|
||||
{ value: 'all', label: 'Все оценки' },
|
||||
@@ -381,16 +382,21 @@ export default {
|
||||
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 })
|
||||
this.reviews = response.data.reviews
|
||||
this.totalPages = response.data.total_pages
|
||||
this.reviews = response.data.reviews || []
|
||||
this.totalPages = response.data.total_pages || 1
|
||||
this.totalItems = response.data.total_items || 0
|
||||
this.currentPage = response.data.current_page || 1
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке отзывов:', error)
|
||||
this.$notify({
|
||||
type: 'error',
|
||||
title: 'Ошибка',
|
||||
text: 'Не удалось загрузить отзывы'
|
||||
})
|
||||
this.showNotification('error', 'Ошибка', 'Не удалось загрузить отзывы')
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
@@ -402,34 +408,47 @@ export default {
|
||||
this.stats = response.data
|
||||
} catch (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() {
|
||||
try {
|
||||
const token = localStorage.getItem('auth_token') // Используем auth_token вместо token
|
||||
const token = localStorage.getItem('auth_token')
|
||||
if (token) {
|
||||
// Пробуем получить профиль для проверки токена
|
||||
try {
|
||||
// Пытаемся получить профиль пользователя для проверки авторизации
|
||||
const response = await apiClient.get('/user/profile')
|
||||
this.isAuthenticated = true
|
||||
this.currentUser = response.data
|
||||
} catch (error) {
|
||||
// Если запрос профиля失敗, считаем неавторизованным
|
||||
console.error('Токен невалиден:', error)
|
||||
this.isAuthenticated = false
|
||||
localStorage.removeItem('auth_token')
|
||||
this.handleAuthError()
|
||||
}
|
||||
} else {
|
||||
this.isAuthenticated = false
|
||||
this.currentUser = null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка проверки авторизации:', error)
|
||||
this.isAuthenticated = false
|
||||
localStorage.removeItem('auth_token')
|
||||
this.handleAuthError()
|
||||
}
|
||||
},
|
||||
|
||||
handleAuthError() {
|
||||
this.isAuthenticated = false
|
||||
this.currentUser = null
|
||||
localStorage.removeItem('auth_token')
|
||||
// НЕ перенаправляем на login, чтобы пользователь мог просматривать отзывы
|
||||
},
|
||||
|
||||
getInitials(author) {
|
||||
if (!author) return '?'
|
||||
const firstName = author.first_name || ''
|
||||
@@ -442,12 +461,17 @@ export default {
|
||||
},
|
||||
|
||||
formatDate(dateString) {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})
|
||||
try {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
return 'Неизвестная дата'
|
||||
}
|
||||
},
|
||||
|
||||
filterByRating(rating) {
|
||||
@@ -457,9 +481,11 @@ export default {
|
||||
},
|
||||
|
||||
changePage(page) {
|
||||
this.currentPage = page
|
||||
this.loadReviews()
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
if (page >= 1 && page <= this.totalPages) {
|
||||
this.currentPage = page
|
||||
this.loadReviews()
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
},
|
||||
|
||||
getRatingCount(rating) {
|
||||
@@ -473,12 +499,17 @@ export default {
|
||||
},
|
||||
|
||||
isMyReview(review) {
|
||||
// В реальном приложении сравнивать с ID текущего пользователя
|
||||
return this.isAuthenticated && review.author && review.author.id === this.currentUser?.id
|
||||
return this.isAuthenticated &&
|
||||
this.currentUser &&
|
||||
review.author &&
|
||||
review.author.id === this.currentUser.id
|
||||
},
|
||||
|
||||
async submitReview() {
|
||||
if (!this.isFormValid) return
|
||||
if (!this.isFormValid) {
|
||||
this.showNotification('error', 'Ошибка', 'Пожалуйста, заполните все обязательные поля правильно')
|
||||
return
|
||||
}
|
||||
|
||||
this.submitting = true
|
||||
|
||||
@@ -492,33 +523,55 @@ export default {
|
||||
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.loadReviews() // Перезагружаем список отзывов
|
||||
this.loadStats() // Обновляем статистику
|
||||
|
||||
this.$notify({
|
||||
type: 'success',
|
||||
title: 'Успех',
|
||||
text: 'Спасибо за ваш отзыв! После проверки модератором он будет опубликован.'
|
||||
})
|
||||
this.loadReviews()
|
||||
this.loadStats()
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка при отправке отзыва:', error)
|
||||
this.$notify({
|
||||
type: 'error',
|
||||
title: 'Ошибка',
|
||||
text: 'Произошла ошибка при отправке отзыва. Попробуйте еще раз.'
|
||||
})
|
||||
let errorMessage = 'Произошла ошибка при отправке отзыва. Попробуйте еще раз.'
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
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 {
|
||||
this.submitting = false
|
||||
}
|
||||
},
|
||||
|
||||
async editReview(review) {
|
||||
// Реализация редактирования отзыва
|
||||
console.log('Редактирование отзыва:', review)
|
||||
this.editingReview = 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) {
|
||||
@@ -529,22 +582,25 @@ export default {
|
||||
try {
|
||||
await apiClient.delete(`/reviews/${reviewId}`)
|
||||
|
||||
this.loadReviews() // Перезагружаем список отзывов
|
||||
this.loadStats() // Обновляем статистику
|
||||
this.loadReviews()
|
||||
this.loadStats()
|
||||
|
||||
this.$notify({
|
||||
type: 'success',
|
||||
title: 'Успех',
|
||||
text: 'Отзыв успешно удален'
|
||||
})
|
||||
this.showNotification('success', 'Успех', 'Отзыв успешно удален')
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка при удалении отзыва:', error)
|
||||
this.$notify({
|
||||
type: 'error',
|
||||
title: 'Ошибка',
|
||||
text: 'Не удалось удалить отзыв'
|
||||
})
|
||||
let errorMessage = 'Не удалось удалить отзыв'
|
||||
|
||||
if (error.response?.status === 403) {
|
||||
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: '',
|
||||
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() {
|
||||
@@ -566,6 +649,11 @@ export default {
|
||||
},
|
||||
watch: {
|
||||
sortBy() {
|
||||
this.currentPage = 1
|
||||
this.loadReviews()
|
||||
},
|
||||
activeRatingFilter() {
|
||||
this.currentPage = 1
|
||||
this.loadReviews()
|
||||
}
|
||||
}
|
||||
@@ -573,9 +661,193 @@ export default {
|
||||
</script>
|
||||
|
||||
<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 {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
"math"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -88,39 +89,41 @@ func (r *reviewRepository) Delete(id uint) error {
|
||||
}
|
||||
|
||||
func (r *reviewRepository) GetStats() (*models.ReviewsStatsResponse, error) {
|
||||
var totalReviews int64
|
||||
var averageRating float64
|
||||
var successStories int64
|
||||
var stats models.ReviewsStatsResponse
|
||||
|
||||
// Общее количество отзывов
|
||||
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
|
||||
}
|
||||
|
||||
// Средний рейтинг
|
||||
if err := r.db.Model(&models.Review{}).Select("AVG(rating)").Row().Scan(&averageRating); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.TotalReviews = result.TotalReviews
|
||||
stats.AverageRating = math.Round(result.AverageRating*100) / 100 // Округляем до 2 знаков
|
||||
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()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.RatingDistribution = ratingDistribution
|
||||
|
||||
return &models.ReviewsStatsResponse{
|
||||
TotalReviews: int(totalReviews),
|
||||
AverageRating: averageRating,
|
||||
SuccessStories: int(successStories),
|
||||
RatingDistribution: ratingDistribution,
|
||||
}, nil
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
func (r *reviewRepository) GetRatingDistribution() (map[int]int, error) {
|
||||
|
||||
Reference in New Issue
Block a user