feat: добавил вкладку с отзывами для админа
Backend CI / build-and-test (push) Failing after 28s
Frontend CI / build-and-check (push) Failing after 5m9s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 13s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 31s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 23s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 11s
Backend CI / build-and-test (push) Failing after 28s
Frontend CI / build-and-check (push) Failing after 5m9s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 13s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 31s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 23s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 11s
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
<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 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(' / '),
|
||||
})),
|
||||
)
|
||||
|
||||
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 {
|
||||
reviews.value = await reviewsApi.list({ PageSize: 100 })
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Не удалось загрузить отзывы.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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="Пока нет ни одного отзыва."
|
||||
/>
|
||||
<DataTable v-else :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>
|
||||
</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;
|
||||
}
|
||||
.tags-cell {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
min-width: 160px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user