Dev #11

Merged
serega404 merged 87 commits from dev into main 2026-05-25 03:22:55 +03:00
5 changed files with 366 additions and 98 deletions
Showing only changes of commit 934682f035 - Show all commits
+41 -6
View File
@@ -10,6 +10,7 @@ import type {
LocationDto,
PagedResult,
ReviewDto,
ReviewQuery,
SyncResultDto,
SyncScheduleRequest,
SyncStatusDto,
@@ -115,19 +116,53 @@ export const notificationsApi = {
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 = {
create: (lectureId: string | number, rating: 'Like' | 'Neutral' | 'Dislike', text: string) =>
apiRequest<ReviewDto>('/reviews', {
method: 'POST',
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)
listPage: listReviewsPage,
async list(query: ReviewQuery = { PageSize: 100 }) {
return (await listReviewsPage(query)).items
},
async pending() {
const payload = await apiRequest<PagedResult<ReviewDto> | ReviewDto[]>('/reviews/pending')
return extractItems(payload)
pendingPage: listPendingReviewsPage,
async pending(query: ReviewQuery = { PageSize: 100 }) {
return (await listPendingReviewsPage(query)).items
},
reanalyze: (id: string | number) =>
apiRequest<void>(`/reviews/${id}/reanalyze`, { method: 'POST' }),
+5
View File
@@ -99,6 +99,11 @@ export interface CreateLectureRequest {
onlineUrl?: string | null
}
export interface ReviewQuery {
Page?: number
PageSize?: number
}
export interface ReviewDto {
id: 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 StatusBadge from '@/components/ui/StatusBadge.vue'
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 lectures = ref<LectureDto[]>([])
const reviews = ref<ReviewDto[]>([])
const pendingReviewsCount = ref(0)
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(() =>
syncStatus.value?.lastSyncAt
? `Последняя синхронизация: ${new Date(syncStatus.value.lastSyncAt).toLocaleString('ru-RU')}`
@@ -23,12 +25,13 @@ onMounted(async () => {
const [usersResult, lecturesResult, reviewsResult, syncResult] = await Promise.allSettled([
usersApi.list({ PageSize: 100 }),
lecturesApi.list({ PageSize: 100 }),
reviewsApi.pending(),
reviewsApi.pendingPage({ Page: 1, PageSize: 1 }),
syncApi.status(),
])
if (usersResult.status === 'fulfilled') users.value = usersResult.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
})
</script>
@@ -41,7 +44,12 @@ onMounted(async () => {
<StatsWidget label="Пользователей" :value="users.length" icon="users" color="green" />
<StatsWidget label="Лекций" :value="lectures.length" icon="books" color="aqua" />
<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 class="grid">
@@ -49,12 +57,14 @@ onMounted(async () => {
<div class="section-title">Состояние синхронизации расписания</div>
<StatusBadge :status="syncStatus?.status ?? 'pending'" />
<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>
<div class="section-title">Очередь LLM-анализа</div>
<div class="queue-meta">В очереди: {{ reviews.length }} отзывов</div>
<ProgressBar :value="Math.min(reviews.length * 10, 100)" :max="100" />
<div class="queue-meta">В очереди: {{ pendingReviewsCount }} отзывов</div>
<ProgressBar :value="Math.min(pendingReviewsCount * 10, 100)" :max="100" />
<div class="queue-status">Следующая проверка через 12 минут</div>
</GlassCard>
</div>
@@ -62,11 +72,89 @@ onMounted(async () => {
</template>
<style scoped>
.admin-dashboard { display: flex; flex-direction: column; gap: 18px; }
.stats-row { 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; }
.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; }
.admin-dashboard {
display: flex;
flex-direction: column;
gap: 18px;
}
.stats-row {
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>
+87 -7
View File
@@ -22,10 +22,14 @@ const columns = [
const reviews = ref<Review[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = ref(20)
const totalCount = ref(0)
const totalPages = ref(0)
const error = ref('')
const rows = computed(() =>
reviews.value.map(review => ({
reviews.value.map((review) => ({
id: review.id,
lecture: review.lectureId,
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() {
loading.value = true
error.value = ''
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) {
error.value = err instanceof Error ? err.message : 'Не удалось загрузить очередь LLM.'
} 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) {
await reviewsApi.reanalyze(id)
await fetchPending()
@@ -61,18 +84,45 @@ onMounted(fetchPending)
<div class="admin-llm page-content">
<div class="header">
<h1 class="page-title">Очередь LLM-анализа отзывов</h1>
<button class="btn-primary" @click="fetchPending">Обновить очередь</button>
<button class="btn-primary" :disabled="loading" @click="fetchPending">
{{ loading ? 'Загрузка...' : 'Обновить очередь' }}
</button>
</div>
<GlassCard>
<EmptyState v-if="error" title="Не удалось загрузить очередь" :subtitle="error" />
<EmptyState v-else-if="!rows.length && !loading" title="Очередь пуста" subtitle="Нет отзывов, ожидающих LLM-анализ." />
<EmptyState
v-else-if="!rows.length && !loading"
title="Очередь пуста"
subtitle="Нет отзывов, ожидающих LLM-анализ."
/>
<div v-else class="table-section">
<div class="pagination-bar">
<span> В очереди {{ totalCount }} отзывов; показаны {{ pageStart }}{{ pageEnd }} </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 #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'">
<span
:class="
value >= 0.7
? 'badge badge-green'
: value >= 0.4
? 'badge badge-orange'
: 'badge badge-red'
"
>
{{ value }}
</span>
</template>
@@ -80,11 +130,41 @@ onMounted(fetchPending)
<button class="btn-ghost" @click="reanalyze(row.id)">Повторить</button>
</template>
</DataTable>
</div>
</GlassCard>
</div>
</template>
<style scoped>
.admin-llm { display: flex; flex-direction: column; gap: 16px; }
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.admin-llm {
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>
+63 -3
View File
@@ -35,6 +35,10 @@ const ratingLabel: Record<string, string> = {
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 reanalyzingId = ref<number | null>(null)
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) {
if (value === null || value === undefined) return '—'
return Number(value).toFixed(2)
@@ -72,7 +83,13 @@ async function fetchReviews() {
loading.value = true
error.value = ''
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) {
error.value = err instanceof Error ? err.message : 'Не удалось загрузить отзывы.'
} 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) {
reanalyzingId.value = id
try {
@@ -114,7 +137,20 @@ onMounted(fetchReviews)
title="Отзывов нет"
subtitle="Пока нет ни одного отзыва."
/>
<DataTable v-else :columns="columns" :rows="rows">
<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="{ value }">
<span class="review-text" :title="value">{{ value }}</span>
</template>
@@ -173,11 +209,16 @@ onMounted(fetchReviews)
</div>
</template>
<template #actions="{ row }">
<button class="btn-ghost" :disabled="reanalyzingId === row.id" @click="reanalyze(row.id)">
<button
class="btn-ghost"
:disabled="reanalyzingId === row.id"
@click="reanalyze(row.id)"
>
{{ reanalyzingId === row.id ? 'Запускаем...' : 'Повторить анализ' }}
</button>
</template>
</DataTable>
</div>
</GlassCard>
</div>
</template>
@@ -213,6 +254,25 @@ onMounted(fetchReviews)
color: var(--color-text-secondary);
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 {
display: flex;
flex-wrap: wrap;