From a8d51df3f1bc7f40ff4952921a80029d00de65fc Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Thu, 28 May 2026 05:09:47 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D1=8D=D0=BD=D0=B4=D0=BF=D0=BE=D0=B8=D0=BD=D1=82=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D1=81=D0=BA=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=B4=D0=B0=D1=88=D0=B1=D0=BE=D1=80=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/UsersController.cs | 13 ++++ backend/UniVerse.Api/openapi.json | 68 +++++++++++++++++++ .../DTOs/Users/UserDtos.cs | 7 ++ .../Interfaces/IUserService.cs | 1 + .../Services/UserService.cs | 11 +++ frontend/src/api/index.ts | 2 + frontend/src/api/types.ts | 7 ++ .../src/views/admin/AdminDashboardView.vue | 37 ++++------ 8 files changed, 123 insertions(+), 23 deletions(-) diff --git a/backend/UniVerse.Api/Controllers/UsersController.cs b/backend/UniVerse.Api/Controllers/UsersController.cs index 6a15161..3ad558e 100644 --- a/backend/UniVerse.Api/Controllers/UsersController.cs +++ b/backend/UniVerse.Api/Controllers/UsersController.cs @@ -159,6 +159,19 @@ public class UsersController : ControllerBase [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> Stats(int id) => Ok(await _users.GetStatsAsync(id)); + /// Получить статистику для админского дашборда. + /// Только Admin. + /// Агрегированная статистика дашборда. + /// Требуется аутентификация. + /// Требуется роль Admin. + [Authorize(Roles = "Admin")] + [HttpGet("admin/stats")] + [ProducesResponseType(typeof(AdminDashboardStatsDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> AdminStats() => + Ok(await _users.GetAdminDashboardStatsAsync()); + /// Получить список записей пользователя на лекции. /// Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/enrollments. /// ID пользователя. diff --git a/backend/UniVerse.Api/openapi.json b/backend/UniVerse.Api/openapi.json index f4a4213..5bb5999 100644 --- a/backend/UniVerse.Api/openapi.json +++ b/backend/UniVerse.Api/openapi.json @@ -4158,6 +4158,52 @@ ] } }, + "/api/v1/users/admin/stats": { + "get": { + "tags": [ + "Users" + ], + "summary": "Получить статистику для админского дашборда.", + "description": "Только Admin.\n\n**Required roles:** Admin", + "responses": { + "200": { + "description": "Агрегированная статистика дашборда.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminDashboardStatsDto" + } + } + } + }, + "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/users/{id}/enrollments": { "get": { "tags": [ @@ -4758,6 +4804,28 @@ }, "additionalProperties": false }, + "AdminDashboardStatsDto": { + "type": "object", + "properties": { + "usersCount": { + "type": "integer", + "format": "int32" + }, + "lecturesCount": { + "type": "integer", + "format": "int32" + }, + "enrollmentsCount": { + "type": "integer", + "format": "int32" + }, + "pendingReviewsCount": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, "AuthResponse": { "type": "object", "properties": { diff --git a/backend/UniVerse.Application/DTOs/Users/UserDtos.cs b/backend/UniVerse.Application/DTOs/Users/UserDtos.cs index c06797f..b997704 100644 --- a/backend/UniVerse.Application/DTOs/Users/UserDtos.cs +++ b/backend/UniVerse.Application/DTOs/Users/UserDtos.cs @@ -42,6 +42,13 @@ public record UserStatsDto( IReadOnlyList EnrollmentSlotRules ); +public record AdminDashboardStatsDto( + int UsersCount, + int LecturesCount, + int EnrollmentsCount, + int PendingReviewsCount +); + public record EnrollmentSlotRuleDto(int Level, int Slots); public record UpdateUserRequest( diff --git a/backend/UniVerse.Application/Interfaces/IUserService.cs b/backend/UniVerse.Application/Interfaces/IUserService.cs index 7c6b60f..be0b46d 100644 --- a/backend/UniVerse.Application/Interfaces/IUserService.cs +++ b/backend/UniVerse.Application/Interfaces/IUserService.cs @@ -10,6 +10,7 @@ public interface IUserService Task GetByIdAsync(int id); Task UpdateProfileAsync(int id, UpdateUserRequest request); Task GetStatsAsync(int id); + Task GetAdminDashboardStatsAsync(); Task> GetEnrollmentsAsync(int id, PaginationRequest pagination); Task> GetAllAsync(UserFilterRequest filter); Task SetRolesAsync(int id, IReadOnlyCollection roles); diff --git a/backend/UniVerse.Infrastructure/Services/UserService.cs b/backend/UniVerse.Infrastructure/Services/UserService.cs index 30cdd95..2c29b8d 100644 --- a/backend/UniVerse.Infrastructure/Services/UserService.cs +++ b/backend/UniVerse.Infrastructure/Services/UserService.cs @@ -74,6 +74,17 @@ public class UserService : IUserService ); } + public async Task GetAdminDashboardStatsAsync() + { + var usersCount = await _db.Users + .CountAsync(user => !user.Roles.Any(role => role.Role == UserRole.Teacher)); + var lecturesCount = await _db.Lectures.CountAsync(); + var enrollmentsCount = await _db.LectureEnrollments.CountAsync(); + var pendingReviewsCount = await _db.Reviews.CountAsync(review => review.LlmStatus == ReviewLlmStatus.Pending); + + return new AdminDashboardStatsDto(usersCount, lecturesCount, enrollmentsCount, pendingReviewsCount); + } + public async Task> GetEnrollmentsAsync(int id, PaginationRequest pagination) { if (!await _db.Users.AnyAsync(u => u.Id == id)) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 6ef26b3..6af0b79 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -18,6 +18,7 @@ import type { TagDto, UpdateReviewPromptRequest, UserAchievementDto, + AdminDashboardStatsDto, CurrentUserDto, UserDto, UserQuery, @@ -68,6 +69,7 @@ export const usersApi = { body: JSON.stringify(payload), }), myStats: () => apiRequest('/users/me/stats'), + adminStats: () => apiRequest('/users/admin/stats'), async myEnrollments() { const payload = await apiRequest | LectureDto[] | undefined>( '/users/me/enrollments', diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index cf6474d..4fa7e2d 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -76,6 +76,13 @@ export interface UserStatsDto { enrollmentSlotRules: EnrollmentSlotRuleDto[] } +export interface AdminDashboardStatsDto { + usersCount: number + lecturesCount: number + enrollmentsCount: number + pendingReviewsCount: number +} + export interface EnrollmentSlotRuleDto { level: number slots: number diff --git a/frontend/src/views/admin/AdminDashboardView.vue b/frontend/src/views/admin/AdminDashboardView.vue index 9559ee9..a7f5804 100644 --- a/frontend/src/views/admin/AdminDashboardView.vue +++ b/frontend/src/views/admin/AdminDashboardView.vue @@ -3,17 +3,11 @@ import { computed, onMounted, ref } from 'vue' import GlassCard from '@/components/ui/GlassCard.vue' import StatsWidget from '@/components/ui/StatsWidget.vue' import StatusBadge from '@/components/ui/StatusBadge.vue' -import { lecturesApi, reviewsApi, syncApi, usersApi } from '@/api' -import type { LectureDto, SyncStatusDto, UserDto } from '@/api/types' +import { syncApi, usersApi } from '@/api' +import type { AdminDashboardStatsDto, SyncStatusDto } from '@/api/types' -const users = ref([]) -const lectures = ref([]) -const pendingReviewsCount = ref(0) +const stats = ref(null) const syncStatus = ref(null) - -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')}` @@ -21,16 +15,8 @@ const syncMeta = computed(() => ) onMounted(async () => { - const [usersResult, lecturesResult, reviewsResult, syncResult] = await Promise.allSettled([ - usersApi.list({ PageSize: 100 }), - lecturesApi.list({ PageSize: 100 }), - reviewsApi.listPage({ Page: 1, PageSize: 1, LlmStatus: 'Pending' }), - syncApi.status(), - ]) - if (usersResult.status === 'fulfilled') users.value = usersResult.value - if (lecturesResult.status === 'fulfilled') lectures.value = lecturesResult.value - if (reviewsResult.status === 'fulfilled') - pendingReviewsCount.value = reviewsResult.value.totalCount + const [statsResult, syncResult] = await Promise.allSettled([usersApi.adminStats(), syncApi.status()]) + if (statsResult.status === 'fulfilled') stats.value = statsResult.value if (syncResult.status === 'fulfilled') syncStatus.value = syncResult.value }) @@ -40,12 +26,17 @@ onMounted(async () => {

Дашборд администратора

- - - + + +