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
560 lines
17 KiB
Vue
560 lines
17 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 { 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>
|