Files
UniVerse/frontend/src/views/admin/AdminReviewsView.vue
T
serega404 934682f035
Frontend CI / build-and-check (push) Failing after 5m10s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 15s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 18s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 32s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 13s
feat: Добавил постраничную загрузку отзывов
2026-05-18 02:54:28 +03:00

283 lines
8.3 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import GlassCard from '@/components/ui/GlassCard.vue'
import DataTable from '@/components/ui/DataTable.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import { reviewsApi } from '@/api'
import type { ReviewDto } from '@/api/types'
const columns = [
{ key: 'id', label: 'ID' },
{ key: 'lecture', label: 'Лекция' },
{ key: 'student', label: 'Студент' },
{ key: 'rating', label: 'Оценка', align: 'center' },
{ key: 'text', label: 'Текст' },
{ key: 'date', label: 'Дата' },
{ key: 'status', label: 'LLM-статус', align: 'center' },
{ key: 'sentiment', label: 'Sentiment', align: 'center' },
{ key: 'quality', label: 'Quality', align: 'center' },
{ key: 'informativeTags', label: 'Informative / tags' },
{ key: 'actions', label: 'Действия', align: 'right' },
]
const statusMap: Record<string, string> = {
Pending: 'pending',
Analyzed: 'done',
Rejected: 'rejected',
}
const ratingLabel: Record<string, string> = {
Like: '👍 Like',
Neutral: '😐 Neutral',
Dislike: '👎 Dislike',
}
const reviews = ref<ReviewDto[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = ref(20)
const totalCount = ref(0)
const totalPages = ref(0)
const reanalyzingId = ref<number | null>(null)
const error = ref('')
const rows = computed(() =>
reviews.value.map((review) => ({
id: review.id,
lecture: review.lectureTitle || `#${review.lectureId}`,
student: review.userName || `#${review.userId}`,
rating: ratingLabel[review.rating] ?? review.rating,
text: review.text || '—',
date: new Date(review.createdAt).toLocaleString('ru-RU'),
status: statusMap[review.llmStatus] ?? review.llmStatus,
rawStatus: review.llmStatus,
sentiment: review.sentiment,
quality: review.qualityScore,
informative: review.isInformative,
tags: review.llmTags ?? [],
informativeTags: [
review.isInformative === null || review.isInformative === undefined
? '—'
: review.isInformative
? 'Да'
: 'Нет',
...(review.llmTags ?? []),
].join(' / '),
})),
)
const pageStart = computed(() =>
totalCount.value === 0 ? 0 : (page.value - 1) * pageSize.value + 1,
)
const pageEnd = computed(() => Math.min(page.value * pageSize.value, totalCount.value))
const canGoPrev = computed(() => page.value > 1)
const canGoNext = computed(() => page.value < totalPages.value)
function formatQuality(value: number | null | undefined) {
if (value === null || value === undefined) return '—'
return Number(value).toFixed(2)
}
async function fetchReviews() {
loading.value = true
error.value = ''
try {
const result = await reviewsApi.listPage({
Page: page.value,
PageSize: pageSize.value,
})
reviews.value = result.items
totalCount.value = result.totalCount
totalPages.value = result.totalPages
} catch (err) {
error.value = err instanceof Error ? err.message : 'Не удалось загрузить отзывы.'
} finally {
loading.value = false
}
}
async function goToPage(nextPage: number) {
if (nextPage < 1 || (totalPages.value && nextPage > totalPages.value)) return
page.value = nextPage
await fetchReviews()
}
async function reanalyze(id: number) {
reanalyzingId.value = id
try {
await reviewsApi.reanalyze(id)
await fetchReviews()
} catch (err) {
error.value = err instanceof Error ? err.message : 'Не удалось запустить повторный анализ.'
} finally {
reanalyzingId.value = null
}
}
onMounted(fetchReviews)
</script>
<template>
<div class="admin-reviews page-content">
<div class="header">
<div>
<h1 class="page-title">Отзывы</h1>
<p class="page-subtitle">Все отзывы студентов и результаты LLM-анализа.</p>
</div>
<button class="btn-primary" :disabled="loading" @click="fetchReviews">
{{ loading ? 'Загрузка...' : 'Обновить отзывы' }}
</button>
</div>
<GlassCard>
<EmptyState v-if="error" title="Не удалось загрузить отзывы" :subtitle="error" />
<EmptyState
v-else-if="!rows.length && !loading"
title="Отзывов нет"
subtitle="Пока нет ни одного отзыва."
/>
<div v-else class="table-section">
<div class="pagination-bar">
<span> Показаны {{ pageStart }}{{ pageEnd }} из {{ totalCount }} отзывов </span>
<div class="pagination-actions">
<button class="btn-ghost" :disabled="loading || !canGoPrev" @click="goToPage(page - 1)">
Назад
</button>
<span>Страница {{ page }} из {{ totalPages || 1 }}</span>
<button class="btn-ghost" :disabled="loading || !canGoNext" @click="goToPage(page + 1)">
Вперёд
</button>
</div>
</div>
<DataTable :columns="columns" :rows="rows">
<template #text="{ value }">
<span class="review-text" :title="value">{{ value }}</span>
</template>
<template #status="{ value, row }">
<StatusBadge :status="value" />
<span class="raw-status">{{ row.rawStatus }}</span>
</template>
<template #sentiment="{ value }">
<span
class="badge"
:class="
value === 'Positive'
? 'badge-green'
: value === 'Negative'
? 'badge-red'
: 'badge-orange'
"
>
{{ value }}
</span>
</template>
<template #quality="{ value }">
<span
:class="
(value ?? 0) >= 0.7
? 'badge badge-green'
: (value ?? 0) >= 0.4
? 'badge badge-orange'
: 'badge badge-red'
"
>
{{ formatQuality(value) }}
</span>
</template>
<template #informativeTags="{ row }">
<div class="tags-cell">
<span
class="badge"
:class="
row.informative
? 'badge-green'
: row.informative === false
? 'badge-red'
: 'badge-gray'
"
>
{{
row.informative === null || row.informative === undefined
? '—'
: row.informative
? 'Informative'
: 'Not informative'
}}
</span>
<span v-for="tag in row.tags" :key="tag" class="badge badge-blue">{{ tag }}</span>
</div>
</template>
<template #actions="{ row }">
<button
class="btn-ghost"
:disabled="reanalyzingId === row.id"
@click="reanalyze(row.id)"
>
{{ reanalyzingId === row.id ? 'Запускаем...' : 'Повторить анализ' }}
</button>
</template>
</DataTable>
</div>
</GlassCard>
</div>
</template>
<style scoped>
.admin-reviews {
display: flex;
flex-direction: column;
gap: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.page-subtitle {
margin: 4px 0 0;
color: var(--color-text-secondary);
}
.review-text {
display: inline-block;
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
.raw-status {
display: block;
margin-top: 4px;
color: var(--color-text-secondary);
font-size: 11px;
}
.table-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
color: var(--color-text-secondary);
font-size: 13px;
}
.pagination-actions {
display: flex;
align-items: center;
gap: 8px;
}
.tags-cell {
display: flex;
flex-wrap: wrap;
gap: 4px;
min-width: 160px;
}
</style>