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
+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>