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:
@@ -43,6 +43,20 @@ public class ReviewsController : ControllerBase
|
|||||||
public async Task<ActionResult<ReviewDto>> Create([FromBody] CreateReviewRequest req) =>
|
public async Task<ActionResult<ReviewDto>> Create([FromBody] CreateReviewRequest req) =>
|
||||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _reviews.CreateAsync(CurrentUserId, req));
|
CreatedAtAction(nameof(Get), new { id = 0 }, await _reviews.CreateAsync(CurrentUserId, req));
|
||||||
|
|
||||||
|
/// <summary>Получить список всех отзывов.</summary>
|
||||||
|
/// <remarks>Только Admin. Возвращает все отзывы независимо от LLM-статуса.</remarks>
|
||||||
|
/// <param name="pagination">Параметры пагинации.</param>
|
||||||
|
/// <response code="200">Список всех отзывов (пагинированный).</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResult<ReviewDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<ActionResult> List([FromQuery] PaginationRequest pagination) =>
|
||||||
|
Ok(await _reviews.GetAllAsync(pagination));
|
||||||
|
|
||||||
/// <summary>Получить отзыв по ID.</summary>
|
/// <summary>Получить отзыв по ID.</summary>
|
||||||
/// <remarks>Только Admin или Teacher.</remarks>
|
/// <remarks>Только Admin или Teacher.</remarks>
|
||||||
/// <param name="id">ID отзыва.</param>
|
/// <param name="id">ID отзыва.</param>
|
||||||
|
|||||||
@@ -2503,6 +2503,68 @@
|
|||||||
"Bearer": [ ]
|
"Bearer": [ ]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Reviews"
|
||||||
|
],
|
||||||
|
"summary": "Получить список всех отзывов.",
|
||||||
|
"description": "Только Admin. Возвращает все отзывы независимо от LLM-статуса.\n\n**Required roles:** Admin",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Page",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PageSize",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Список всех отзывов (пагинированный).",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ReviewDtoPagedResult"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Требуется аутентификация.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Требуется роль Admin.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"Bearer": [ ]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/reviews/{id}": {
|
"/api/v1/reviews/{id}": {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public interface IReviewService
|
|||||||
Task DeleteAsync(int id, int userId, bool isAdmin = false);
|
Task DeleteAsync(int id, int userId, bool isAdmin = false);
|
||||||
Task<PagedResult<ReviewDto>> GetByLectureAsync(int lectureId, PaginationRequest pagination);
|
Task<PagedResult<ReviewDto>> GetByLectureAsync(int lectureId, PaginationRequest pagination);
|
||||||
Task<PagedResult<ReviewDto>> GetByUserAsync(int userId, PaginationRequest pagination);
|
Task<PagedResult<ReviewDto>> GetByUserAsync(int userId, PaginationRequest pagination);
|
||||||
|
Task<PagedResult<ReviewDto>> GetAllAsync(PaginationRequest pagination);
|
||||||
Task<PagedResult<ReviewDto>> GetPendingAsync(PaginationRequest pagination);
|
Task<PagedResult<ReviewDto>> GetPendingAsync(PaginationRequest pagination);
|
||||||
Task ReanalyzeAsync(int id);
|
Task ReanalyzeAsync(int id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,15 @@ public class ReviewService : IReviewService
|
|||||||
return PagedResult<ReviewDto>.Create(items.Select(r => r.ToDto()).ToList(), total, pagination.Page, pagination.PageSize);
|
return PagedResult<ReviewDto>.Create(items.Select(r => r.ToDto()).ToList(), total, pagination.Page, pagination.PageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResult<ReviewDto>> GetAllAsync(PaginationRequest pagination)
|
||||||
|
{
|
||||||
|
var query = BaseQuery();
|
||||||
|
var total = await query.CountAsync();
|
||||||
|
var items = await query.OrderByDescending(r => r.CreatedAt)
|
||||||
|
.Skip((pagination.Page - 1) * pagination.PageSize).Take(pagination.PageSize).ToListAsync();
|
||||||
|
return PagedResult<ReviewDto>.Create(items.Select(r => r.ToDto()).ToList(), total, pagination.Page, pagination.PageSize);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<PagedResult<ReviewDto>> GetPendingAsync(PaginationRequest pagination)
|
public async Task<PagedResult<ReviewDto>> GetPendingAsync(PaginationRequest pagination)
|
||||||
{
|
{
|
||||||
var query = BaseQuery().Where(r => r.LlmStatus == ReviewLlmStatus.Pending);
|
var query = BaseQuery().Where(r => r.LlmStatus == ReviewLlmStatus.Pending);
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ export const reviewsApi = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ lectureId: Number(lectureId), rating, text }),
|
body: JSON.stringify({ lectureId: Number(lectureId), rating, text }),
|
||||||
}),
|
}),
|
||||||
|
async list(query: Record<string, unknown> = { PageSize: 100 }) {
|
||||||
|
const payload = await apiRequest<PagedResult<ReviewDto> | ReviewDto[]>('/reviews', { query })
|
||||||
|
return extractItems(payload)
|
||||||
|
},
|
||||||
async pending() {
|
async pending() {
|
||||||
const payload = await apiRequest<PagedResult<ReviewDto> | ReviewDto[]>('/reviews/pending')
|
const payload = await apiRequest<PagedResult<ReviewDto> | ReviewDto[]>('/reviews/pending')
|
||||||
return extractItems(payload)
|
return extractItems(payload)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const navItems: NavItem[] = [
|
|||||||
{ label: 'Пользователи', icon: 'users', to: '/admin/users', roles: ['admin'] },
|
{ label: 'Пользователи', icon: 'users', to: '/admin/users', roles: ['admin'] },
|
||||||
{ label: 'Лекции', icon: 'books', to: '/admin/lectures', roles: ['admin'] },
|
{ label: 'Лекции', icon: 'books', to: '/admin/lectures', roles: ['admin'] },
|
||||||
{ label: 'ИИ очередь', icon: 'robot', to: '/admin/llm-queue', roles: ['admin'] },
|
{ label: 'ИИ очередь', icon: 'robot', to: '/admin/llm-queue', roles: ['admin'] },
|
||||||
|
{ label: 'Отзывы', icon: 'message-circle', to: '/admin/reviews', roles: ['admin'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
const visible = computed(() =>
|
const visible = computed(() =>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const router = createRouter({
|
|||||||
{ path: '/admin/users', name: 'admin-users', component: () => import('@/views/admin/AdminUsersView.vue'), meta: { role: 'admin' } },
|
{ path: '/admin/users', name: 'admin-users', component: () => import('@/views/admin/AdminUsersView.vue'), meta: { role: 'admin' } },
|
||||||
{ path: '/admin/lectures', name: 'admin-lectures', component: () => import('@/views/admin/AdminLecturesView.vue'), meta: { role: 'admin' } },
|
{ path: '/admin/lectures', name: 'admin-lectures', component: () => import('@/views/admin/AdminLecturesView.vue'), meta: { role: 'admin' } },
|
||||||
{ path: '/admin/llm-queue', name: 'admin-llm', component: () => import('@/views/admin/AdminLLMQueueView.vue'), meta: { role: 'admin' } },
|
{ path: '/admin/llm-queue', name: 'admin-llm', component: () => import('@/views/admin/AdminLLMQueueView.vue'), meta: { role: 'admin' } },
|
||||||
|
{ path: '/admin/reviews', name: 'admin-reviews', component: () => import('@/views/admin/AdminReviewsView.vue'), meta: { role: 'admin' } },
|
||||||
|
|
||||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||||
|
import AppIcon from '@/components/ui/AppIcon.vue'
|
||||||
import { reviewsApi } from '@/api'
|
import { reviewsApi } from '@/api'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -44,7 +45,7 @@ async function submit() {
|
|||||||
|
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<div v-if="submitted && !editing" class="success-state">
|
<div v-if="submitted && !editing" class="success-state">
|
||||||
<div class="success-icon">✅</div>
|
<AppIcon class="success-icon" icon="circle-check" :size="32" />
|
||||||
<div class="success-title">Отзыв отправлен и будет обработан</div>
|
<div class="success-title">Отзыв отправлен и будет обработан</div>
|
||||||
<div class="success-sub">
|
<div class="success-sub">
|
||||||
Спасибо! Оценка полезности отзыва рассчитывается автоматически. Студентам не показывается техническая оценка LLM.
|
Спасибо! Оценка полезности отзыва рассчитывается автоматически. Студентам не показывается техническая оценка LLM.
|
||||||
@@ -107,7 +108,7 @@ textarea {
|
|||||||
.hint { font-size: 12px; color: var(--color-text-secondary); background: rgba(255,255,255,0.6); padding: 8px 12px; border-radius: var(--radius-sm); }
|
.hint { font-size: 12px; color: var(--color-text-secondary); background: rgba(255,255,255,0.6); padding: 8px 12px; border-radius: var(--radius-sm); }
|
||||||
.form-actions { display: flex; gap: 10px; }
|
.form-actions { display: flex; gap: 10px; }
|
||||||
.success-state { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
|
.success-state { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
|
||||||
.success-icon { font-size: 28px; }
|
.success-icon { color: var(--color-primary); }
|
||||||
.success-title { font-size: 16px; font-weight: 700; }
|
.success-title { font-size: 16px; font-weight: 700; }
|
||||||
.success-sub { font-size: 13px; color: var(--color-text-secondary); }
|
.success-sub { font-size: 13px; color: var(--color-text-secondary); }
|
||||||
.error { color: var(--color-error); font-size: 13px; }
|
.error { color: var(--color-error); font-size: 13px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user