feat: Добавил постраничную загрузку отзывов
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

This commit is contained in:
2026-05-18 02:54:28 +03:00
parent b984d29c50
commit 934682f035
5 changed files with 366 additions and 98 deletions
+41 -6
View File
@@ -10,6 +10,7 @@ import type {
LocationDto, LocationDto,
PagedResult, PagedResult,
ReviewDto, ReviewDto,
ReviewQuery,
SyncResultDto, SyncResultDto,
SyncScheduleRequest, SyncScheduleRequest,
SyncStatusDto, SyncStatusDto,
@@ -115,19 +116,53 @@ export const notificationsApi = {
markAllRead: () => apiRequest<void>('/notifications/read-all', { method: 'PATCH' }), markAllRead: () => apiRequest<void>('/notifications/read-all', { method: 'PATCH' }),
} }
function normalizePagedResult<T>(
payload: PagedResult<T> | T[] | undefined,
query: { Page?: number; PageSize?: number } = {},
): PagedResult<T> {
if (!Array.isArray(payload) && payload) return payload
const items = payload ?? []
const page = query.Page ?? 1
const pageSize = query.PageSize ?? items.length
const totalPages = pageSize > 0 ? Math.ceil(items.length / pageSize) : 0
return {
items,
totalCount: items.length,
page,
pageSize,
totalPages,
}
}
async function listReviewsPage(query: ReviewQuery = {}) {
const payload = await apiRequest<PagedResult<ReviewDto> | ReviewDto[]>('/reviews', {
query: query as Record<string, unknown>,
})
return normalizePagedResult(payload, query)
}
async function listPendingReviewsPage(query: ReviewQuery = {}) {
const payload = await apiRequest<PagedResult<ReviewDto> | ReviewDto[]>('/reviews/pending', {
query: query as Record<string, unknown>,
})
return normalizePagedResult(payload, query)
}
export const reviewsApi = { export const reviewsApi = {
create: (lectureId: string | number, rating: 'Like' | 'Neutral' | 'Dislike', text: string) => create: (lectureId: string | number, rating: 'Like' | 'Neutral' | 'Dislike', text: string) =>
apiRequest<ReviewDto>('/reviews', { apiRequest<ReviewDto>('/reviews', {
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 }) { listPage: listReviewsPage,
const payload = await apiRequest<PagedResult<ReviewDto> | ReviewDto[]>('/reviews', { query }) async list(query: ReviewQuery = { PageSize: 100 }) {
return extractItems(payload) return (await listReviewsPage(query)).items
}, },
async pending() { pendingPage: listPendingReviewsPage,
const payload = await apiRequest<PagedResult<ReviewDto> | ReviewDto[]>('/reviews/pending') async pending(query: ReviewQuery = { PageSize: 100 }) {
return extractItems(payload) return (await listPendingReviewsPage(query)).items
}, },
reanalyze: (id: string | number) => reanalyze: (id: string | number) =>
apiRequest<void>(`/reviews/${id}/reanalyze`, { method: 'POST' }), apiRequest<void>(`/reviews/${id}/reanalyze`, { method: 'POST' }),
+5
View File
@@ -99,6 +99,11 @@ export interface CreateLectureRequest {
onlineUrl?: string | null onlineUrl?: string | null
} }
export interface ReviewQuery {
Page?: number
PageSize?: number
}
export interface ReviewDto { export interface ReviewDto {
id: number id: number
lectureId: number lectureId: number
+104 -16
View File
@@ -5,14 +5,16 @@ import StatsWidget from '@/components/ui/StatsWidget.vue'
import ProgressBar from '@/components/ui/ProgressBar.vue' import ProgressBar from '@/components/ui/ProgressBar.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue' import StatusBadge from '@/components/ui/StatusBadge.vue'
import { lecturesApi, reviewsApi, syncApi, usersApi } from '@/api' import { lecturesApi, reviewsApi, syncApi, usersApi } from '@/api'
import type { LectureDto, ReviewDto, SyncStatusDto, UserDto } from '@/api/types' import type { LectureDto, SyncStatusDto, UserDto } from '@/api/types'
const users = ref<UserDto[]>([]) const users = ref<UserDto[]>([])
const lectures = ref<LectureDto[]>([]) const lectures = ref<LectureDto[]>([])
const reviews = ref<ReviewDto[]>([]) const pendingReviewsCount = ref(0)
const syncStatus = ref<SyncStatusDto | null>(null) const syncStatus = ref<SyncStatusDto | null>(null)
const enrollmentCount = computed(() => lectures.value.reduce((sum, lecture) => sum + lecture.enrollmentsCount, 0)) const enrollmentCount = computed(() =>
lectures.value.reduce((sum, lecture) => sum + lecture.enrollmentsCount, 0),
)
const syncMeta = computed(() => const syncMeta = computed(() =>
syncStatus.value?.lastSyncAt syncStatus.value?.lastSyncAt
? `Последняя синхронизация: ${new Date(syncStatus.value.lastSyncAt).toLocaleString('ru-RU')}` ? `Последняя синхронизация: ${new Date(syncStatus.value.lastSyncAt).toLocaleString('ru-RU')}`
@@ -23,12 +25,13 @@ onMounted(async () => {
const [usersResult, lecturesResult, reviewsResult, syncResult] = await Promise.allSettled([ const [usersResult, lecturesResult, reviewsResult, syncResult] = await Promise.allSettled([
usersApi.list({ PageSize: 100 }), usersApi.list({ PageSize: 100 }),
lecturesApi.list({ PageSize: 100 }), lecturesApi.list({ PageSize: 100 }),
reviewsApi.pending(), reviewsApi.pendingPage({ Page: 1, PageSize: 1 }),
syncApi.status(), syncApi.status(),
]) ])
if (usersResult.status === 'fulfilled') users.value = usersResult.value if (usersResult.status === 'fulfilled') users.value = usersResult.value
if (lecturesResult.status === 'fulfilled') lectures.value = lecturesResult.value if (lecturesResult.status === 'fulfilled') lectures.value = lecturesResult.value
if (reviewsResult.status === 'fulfilled') reviews.value = reviewsResult.value if (reviewsResult.status === 'fulfilled')
pendingReviewsCount.value = reviewsResult.value.totalCount
if (syncResult.status === 'fulfilled') syncStatus.value = syncResult.value if (syncResult.status === 'fulfilled') syncStatus.value = syncResult.value
}) })
</script> </script>
@@ -41,7 +44,12 @@ onMounted(async () => {
<StatsWidget label="Пользователей" :value="users.length" icon="users" color="green" /> <StatsWidget label="Пользователей" :value="users.length" icon="users" color="green" />
<StatsWidget label="Лекций" :value="lectures.length" icon="books" color="aqua" /> <StatsWidget label="Лекций" :value="lectures.length" icon="books" color="aqua" />
<StatsWidget label="Записей" :value="enrollmentCount" icon="calendar-event" color="orange" /> <StatsWidget label="Записей" :value="enrollmentCount" icon="calendar-event" color="orange" />
<StatsWidget label="Отзывов в LLM" :value="reviews.length" icon="message-circle" color="purple" /> <StatsWidget
label="Отзывов в LLM"
:value="pendingReviewsCount"
icon="message-circle"
color="purple"
/>
</div> </div>
<div class="grid"> <div class="grid">
@@ -49,12 +57,14 @@ onMounted(async () => {
<div class="section-title">Состояние синхронизации расписания</div> <div class="section-title">Состояние синхронизации расписания</div>
<StatusBadge :status="syncStatus?.status ?? 'pending'" /> <StatusBadge :status="syncStatus?.status ?? 'pending'" />
<div class="sync-meta">{{ syncMeta }}</div> <div class="sync-meta">{{ syncMeta }}</div>
<div class="sync-error" v-if="syncStatus?.lastResult?.error">Ошибка: {{ syncStatus.lastResult.error }}</div> <div class="sync-error" v-if="syncStatus?.lastResult?.error">
Ошибка: {{ syncStatus.lastResult.error }}
</div>
</GlassCard> </GlassCard>
<GlassCard> <GlassCard>
<div class="section-title">Очередь LLM-анализа</div> <div class="section-title">Очередь LLM-анализа</div>
<div class="queue-meta">В очереди: {{ reviews.length }} отзывов</div> <div class="queue-meta">В очереди: {{ pendingReviewsCount }} отзывов</div>
<ProgressBar :value="Math.min(reviews.length * 10, 100)" :max="100" /> <ProgressBar :value="Math.min(pendingReviewsCount * 10, 100)" :max="100" />
<div class="queue-status">Следующая проверка через 12 минут</div> <div class="queue-status">Следующая проверка через 12 минут</div>
</GlassCard> </GlassCard>
</div> </div>
@@ -62,11 +72,89 @@ onMounted(async () => {
</template> </template>
<style scoped> <style scoped>
.admin-dashboard { display: flex; flex-direction: column; gap: 18px; } .admin-dashboard {
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; } display: flex;
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; } flex-direction: column;
.sync-meta { font-size: 12px; color: var(--color-text-secondary); margin-top: 6px; } gap: 18px;
.sync-error { font-size: 12px; color: var(--color-error); margin-top: 8px; } }
.queue-meta { font-size: 12px; color: var(--color-text-secondary); margin-bottom: 8px; } .stats-row {
.queue-status { font-size: 12px; color: var(--color-text-secondary); margin-top: 6px; } display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.bars {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 10px;
}
.bar-row {
display: grid;
grid-template-columns: 1fr 2fr auto;
align-items: center;
gap: 8px;
font-size: 13px;
}
.bar {
background: var(--color-black-a08);
border-radius: 6px;
height: 8px;
overflow: hidden;
}
.bar-fill {
background: var(--gradient-progress-success);
height: 100%;
}
.percent {
color: var(--color-text-secondary);
font-size: 12px;
}
.metric {
margin-bottom: 10px;
color: var(--color-text-secondary);
}
.activity {
display: flex;
gap: 10px;
margin-top: 12px;
align-items: flex-end;
}
.day {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--color-text-secondary);
}
.day-bar {
width: 16px;
background: var(--gradient-bar-neutral-vertical);
border-radius: 6px 6px 0 0;
}
.sync-meta {
font-size: 12px;
color: var(--color-text-secondary);
margin-top: 6px;
}
.sync-error {
font-size: 12px;
color: var(--color-error);
margin-top: 8px;
}
.queue-meta {
font-size: 12px;
color: var(--color-text-secondary);
margin-bottom: 8px;
}
.queue-status {
font-size: 12px;
color: var(--color-text-secondary);
margin-top: 6px;
}
</style> </style>
+99 -19
View File
@@ -22,10 +22,14 @@ const columns = [
const reviews = ref<Review[]>([]) const reviews = ref<Review[]>([])
const loading = ref(false) const loading = ref(false)
const page = ref(1)
const pageSize = ref(20)
const totalCount = ref(0)
const totalPages = ref(0)
const error = ref('') const error = ref('')
const rows = computed(() => const rows = computed(() =>
reviews.value.map(review => ({ reviews.value.map((review) => ({
id: review.id, id: review.id,
lecture: review.lectureId, lecture: review.lectureId,
student: review.userName, student: review.userName,
@@ -37,11 +41,24 @@ const rows = computed(() =>
})), })),
) )
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)
async function fetchPending() { async function fetchPending() {
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
reviews.value = (await reviewsApi.pending()).map(mapApiReview) const result = await reviewsApi.pendingPage({
Page: page.value,
PageSize: pageSize.value,
})
reviews.value = result.items.map(mapApiReview)
totalCount.value = result.totalCount
totalPages.value = result.totalPages
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Не удалось загрузить очередь LLM.' error.value = err instanceof Error ? err.message : 'Не удалось загрузить очередь LLM.'
} finally { } finally {
@@ -49,6 +66,12 @@ async function fetchPending() {
} }
} }
async function goToPage(nextPage: number) {
if (nextPage < 1 || (totalPages.value && nextPage > totalPages.value)) return
page.value = nextPage
await fetchPending()
}
async function reanalyze(id: string) { async function reanalyze(id: string) {
await reviewsApi.reanalyze(id) await reviewsApi.reanalyze(id)
await fetchPending() await fetchPending()
@@ -61,30 +84,87 @@ onMounted(fetchPending)
<div class="admin-llm page-content"> <div class="admin-llm page-content">
<div class="header"> <div class="header">
<h1 class="page-title">Очередь LLM-анализа отзывов</h1> <h1 class="page-title">Очередь LLM-анализа отзывов</h1>
<button class="btn-primary" @click="fetchPending">Обновить очередь</button> <button class="btn-primary" :disabled="loading" @click="fetchPending">
{{ loading ? 'Загрузка...' : 'Обновить очередь' }}
</button>
</div> </div>
<GlassCard> <GlassCard>
<EmptyState v-if="error" title="Не удалось загрузить очередь" :subtitle="error" /> <EmptyState v-if="error" title="Не удалось загрузить очередь" :subtitle="error" />
<EmptyState v-else-if="!rows.length && !loading" title="Очередь пуста" subtitle="Нет отзывов, ожидающих LLM-анализ." /> <EmptyState
<DataTable :columns="columns" :rows="rows"> v-else-if="!rows.length && !loading"
<template #status="{ value }"> title="Очередь пуста"
<StatusBadge :status="value" /> subtitle="Нет отзывов, ожидающих LLM-анализ."
</template> />
<template #quality="{ value }"> <div v-else class="table-section">
<span :class="value >= 0.7 ? 'badge badge-green' : value >= 0.4 ? 'badge badge-orange' : 'badge badge-red'"> <div class="pagination-bar">
{{ value }} <span> В очереди {{ totalCount }} отзывов; показаны {{ pageStart }}{{ pageEnd }} </span>
</span> <div class="pagination-actions">
</template> <button class="btn-ghost" :disabled="loading || !canGoPrev" @click="goToPage(page - 1)">
<template #actions="{ row }"> Назад
<button class="btn-ghost" @click="reanalyze(row.id)">Повторить</button> </button>
</template> <span>Страница {{ page }} из {{ totalPages || 1 }}</span>
</DataTable> <button class="btn-ghost" :disabled="loading || !canGoNext" @click="goToPage(page + 1)">
Вперёд
</button>
</div>
</div>
<DataTable :columns="columns" :rows="rows">
<template #status="{ value }">
<StatusBadge :status="value" />
</template>
<template #quality="{ value }">
<span
:class="
value >= 0.7
? 'badge badge-green'
: value >= 0.4
? 'badge badge-orange'
: 'badge badge-red'
"
>
{{ value }}
</span>
</template>
<template #actions="{ row }">
<button class="btn-ghost" @click="reanalyze(row.id)">Повторить</button>
</template>
</DataTable>
</div>
</GlassCard> </GlassCard>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.admin-llm { display: flex; flex-direction: column; gap: 16px; } .admin-llm {
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } display: flex;
flex-direction: column;
gap: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.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;
}
</style> </style>
+117 -57
View File
@@ -35,6 +35,10 @@ const ratingLabel: Record<string, string> = {
const reviews = ref<ReviewDto[]>([]) const reviews = ref<ReviewDto[]>([])
const loading = ref(false) 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 reanalyzingId = ref<number | null>(null)
const error = ref('') const error = ref('')
@@ -63,6 +67,13 @@ const rows = computed(() =>
})), })),
) )
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) { function formatQuality(value: number | null | undefined) {
if (value === null || value === undefined) return '—' if (value === null || value === undefined) return '—'
return Number(value).toFixed(2) return Number(value).toFixed(2)
@@ -72,7 +83,13 @@ async function fetchReviews() {
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
reviews.value = await reviewsApi.list({ PageSize: 100 }) 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) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Не удалось загрузить отзывы.' error.value = err instanceof Error ? err.message : 'Не удалось загрузить отзывы.'
} finally { } finally {
@@ -80,6 +97,12 @@ async function 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) { async function reanalyze(id: number) {
reanalyzingId.value = id reanalyzingId.value = id
try { try {
@@ -114,70 +137,88 @@ onMounted(fetchReviews)
title="Отзывов нет" title="Отзывов нет"
subtitle="Пока нет ни одного отзыва." subtitle="Пока нет ни одного отзыва."
/> />
<DataTable v-else :columns="columns" :rows="rows"> <div v-else class="table-section">
<template #text="{ value }"> <div class="pagination-bar">
<span class="review-text" :title="value">{{ value }}</span> <span> Показаны {{ pageStart }}{{ pageEnd }} из {{ totalCount }} отзывов </span>
</template> <div class="pagination-actions">
<template #status="{ value, row }"> <button class="btn-ghost" :disabled="loading || !canGoPrev" @click="goToPage(page - 1)">
<StatusBadge :status="value" /> Назад
<span class="raw-status">{{ row.rawStatus }}</span> </button>
</template> <span>Страница {{ page }} из {{ totalPages || 1 }}</span>
<template #sentiment="{ value }"> <button class="btn-ghost" :disabled="loading || !canGoNext" @click="goToPage(page + 1)">
<span Вперёд
class="badge" </button>
:class=" </div>
value === 'Positive' </div>
? 'badge-green' <DataTable :columns="columns" :rows="rows">
: value === 'Negative' <template #text="{ value }">
? 'badge-red' <span class="review-text" :title="value">{{ value }}</span>
: 'badge-orange' </template>
" <template #status="{ value, row }">
> <StatusBadge :status="value" />
{{ value }} <span class="raw-status">{{ row.rawStatus }}</span>
</span> </template>
</template> <template #sentiment="{ value }">
<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 <span
class="badge" class="badge"
:class=" :class="
row.informative value === 'Positive'
? 'badge-green' ? 'badge-green'
: row.informative === false : value === 'Negative'
? 'badge-red' ? 'badge-red'
: 'badge-gray' : 'badge-orange'
" "
> >
{{ {{ value }}
row.informative === null || row.informative === undefined
? '—'
: row.informative
? 'Informative'
: 'Not informative'
}}
</span> </span>
<span v-for="tag in row.tags" :key="tag" class="badge badge-blue">{{ tag }}</span> </template>
</div> <template #quality="{ value }">
</template> <span
<template #actions="{ row }"> :class="
<button class="btn-ghost" :disabled="reanalyzingId === row.id" @click="reanalyze(row.id)"> (value ?? 0) >= 0.7
{{ reanalyzingId === row.id ? 'Запускаем...' : 'Повторить анализ' }} ? 'badge badge-green'
</button> : (value ?? 0) >= 0.4
</template> ? 'badge badge-orange'
</DataTable> : '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> </GlassCard>
</div> </div>
</template> </template>
@@ -213,6 +254,25 @@ onMounted(fetchReviews)
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 11px; 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 { .tags-cell {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;