feat: добавил кабинеты преподавателя и администратора
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 8s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 38s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 18s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 6s

This commit is contained in:
2026-05-11 01:58:09 +03:00
parent 779b6aba77
commit 610c15c9fd
11 changed files with 399 additions and 90 deletions
@@ -1,8 +1,11 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import GlassCard from '@/components/ui/GlassCard.vue'
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'
const disciplines = [
{ name: 'Информатика и ИИ', value: 80 },
@@ -10,6 +13,30 @@ const disciplines = [
{ name: 'Философия и этика', value: 42 },
{ name: 'Право и политика', value: 36 },
]
const users = ref<UserDto[]>([])
const lectures = ref<LectureDto[]>([])
const reviews = ref<ReviewDto[]>([])
const syncStatus = ref<SyncStatusDto | null>(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')}`
: 'Синхронизация ещё не выполнялась',
)
onMounted(async () => {
const [usersResult, lecturesResult, reviewsResult, syncResult] = await Promise.allSettled([
usersApi.list({ PageSize: 100 }),
lecturesApi.list({ PageSize: 100 }),
reviewsApi.pending(),
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 (syncResult.status === 'fulfilled') syncStatus.value = syncResult.value
})
</script>
<template>
@@ -17,10 +44,10 @@ const disciplines = [
<h1 class="page-title">Дашборд администратора</h1>
<div class="stats-row">
<StatsWidget label="Пользователей" :value="1247" icon="👥" color="green" />
<StatsWidget label="Лекций" :value="89" icon="📚" color="aqua" />
<StatsWidget label="Записей" :value="3421" icon="🗓️" color="orange" />
<StatsWidget label="Отзывов" :value="1089" icon="💬" color="purple" />
<StatsWidget label="Пользователей" :value="users.length" icon="👥" color="green" />
<StatsWidget label="Лекций" :value="lectures.length" icon="📚" color="aqua" />
<StatsWidget label="Записей" :value="enrollmentCount" icon="🗓️" color="orange" />
<StatsWidget label="Отзывов в LLM" :value="reviews.length" icon="💬" color="purple" />
</div>
<div class="grid">
@@ -54,14 +81,14 @@ const disciplines = [
<div class="grid">
<GlassCard>
<div class="section-title">Состояние синхронизации расписания</div>
<StatusBadge status="open" />
<div class="sync-meta">Последняя синхронизация: сегодня, 09:15</div>
<div class="sync-error">Ошибка: 2 аудитории не сопоставлены с корпусами</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>
</GlassCard>
<GlassCard>
<div class="section-title">Очередь LLM-анализа</div>
<div class="queue-meta">В очереди: 24 отзыва · Обработка: 6/час</div>
<ProgressBar :value="60" :max="100" />
<div class="queue-meta">В очереди: {{ reviews.length }} отзывов</div>
<ProgressBar :value="Math.min(reviews.length * 10, 100)" :max="100" />
<div class="queue-status">Следующая проверка через 12 минут</div>
</GlassCard>
</div>
+45 -9
View File
@@ -1,7 +1,12 @@
<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 { mapApiReview } from '@/api/mappers'
import type { Review } from '@/types'
const columns = [
{ key: 'id', label: 'ID' },
@@ -15,22 +20,53 @@ const columns = [
{ key: 'actions', label: 'Действия', align: 'right' },
]
const rows = [
{ id: 'RV-1024', lecture: 'Нейронные сети', student: 'А. Морозов', date: '06.05', status: 'pending', sentiment: 'Позитивный', quality: 0.82, coins: 20 },
{ id: 'RV-1025', lecture: 'Квантовые вычисления', student: 'Н. Иванова', date: '05.05', status: 'active', sentiment: 'Нейтральный', quality: 0.63, coins: 10 },
{ id: 'RV-1026', lecture: 'Право в информационном обществе', student: 'Д. Комаров', date: '04.05', status: 'done', sentiment: 'Негативный', quality: 0.41, coins: 0 },
{ id: 'RV-1027', lecture: 'Философия цифровой эпохи', student: 'С. Орлова', date: '03.05', status: 'rejected', sentiment: 'Нейтральный', quality: 0.22, coins: 0 },
]
const reviews = ref<Review[]>([])
const loading = ref(false)
const error = ref('')
const rows = computed(() =>
reviews.value.map(review => ({
id: review.id,
lecture: review.lectureId,
student: review.userName,
date: new Date(review.createdAt).toLocaleDateString('ru-RU'),
status: review.status,
sentiment: review.sentiment,
quality: review.quality ?? 0,
coins: review.coins ?? 0,
})),
)
async function fetchPending() {
loading.value = true
error.value = ''
try {
reviews.value = (await reviewsApi.pending()).map(mapApiReview)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Не удалось загрузить очередь LLM.'
} finally {
loading.value = false
}
}
async function reanalyze(id: string) {
await reviewsApi.reanalyze(id)
await fetchPending()
}
onMounted(fetchPending)
</script>
<template>
<div class="admin-llm page-content">
<div class="header">
<h1 class="page-title">Очередь LLM-анализа отзывов</h1>
<button class="btn-primary">Запустить повторный анализ</button>
<button class="btn-primary" @click="fetchPending">Обновить очередь</button>
</div>
<GlassCard>
<EmptyState v-if="error" title="Не удалось загрузить очередь" :subtitle="error" />
<EmptyState v-else-if="!rows.length && !loading" title="Очередь пуста" subtitle="Нет отзывов, ожидающих LLM-анализ." />
<DataTable :columns="columns" :rows="rows">
<template #status="{ value }">
<StatusBadge :status="value" />
@@ -40,8 +76,8 @@ const rows = [
{{ value }}
</span>
</template>
<template #actions>
<button class="btn-ghost">Повторить</button>
<template #actions="{ row }">
<button class="btn-ghost" @click="reanalyze(row.id)">Повторить</button>
</template>
</DataTable>
</GlassCard>
+75 -19
View File
@@ -1,7 +1,10 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { coursesApi, lecturesApi, locationsApi, tagsApi } from '@/api'
import type { CourseDto, LectureDto, LocationDto, TagDto } from '@/api/types'
import GlassCard from '@/components/ui/GlassCard.vue'
import DataTable from '@/components/ui/DataTable.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
type TabKey = 'lectures' | 'courses' | 'rooms' | 'tags'
type TabConfig = {
@@ -11,6 +14,11 @@ type TabConfig = {
}
const activeTab = ref<TabKey>('lectures')
const lectures = ref<LectureDto[]>([])
const courses = ref<CourseDto[]>([])
const locations = ref<LocationDto[]>([])
const tags = ref<TagDto[]>([])
const loading = ref(false)
const tabConfig: Record<TabKey, TabConfig> = {
lectures: {
@@ -21,11 +29,7 @@ const tabConfig: Record<TabKey, TabConfig> = {
{ key: 'format', label: 'Формат' },
{ key: 'status', label: 'Синхронизация', align: 'center' },
],
rows: [
{ id: '1', title: 'Введение в нейронные сети', teacher: 'Волков М.С.', format: 'Офлайн', status: 'Синхронизировано' },
{ id: '2', title: 'Квантовые вычисления', teacher: 'Петров А.И.', format: 'Офлайн', status: 'Синхронизировано' },
{ id: '3', title: 'Философия цифровой эпохи', teacher: 'Дмитриев К.О.', format: 'Онлайн', status: 'Ошибка' },
],
rows: [],
},
courses: {
title: 'Курсы',
@@ -34,10 +38,7 @@ const tabConfig: Record<TabKey, TabConfig> = {
{ key: 'institute', label: 'Институт' },
{ key: 'tags', label: 'Теги' },
],
rows: [
{ id: '1', title: 'Машинное обучение', institute: 'ИКТИБ', tags: '#ML #ИИ #Python' },
{ id: '2', title: 'Цифровая этика', institute: 'ИФиСН', tags: '#философия #этика' },
],
rows: [],
},
rooms: {
title: 'Аудитории',
@@ -46,10 +47,7 @@ const tabConfig: Record<TabKey, TabConfig> = {
{ key: 'room', label: 'Аудитория' },
{ key: 'capacity', label: 'Вместимость', align: 'center' },
],
rows: [
{ id: '1', building: 'ИКТИБ', room: '305', capacity: 30 },
{ id: '2', building: 'ИФиМКН', room: '201', capacity: 25 },
],
rows: [],
},
tags: {
title: 'Теги',
@@ -58,14 +56,71 @@ const tabConfig: Record<TabKey, TabConfig> = {
{ key: 'category', label: 'Категория' },
{ key: 'linked', label: 'Привязки', align: 'center' },
],
rows: [
{ id: '1', tag: '#ML', category: 'Data Science', linked: 12 },
{ id: '2', tag: '#философия', category: 'Гуманитарные', linked: 6 },
],
rows: [],
},
}
const current = computed(() => tabConfig[activeTab.value])
const current = computed(() => {
const config = tabConfig[activeTab.value]
if (activeTab.value === 'lectures') {
return {
...config,
rows: lectures.value.map(l => ({
id: l.id,
title: l.title || l.courseName || 'Без названия',
teacher: l.teacherName || 'Не назначен',
format: l.format === 'Online' ? 'Онлайн' : 'Офлайн',
status: l.isOpen ? 'Открыта' : 'Закрыта',
})),
}
}
if (activeTab.value === 'courses') {
return {
...config,
rows: courses.value.map(c => ({
id: c.id,
title: c.name || 'Без названия',
institute: c.isSynced ? 'Синхронизирован' : 'Ручной',
tags: c.tags?.map(tag => `#${tag.name}`).join(' ') || '—',
})),
}
}
if (activeTab.value === 'rooms') {
return {
...config,
rows: locations.value.map(l => ({
id: l.id,
building: l.building || l.name || '—',
room: l.room || '—',
capacity: '—',
})),
}
}
return {
...config,
rows: tags.value.map(tag => ({
id: tag.id,
tag: `#${tag.name}`,
category: tag.type,
linked: '—',
})),
}
})
onMounted(async () => {
loading.value = true
const [lecturesResult, coursesResult, locationsResult, tagsResult] = await Promise.allSettled([
lecturesApi.list({ PageSize: 100 }),
coursesApi.list(),
locationsApi.list(),
tagsApi.list(),
])
if (lecturesResult.status === 'fulfilled') lectures.value = lecturesResult.value
if (coursesResult.status === 'fulfilled') courses.value = coursesResult.value
if (locationsResult.status === 'fulfilled') locations.value = locationsResult.value
if (tagsResult.status === 'fulfilled') tags.value = tagsResult.value
loading.value = false
})
</script>
<template>
@@ -85,6 +140,7 @@ const current = computed(() => tabConfig[activeTab.value])
<div class="grid">
<GlassCard>
<div class="section-title">{{ current.title }}</div>
<EmptyState v-if="!current.rows.length && !loading" title="Данных пока нет" subtitle="Backend не вернул записи для выбранного раздела." />
<DataTable :columns="current.columns" :rows="current.rows" />
</GlassCard>
+59 -12
View File
@@ -1,11 +1,17 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { usersApi } from '@/api'
import type { UserDto } from '@/api/types'
import GlassCard from '@/components/ui/GlassCard.vue'
import DataTable from '@/components/ui/DataTable.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
const search = ref('')
const roleFilter = ref('Все роли')
const instituteFilter = ref('Все институты')
const users = ref<UserDto[]>([])
const loading = ref(false)
const error = ref('')
const columns = [
{ key: 'name', label: 'Имя' },
@@ -17,11 +23,51 @@ const columns = [
{ key: 'actions', label: 'Действия', align: 'right' },
]
const rows = [
{ id: '1', name: 'Алексей Морозов', email: 'a.morozov@sfedu.ru', role: 'Студент', institute: 'ИКТИБ', activity: 'Высокая', created: '12.03.2024' },
{ id: '2', name: 'Елена Смирнова', email: 'e.smirnova@sfedu.ru', role: 'Преподаватель', institute: 'АГиС', activity: 'Средняя', created: '05.02.2023' },
{ id: '3', name: 'Виктор Алексеев', email: 'admin@sfedu.ru', role: 'Администратор', institute: 'ЮФУ', activity: 'Высокая', created: '01.09.2022' },
]
const roleLabels = { Student: 'Студент', Teacher: 'Преподаватель', Admin: 'Администратор' } as const
const roleApi = { Студент: 'Student', Преподаватель: 'Teacher', Администратор: 'Admin' } as const
const rows = computed(() =>
users.value.map(user => ({
id: user.id,
name: user.displayName || user.email,
email: user.email,
role: roleLabels[user.role],
apiRole: user.role,
institute: 'ЮФУ',
activity: user.isActive ? 'Активен' : 'Заблокирован',
isActive: user.isActive,
created: new Date(user.createdAt).toLocaleDateString('ru-RU'),
})),
)
async function fetchUsers() {
loading.value = true
error.value = ''
try {
users.value = await usersApi.list({
Search: search.value || undefined,
Role: roleFilter.value === 'Все роли' ? undefined : roleApi[roleFilter.value as keyof typeof roleApi],
PageSize: 100,
})
} catch (err) {
error.value = err instanceof Error ? err.message : 'Не удалось загрузить пользователей.'
} finally {
loading.value = false
}
}
async function toggleActive(row: Record<string, any>) {
await usersApi.setActive(row.id, !row.isActive)
await fetchUsers()
}
async function promoteRole(row: Record<string, any>) {
const next = row.apiRole === 'Student' ? 'Teacher' : row.apiRole === 'Teacher' ? 'Admin' : 'Student'
await usersApi.setRole(row.id, next)
await fetchUsers()
}
onMounted(fetchUsers)
</script>
<template>
@@ -34,7 +80,7 @@ const rows = [
<GlassCard>
<div class="filters">
<input v-model="search" class="glass-input" placeholder="Поиск по имени или email" />
<select v-model="roleFilter" class="glass-input">
<select v-model="roleFilter" class="glass-input" @change="fetchUsers">
<option>Все роли</option>
<option>Студент</option>
<option>Преподаватель</option>
@@ -49,18 +95,19 @@ const rows = [
</select>
</div>
<EmptyState v-if="error" title="Не удалось загрузить пользователей" :subtitle="error" />
<EmptyState v-else-if="!rows.length && !loading" title="Пользователей не найдено" subtitle="Попробуйте изменить фильтры." />
<DataTable :columns="columns" :rows="rows">
<template #role="{ value }">
<span :class="value === 'Студент' ? 'badge badge-green' : value === 'Преподаватель' ? 'badge badge-blue' : 'badge badge-purple'">{{ value }}</span>
</template>
<template #activity="{ value }">
<span class="badge" :class="value === 'Высокая' ? 'badge-green' : 'badge-orange'">{{ value }}</span>
<span class="badge" :class="value === 'Активен' ? 'badge-green' : 'badge-orange'">{{ value }}</span>
</template>
<template #actions>
<template #actions="{ row }">
<div class="actions">
<button class="btn-ghost">Назначить роль</button>
<button class="btn-ghost">Заблокировать</button>
<button class="btn-ghost">Профиль</button>
<button class="btn-ghost" @click="promoteRole(row)">Назначить роль</button>
<button class="btn-ghost" @click="toggleActive(row)">{{ row.isActive ? 'Заблокировать' : 'Активировать' }}</button>
</div>
</template>
</DataTable>