Files
UniVerse/frontend/src/views/admin/AdminReviewsView.vue
T
serega404 7f923cd612
Frontend CI / build-and-check (push) Successful in 26s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 6s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 13s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 35s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 3s
Backend CI / build-and-test (pull_request) Successful in 1m6s
Frontend CI / build-and-check (pull_request) Successful in 49s
Frontend Playwright / e2e (pull_request) Successful in 10m53s
feat: обновил типы в DataTable
2026-05-28 20:00:05 +03:00

560 lines
17 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 { ApiReviewLlmStatus, ApiReviewSentiment, ReviewDto } from '@/api/types'
type DataTableColumn = { key: string; label: string; align?: 'left' | 'center' | 'right' }
const columns: DataTableColumn[] = [
{ key: 'id', label: 'ID' },
{ key: 'lecture', label: 'Лекция' },
{ key: 'student', label: 'Студент' },
{ key: 'rating', label: 'Оценка', align: 'center' },
{ key: 'text', label: 'Текст' },
{ key: 'date', label: 'Дата' },
{ key: 'analysis', label: 'Результат нейронки' },
{ 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 statusFilters: Array<{ label: string; value: ApiReviewLlmStatus | 'All' }> = [
{ label: 'Все', value: 'All' },
{ label: 'На проверке', value: 'Pending' },
{ label: 'Проверены', value: 'Analyzed' },
{ label: 'Отклонены', value: 'Rejected' },
]
const sentimentLabel: Record<ApiReviewSentiment, string> = {
Positive: 'Позитивный',
Neutral: 'Нейтральный',
Negative: 'Негативный',
}
const sentimentClass: Record<ApiReviewSentiment, string> = {
Positive: 'badge-green',
Neutral: 'badge-orange',
Negative: 'badge-red',
}
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 statusFilter = ref<ApiReviewLlmStatus | 'All'>('All')
const reanalyzingId = ref<number | null>(null)
const error = ref('')
const promptText = ref('')
const savedPromptText = ref('')
const promptUpdatedAt = ref<string | null>(null)
const promptLoading = ref(false)
const promptSaving = ref(false)
const promptError = ref('')
const promptSuccess = ref('')
const rows = computed(() =>
reviews.value.map((review) => {
const analysisReady = review.llmStatus === 'Analyzed'
const sentiment = analysisReady ? review.sentiment : null
const tags = analysisReady ? normalizeTags(review.llmTags) : []
return {
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,
analysis: review.llmStatus,
analysisReady,
analysisMessage: getAnalysisMessage(review),
sentiment,
sentimentLabel: sentiment ? sentimentLabel[sentiment] : '—',
sentimentClass: sentiment ? sentimentClass[sentiment] : 'badge-gray',
quality: analysisReady ? review.qualityScore : null,
qualityLabel: analysisReady ? formatQuality(review.qualityScore) : '—',
qualityClass: getQualityClass(analysisReady ? review.qualityScore : null),
informative: analysisReady ? review.isInformative : null,
informativeLabel: analysisReady ? formatInformative(review.isInformative) : '—',
informativeClass: getInformativeClass(analysisReady ? review.isInformative : null),
tags,
llmRawOutput: analysisReady ? formatRawOutput(review.llmRawOutput) : '',
}
}),
)
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)
const promptStatusLabel = computed(() => {
if (promptLoading.value) return 'Загрузка...'
if (!promptUpdatedAt.value) return 'Базовый промпт'
return `Обновлён ${new Date(promptUpdatedAt.value).toLocaleString('ru-RU')}`
})
const canSavePrompt = computed(
() =>
!promptLoading.value &&
!promptSaving.value &&
promptText.value.trim().length > 0 &&
promptText.value !== savedPromptText.value,
)
function normalizeTags(tags: string[] | null | undefined) {
return Array.from(new Set((tags ?? []).map((tag) => tag.trim()).filter(Boolean)))
}
function formatQuality(value: number | null | undefined) {
if (value === null || value === undefined) return '—'
const numeric = Number(value)
if (!Number.isFinite(numeric)) return '—'
const score = Math.max(0, Math.min(1, numeric))
return `${Math.round(score * 100)}%`
}
function getQualityClass(value: number | null | undefined) {
if (value === null || value === undefined) return 'badge-gray'
if (!Number.isFinite(Number(value))) return 'badge-gray'
if (value >= 0.7) return 'badge-green'
if (value >= 0.4) return 'badge-orange'
return 'badge-red'
}
function formatInformative(value: boolean | null | undefined) {
if (value === null || value === undefined) return '—'
return value ? 'Информативный' : 'Неинформативный'
}
function getInformativeClass(value: boolean | null | undefined) {
if (value === null || value === undefined) return 'badge-gray'
return value ? 'badge-green' : 'badge-red'
}
function getAnalysisMessage(review: ReviewDto) {
if (review.llmStatus === 'Pending') return 'Ожидает обработки'
if (review.llmStatus === 'Rejected') return 'Отзыв отклонён нейронкой'
return 'Анализ завершён, данных нет'
}
function formatRawOutput(value: string | null | undefined) {
return value?.trim() || ''
}
async function fetchPrompt() {
promptLoading.value = true
promptError.value = ''
promptSuccess.value = ''
try {
const result = await reviewsApi.getPrompt()
promptText.value = result.prompt
savedPromptText.value = result.prompt
promptUpdatedAt.value = result.updatedAt ?? null
} catch (err) {
promptError.value = err instanceof Error ? err.message : 'Не удалось загрузить промпт.'
} finally {
promptLoading.value = false
}
}
async function savePrompt() {
promptError.value = ''
promptSuccess.value = ''
if (!promptText.value.trim()) {
promptError.value = 'Промпт не должен быть пустым.'
return
}
promptSaving.value = true
try {
const result = await reviewsApi.updatePrompt({ prompt: promptText.value })
promptText.value = result.prompt
savedPromptText.value = result.prompt
promptUpdatedAt.value = result.updatedAt ?? null
promptSuccess.value = 'Промпт сохранён.'
} catch (err) {
promptError.value = err instanceof Error ? err.message : 'Не удалось сохранить промпт.'
} finally {
promptSaving.value = false
}
}
async function fetchReviews() {
loading.value = true
error.value = ''
try {
const result = await reviewsApi.listPage({
Page: page.value,
PageSize: pageSize.value,
...(statusFilter.value === 'All' ? {} : { LlmStatus: statusFilter.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 selectStatusFilter(nextStatus: ApiReviewLlmStatus | 'All') {
if (statusFilter.value === nextStatus) return
statusFilter.value = nextStatus
page.value = 1
await fetchReviews()
}
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(() => {
void fetchPrompt()
void 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>
<div class="review-toolbar">
<div class="status-filters" aria-label="Фильтр статуса LLM">
<button
v-for="filter in statusFilters"
:key="filter.value"
type="button"
:class="{ active: statusFilter === filter.value }"
:disabled="loading"
@click="selectStatusFilter(filter.value)"
>
{{ filter.label }}
</button>
</div>
</div>
<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="{ row }">
<span class="review-text" :title="row.text">{{ row.text }}</span>
</template>
<template #analysis="{ row }">
<div class="analysis-cell">
<div v-if="!row.analysisReady" class="analysis-head">
<StatusBadge :status="row.status" />
</div>
<div v-if="row.analysisReady" class="analysis-result">
<div class="analysis-metrics">
<span class="badge" :class="row.qualityClass">
Качество {{ row.qualityLabel }}
</span>
<span class="badge" :class="row.sentimentClass">{{ row.sentimentLabel }}</span>
<span class="badge" :class="row.informativeClass">
{{ row.informativeLabel }}
</span>
</div>
<div v-if="row.tags.length" class="analysis-tags">
<span v-for="tag in row.tags" :key="tag" class="badge badge-blue">{{ tag }}</span>
</div>
<span v-else class="analysis-note">Теги не найдены</span>
<details v-if="row.llmRawOutput" class="raw-output">
<summary>Текст ответа нейронки</summary>
<pre>{{ row.llmRawOutput }}</pre>
</details>
<span v-else class="analysis-note">Текст ответа нейронки не сохранён</span>
</div>
</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>
<GlassCard>
<div class="prompt-header">
<div>
<div class="section-title">Промпт LLM-анализа</div>
<p class="prompt-subtitle">
Шаблон применяется к новым проверкам и ручному повторному анализу отзывов.
</p>
</div>
<span class="prompt-status">{{ promptStatusLabel }}</span>
</div>
<form class="prompt-form" @submit.prevent="savePrompt">
<textarea
v-model="promptText"
class="glass-input prompt-textarea"
rows="9"
:disabled="promptLoading || promptSaving"
placeholder="Загрузка промпта..."
></textarea>
<div class="prompt-footer">
<div class="prompt-messages">
<span class="prompt-hint"
>Обязательные плейсхолдеры: {lectureContext}, {reviewText}</span
>
<span v-if="promptError" class="prompt-error">{{ promptError }}</span>
<span v-else-if="promptSuccess" class="prompt-success">{{ promptSuccess }}</span>
</div>
<button class="btn-primary" type="submit" :disabled="!canSavePrompt">
{{ promptSaving ? 'Сохраняем...' : 'Сохранить промпт' }}
</button>
</div>
</form>
</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);
}
.section-title {
font-size: 16px;
font-weight: 700;
}
.prompt-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.prompt-subtitle {
margin: 4px 0 0;
color: var(--color-text-secondary);
font-size: 13px;
}
.prompt-status {
flex: 0 0 auto;
border: 1px solid var(--color-border-glass);
border-radius: 999px;
padding: 5px 10px;
background: var(--color-white-a72);
color: var(--color-text-secondary);
font-size: 12px;
}
.prompt-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.prompt-textarea {
min-height: 250px;
resize: vertical;
line-height: 1.45;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
}
.prompt-textarea:disabled {
color: var(--color-text-secondary);
cursor: wait;
}
.prompt-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.prompt-messages {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 240px;
color: var(--color-text-secondary);
font-size: 12px;
}
.prompt-error {
color: var(--color-error);
}
.prompt-success {
color: var(--color-success-text);
}
.review-text {
display: inline-block;
max-width: 320px;
white-space: pre-wrap;
overflow-wrap: anywhere;
vertical-align: middle;
}
.table-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.review-toolbar {
display: flex;
justify-content: flex-start;
margin-bottom: 12px;
}
.status-filters {
display: inline-flex;
border: 1px solid var(--color-border-glass);
border-radius: 10px;
overflow: hidden;
}
.status-filters button {
background: var(--color-white-a70);
border: none;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 13px;
padding: 8px 14px;
}
.status-filters button.active {
background: var(--color-primary-a18);
color: var(--color-primary-dark);
font-weight: 600;
}
.status-filters button:disabled {
cursor: wait;
opacity: 0.7;
}
.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;
}
.analysis-cell {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 260px;
}
.analysis-head,
.analysis-metrics,
.analysis-tags {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.analysis-result {
display: flex;
flex-direction: column;
gap: 6px;
}
.analysis-note {
color: var(--color-text-secondary);
font-size: 12px;
}
.raw-output {
max-width: 640px;
}
.raw-output summary {
color: var(--color-primary-dark);
cursor: pointer;
font-size: 12px;
font-weight: 600;
}
.raw-output pre {
margin: 6px 0 0;
max-height: 220px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
border: 1px solid var(--color-border-glass);
border-radius: 8px;
background: var(--color-white-a60);
padding: 10px;
color: var(--color-text);
font-size: 12px;
line-height: 1.45;
}
</style>