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
283 lines
8.3 KiB
Vue
283 lines
8.3 KiB
Vue
<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>
|