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
🚀 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:
@@ -17,7 +17,6 @@
|
|||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.Identity.Web" Version="4.9.0" />
|
<PackageReference Include="Microsoft.Identity.Web" Version="4.9.0" />
|
||||||
<PackageReference Include="Microsoft.Identity.Web.MicrosoftGraph" Version="4.9.0" />
|
<PackageReference Include="Microsoft.Identity.Web.MicrosoftGraph" Version="4.9.0" />
|
||||||
<PackageReference Include="Microsoft.OpenApi" Version="3.5.3" />
|
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
|
|||||||
@@ -3,12 +3,17 @@ import type {
|
|||||||
AchievementDto,
|
AchievementDto,
|
||||||
AuthResponse,
|
AuthResponse,
|
||||||
CoinTransactionDto,
|
CoinTransactionDto,
|
||||||
|
CourseDto,
|
||||||
LectureDto,
|
LectureDto,
|
||||||
LectureQuery,
|
LectureQuery,
|
||||||
|
LocationDto,
|
||||||
PagedResult,
|
PagedResult,
|
||||||
ReviewDto,
|
ReviewDto,
|
||||||
|
SyncStatusDto,
|
||||||
|
TagDto,
|
||||||
UserAchievementDto,
|
UserAchievementDto,
|
||||||
UserDto,
|
UserDto,
|
||||||
|
UserQuery,
|
||||||
UserStatsDto,
|
UserStatsDto,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
@@ -41,6 +46,12 @@ export const lecturesApi = {
|
|||||||
|
|
||||||
export const usersApi = {
|
export const usersApi = {
|
||||||
get: (id: string | number) => apiRequest<UserDto>(`/users/${id}`),
|
get: (id: string | number) => apiRequest<UserDto>(`/users/${id}`),
|
||||||
|
async list(query: UserQuery = {}) {
|
||||||
|
const payload = await apiRequest<PagedResult<UserDto> | UserDto[]>('/users', {
|
||||||
|
query: query as Record<string, unknown>,
|
||||||
|
})
|
||||||
|
return extractItems(payload)
|
||||||
|
},
|
||||||
stats: (id: string | number) => apiRequest<UserStatsDto>(`/users/${id}/stats`),
|
stats: (id: string | number) => apiRequest<UserStatsDto>(`/users/${id}/stats`),
|
||||||
async enrollments(id: string | number) {
|
async enrollments(id: string | number) {
|
||||||
const payload = await apiRequest<PagedResult<LectureDto> | LectureDto[] | undefined>(`/users/${id}/enrollments`)
|
const payload = await apiRequest<PagedResult<LectureDto> | LectureDto[] | undefined>(`/users/${id}/enrollments`)
|
||||||
@@ -59,6 +70,10 @@ export const usersApi = {
|
|||||||
)
|
)
|
||||||
return extractItems(payload)
|
return extractItems(payload)
|
||||||
},
|
},
|
||||||
|
setRole: (id: string | number, role: 'Student' | 'Teacher' | 'Admin') =>
|
||||||
|
apiRequest<void>(`/users/${id}/role`, { method: 'PATCH', body: JSON.stringify(role) }),
|
||||||
|
setActive: (id: string | number, isActive: boolean) =>
|
||||||
|
apiRequest<void>(`/users/${id}/active`, { method: 'PATCH', body: JSON.stringify(isActive) }),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const reviewsApi = {
|
export const reviewsApi = {
|
||||||
@@ -67,4 +82,34 @@ export const reviewsApi = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ lectureId: Number(lectureId), rating, text }),
|
body: JSON.stringify({ lectureId: Number(lectureId), rating, text }),
|
||||||
}),
|
}),
|
||||||
|
async pending() {
|
||||||
|
const payload = await apiRequest<PagedResult<ReviewDto> | ReviewDto[]>('/reviews/pending')
|
||||||
|
return extractItems(payload)
|
||||||
|
},
|
||||||
|
reanalyze: (id: string | number) => apiRequest<void>(`/reviews/${id}/reanalyze`, { method: 'POST' }),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const coursesApi = {
|
||||||
|
async list() {
|
||||||
|
const payload = await apiRequest<PagedResult<CourseDto> | CourseDto[]>('/courses', { query: { PageSize: 100 } })
|
||||||
|
return extractItems(payload)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const locationsApi = {
|
||||||
|
async list() {
|
||||||
|
const payload = await apiRequest<PagedResult<LocationDto> | LocationDto[]>('/locations')
|
||||||
|
return extractItems(payload)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tagsApi = {
|
||||||
|
async list() {
|
||||||
|
const payload = await apiRequest<PagedResult<TagDto> | TagDto[]>('/tags')
|
||||||
|
return extractItems(payload)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const syncApi = {
|
||||||
|
status: () => apiRequest<SyncStatusDto>('/sync/status'),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ export interface UserDto extends UserAuthDto {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserQuery {
|
||||||
|
Search?: string
|
||||||
|
Role?: ApiUserRole
|
||||||
|
IsActive?: boolean
|
||||||
|
Page?: number
|
||||||
|
PageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserStatsDto {
|
export interface UserStatsDto {
|
||||||
totalLectures: number
|
totalLectures: number
|
||||||
attendedLectures: number
|
attendedLectures: number
|
||||||
@@ -102,6 +110,45 @@ export interface AchievementDto {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CourseDto {
|
||||||
|
id: number
|
||||||
|
name?: string | null
|
||||||
|
description?: string | null
|
||||||
|
isSynced: boolean
|
||||||
|
tags?: TagDto[] | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationDto {
|
||||||
|
id: number
|
||||||
|
name?: string | null
|
||||||
|
building?: string | null
|
||||||
|
room?: string | null
|
||||||
|
address?: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiTagType = 'Institute' | 'Faculty' | 'Subject' | 'Organization' | 'Topic' | 'Other'
|
||||||
|
|
||||||
|
export interface TagDto {
|
||||||
|
id: number
|
||||||
|
name?: string | null
|
||||||
|
type: ApiTagType
|
||||||
|
parentId?: number | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncStatusDto {
|
||||||
|
lastSyncAt?: string | null
|
||||||
|
status?: string | null
|
||||||
|
lastResult?: {
|
||||||
|
created: number
|
||||||
|
updated: number
|
||||||
|
skipped: number
|
||||||
|
error?: string | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserAchievementDto {
|
export interface UserAchievementDto {
|
||||||
id: number
|
id: number
|
||||||
achievement: AchievementDto
|
achievement: AchievementDto
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ router.beforeEach(async (to) => {
|
|||||||
if (!to.meta.public && !auth.isAuthenticated) {
|
if (!to.meta.public && !auth.isAuthenticated) {
|
||||||
return '/login'
|
return '/login'
|
||||||
}
|
}
|
||||||
|
if (to.meta.role && auth.user && auth.user.role !== to.meta.role) {
|
||||||
|
if (auth.user.role === 'teacher') return '/teacher'
|
||||||
|
if (auth.user.role === 'admin') return '/admin'
|
||||||
|
return '/'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||||
import StatsWidget from '@/components/ui/StatsWidget.vue'
|
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 type { LectureDto, ReviewDto, SyncStatusDto, UserDto } from '@/api/types'
|
||||||
|
|
||||||
const disciplines = [
|
const disciplines = [
|
||||||
{ name: 'Информатика и ИИ', value: 80 },
|
{ name: 'Информатика и ИИ', value: 80 },
|
||||||
@@ -10,6 +13,30 @@ const disciplines = [
|
|||||||
{ name: 'Философия и этика', value: 42 },
|
{ name: 'Философия и этика', value: 42 },
|
||||||
{ name: 'Право и политика', value: 36 },
|
{ 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -17,10 +44,10 @@ const disciplines = [
|
|||||||
<h1 class="page-title">Дашборд администратора</h1>
|
<h1 class="page-title">Дашборд администратора</h1>
|
||||||
|
|
||||||
<div class="stats-row">
|
<div class="stats-row">
|
||||||
<StatsWidget label="Пользователей" :value="1247" icon="👥" color="green" />
|
<StatsWidget label="Пользователей" :value="users.length" icon="👥" color="green" />
|
||||||
<StatsWidget label="Лекций" :value="89" icon="📚" color="aqua" />
|
<StatsWidget label="Лекций" :value="lectures.length" icon="📚" color="aqua" />
|
||||||
<StatsWidget label="Записей" :value="3421" icon="🗓️" color="orange" />
|
<StatsWidget label="Записей" :value="enrollmentCount" icon="🗓️" color="orange" />
|
||||||
<StatsWidget label="Отзывов" :value="1089" icon="💬" color="purple" />
|
<StatsWidget label="Отзывов в LLM" :value="reviews.length" icon="💬" color="purple" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
@@ -54,14 +81,14 @@ const disciplines = [
|
|||||||
<div class="grid">
|
<div class="grid">
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<div class="section-title">Состояние синхронизации расписания</div>
|
<div class="section-title">Состояние синхронизации расписания</div>
|
||||||
<StatusBadge status="open" />
|
<StatusBadge :status="syncStatus?.status ?? 'pending'" />
|
||||||
<div class="sync-meta">Последняя синхронизация: сегодня, 09:15</div>
|
<div class="sync-meta">{{ syncMeta }}</div>
|
||||||
<div class="sync-error">Ошибка: 2 аудитории не сопоставлены с корпусами</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">В очереди: 24 отзыва · Обработка: 6/час</div>
|
<div class="queue-meta">В очереди: {{ reviews.length }} отзывов</div>
|
||||||
<ProgressBar :value="60" :max="100" />
|
<ProgressBar :value="Math.min(reviews.length * 10, 100)" :max="100" />
|
||||||
<div class="queue-status">Следующая проверка через 12 минут</div>
|
<div class="queue-status">Следующая проверка через 12 минут</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||||
import DataTable from '@/components/ui/DataTable.vue'
|
import DataTable from '@/components/ui/DataTable.vue'
|
||||||
import StatusBadge from '@/components/ui/StatusBadge.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 = [
|
const columns = [
|
||||||
{ key: 'id', label: 'ID' },
|
{ key: 'id', label: 'ID' },
|
||||||
@@ -15,22 +20,53 @@ const columns = [
|
|||||||
{ key: 'actions', label: 'Действия', align: 'right' },
|
{ key: 'actions', label: 'Действия', align: 'right' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const rows = [
|
const reviews = ref<Review[]>([])
|
||||||
{ id: 'RV-1024', lecture: 'Нейронные сети', student: 'А. Морозов', date: '06.05', status: 'pending', sentiment: 'Позитивный', quality: 0.82, coins: 20 },
|
const loading = ref(false)
|
||||||
{ id: 'RV-1025', lecture: 'Квантовые вычисления', student: 'Н. Иванова', date: '05.05', status: 'active', sentiment: 'Нейтральный', quality: 0.63, coins: 10 },
|
const error = ref('')
|
||||||
{ 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 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<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">Запустить повторный анализ</button>
|
<button class="btn-primary" @click="fetchPending">Обновить очередь</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
|
<EmptyState v-if="error" title="Не удалось загрузить очередь" :subtitle="error" />
|
||||||
|
<EmptyState v-else-if="!rows.length && !loading" title="Очередь пуста" subtitle="Нет отзывов, ожидающих LLM-анализ." />
|
||||||
<DataTable :columns="columns" :rows="rows">
|
<DataTable :columns="columns" :rows="rows">
|
||||||
<template #status="{ value }">
|
<template #status="{ value }">
|
||||||
<StatusBadge :status="value" />
|
<StatusBadge :status="value" />
|
||||||
@@ -40,8 +76,8 @@ const rows = [
|
|||||||
{{ value }}
|
{{ value }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions="{ row }">
|
||||||
<button class="btn-ghost">Повторить</button>
|
<button class="btn-ghost" @click="reanalyze(row.id)">Повторить</button>
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<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 GlassCard from '@/components/ui/GlassCard.vue'
|
||||||
import DataTable from '@/components/ui/DataTable.vue'
|
import DataTable from '@/components/ui/DataTable.vue'
|
||||||
|
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||||
|
|
||||||
type TabKey = 'lectures' | 'courses' | 'rooms' | 'tags'
|
type TabKey = 'lectures' | 'courses' | 'rooms' | 'tags'
|
||||||
type TabConfig = {
|
type TabConfig = {
|
||||||
@@ -11,6 +14,11 @@ type TabConfig = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activeTab = ref<TabKey>('lectures')
|
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> = {
|
const tabConfig: Record<TabKey, TabConfig> = {
|
||||||
lectures: {
|
lectures: {
|
||||||
@@ -21,11 +29,7 @@ const tabConfig: Record<TabKey, TabConfig> = {
|
|||||||
{ key: 'format', label: 'Формат' },
|
{ key: 'format', label: 'Формат' },
|
||||||
{ key: 'status', label: 'Синхронизация', align: 'center' },
|
{ key: 'status', label: 'Синхронизация', align: 'center' },
|
||||||
],
|
],
|
||||||
rows: [
|
rows: [],
|
||||||
{ id: '1', title: 'Введение в нейронные сети', teacher: 'Волков М.С.', format: 'Офлайн', status: 'Синхронизировано' },
|
|
||||||
{ id: '2', title: 'Квантовые вычисления', teacher: 'Петров А.И.', format: 'Офлайн', status: 'Синхронизировано' },
|
|
||||||
{ id: '3', title: 'Философия цифровой эпохи', teacher: 'Дмитриев К.О.', format: 'Онлайн', status: 'Ошибка' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
courses: {
|
courses: {
|
||||||
title: 'Курсы',
|
title: 'Курсы',
|
||||||
@@ -34,10 +38,7 @@ const tabConfig: Record<TabKey, TabConfig> = {
|
|||||||
{ key: 'institute', label: 'Институт' },
|
{ key: 'institute', label: 'Институт' },
|
||||||
{ key: 'tags', label: 'Теги' },
|
{ key: 'tags', label: 'Теги' },
|
||||||
],
|
],
|
||||||
rows: [
|
rows: [],
|
||||||
{ id: '1', title: 'Машинное обучение', institute: 'ИКТИБ', tags: '#ML #ИИ #Python' },
|
|
||||||
{ id: '2', title: 'Цифровая этика', institute: 'ИФиСН', tags: '#философия #этика' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
title: 'Аудитории',
|
title: 'Аудитории',
|
||||||
@@ -46,10 +47,7 @@ const tabConfig: Record<TabKey, TabConfig> = {
|
|||||||
{ key: 'room', label: 'Аудитория' },
|
{ key: 'room', label: 'Аудитория' },
|
||||||
{ key: 'capacity', label: 'Вместимость', align: 'center' },
|
{ key: 'capacity', label: 'Вместимость', align: 'center' },
|
||||||
],
|
],
|
||||||
rows: [
|
rows: [],
|
||||||
{ id: '1', building: 'ИКТИБ', room: '305', capacity: 30 },
|
|
||||||
{ id: '2', building: 'ИФиМКН', room: '201', capacity: 25 },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
tags: {
|
tags: {
|
||||||
title: 'Теги',
|
title: 'Теги',
|
||||||
@@ -58,14 +56,71 @@ const tabConfig: Record<TabKey, TabConfig> = {
|
|||||||
{ key: 'category', label: 'Категория' },
|
{ key: 'category', label: 'Категория' },
|
||||||
{ key: 'linked', label: 'Привязки', align: 'center' },
|
{ key: 'linked', label: 'Привязки', align: 'center' },
|
||||||
],
|
],
|
||||||
rows: [
|
rows: [],
|
||||||
{ id: '1', tag: '#ML', category: 'Data Science', linked: 12 },
|
|
||||||
{ id: '2', tag: '#философия', category: 'Гуманитарные', linked: 6 },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -85,6 +140,7 @@ const current = computed(() => tabConfig[activeTab.value])
|
|||||||
<div class="grid">
|
<div class="grid">
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<div class="section-title">{{ current.title }}</div>
|
<div class="section-title">{{ current.title }}</div>
|
||||||
|
<EmptyState v-if="!current.rows.length && !loading" title="Данных пока нет" subtitle="Backend не вернул записи для выбранного раздела." />
|
||||||
<DataTable :columns="current.columns" :rows="current.rows" />
|
<DataTable :columns="current.columns" :rows="current.rows" />
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<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 GlassCard from '@/components/ui/GlassCard.vue'
|
||||||
import DataTable from '@/components/ui/DataTable.vue'
|
import DataTable from '@/components/ui/DataTable.vue'
|
||||||
|
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||||
|
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const roleFilter = ref('Все роли')
|
const roleFilter = ref('Все роли')
|
||||||
const instituteFilter = ref('Все институты')
|
const instituteFilter = ref('Все институты')
|
||||||
|
const users = ref<UserDto[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'name', label: 'Имя' },
|
{ key: 'name', label: 'Имя' },
|
||||||
@@ -17,11 +23,51 @@ const columns = [
|
|||||||
{ key: 'actions', label: 'Действия', align: 'right' },
|
{ key: 'actions', label: 'Действия', align: 'right' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const rows = [
|
const roleLabels = { Student: 'Студент', Teacher: 'Преподаватель', Admin: 'Администратор' } as const
|
||||||
{ id: '1', name: 'Алексей Морозов', email: 'a.morozov@sfedu.ru', role: 'Студент', institute: 'ИКТИБ', activity: 'Высокая', created: '12.03.2024' },
|
const roleApi = { Студент: 'Student', Преподаватель: 'Teacher', Администратор: 'Admin' } as const
|
||||||
{ 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 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -34,7 +80,7 @@ const rows = [
|
|||||||
<GlassCard>
|
<GlassCard>
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<input v-model="search" class="glass-input" placeholder="Поиск по имени или email" />
|
<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>
|
<option>Студент</option>
|
||||||
<option>Преподаватель</option>
|
<option>Преподаватель</option>
|
||||||
@@ -49,18 +95,19 @@ const rows = [
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EmptyState v-if="error" title="Не удалось загрузить пользователей" :subtitle="error" />
|
||||||
|
<EmptyState v-else-if="!rows.length && !loading" title="Пользователей не найдено" subtitle="Попробуйте изменить фильтры." />
|
||||||
<DataTable :columns="columns" :rows="rows">
|
<DataTable :columns="columns" :rows="rows">
|
||||||
<template #role="{ value }">
|
<template #role="{ value }">
|
||||||
<span :class="value === 'Студент' ? 'badge badge-green' : value === 'Преподаватель' ? 'badge badge-blue' : 'badge badge-purple'">{{ value }}</span>
|
<span :class="value === 'Студент' ? 'badge badge-green' : value === 'Преподаватель' ? 'badge badge-blue' : 'badge badge-purple'">{{ value }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #activity="{ value }">
|
<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>
|
||||||
<template #actions>
|
<template #actions="{ row }">
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn-ghost">Назначить роль</button>
|
<button class="btn-ghost" @click="promoteRole(row)">Назначить роль</button>
|
||||||
<button class="btn-ghost">Заблокировать</button>
|
<button class="btn-ghost" @click="toggleActive(row)">{{ row.isActive ? 'Заблокировать' : 'Активировать' }}</button>
|
||||||
<button class="btn-ghost">Профиль</button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|||||||
@@ -1,8 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||||
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
||||||
|
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||||
|
import { lecturesApi } from '@/api'
|
||||||
|
import type { Review } from '@/types'
|
||||||
|
import { mapApiReview } from '@/api/mappers'
|
||||||
|
import { useLecturesStore } from '@/stores/lectures'
|
||||||
|
|
||||||
const ratingTrend = [4.2, 4.5, 4.6, 4.8, 4.7]
|
const ratingTrend = [4.2, 4.5, 4.6, 4.8, 4.7]
|
||||||
|
const lecturesStore = useLecturesStore()
|
||||||
|
const reviews = ref<Review[]>([])
|
||||||
|
|
||||||
|
const positive = computed(() => reviews.value.filter(r => r.sentiment === 'positive').length)
|
||||||
|
const neutral = computed(() => reviews.value.filter(r => r.sentiment === 'neutral').length)
|
||||||
|
const negative = computed(() => reviews.value.filter(r => r.sentiment === 'negative').length)
|
||||||
|
const total = computed(() => reviews.value.length || 1)
|
||||||
|
const pct = (value: number) => Math.round((value / total.value) * 100)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!lecturesStore.all.length) await lecturesStore.fetchLectures()
|
||||||
|
const targetLectures = lecturesStore.all.slice(0, 5)
|
||||||
|
const payload = await Promise.allSettled(targetLectures.map(l => lecturesApi.reviews(l.id)))
|
||||||
|
reviews.value = payload.flatMap(result => (result.status === 'fulfilled' ? result.value.map(mapApiReview) : []))
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -25,16 +46,16 @@ const ratingTrend = [4.2, 4.5, 4.6, 4.8, 4.7]
|
|||||||
<div class="section-title">Sentiment-анализ отзывов</div>
|
<div class="section-title">Sentiment-анализ отзывов</div>
|
||||||
<div class="sentiment">
|
<div class="sentiment">
|
||||||
<div>
|
<div>
|
||||||
<div class="sentiment-label">Позитивные 65%</div>
|
<div class="sentiment-label">Позитивные {{ pct(positive) }}%</div>
|
||||||
<ProgressBar :value="65" :max="100" />
|
<ProgressBar :value="pct(positive)" :max="100" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="sentiment-label">Нейтральные 25%</div>
|
<div class="sentiment-label">Нейтральные {{ pct(neutral) }}%</div>
|
||||||
<ProgressBar :value="25" :max="100" color="linear-gradient(90deg, #7DD3FC, #BAE6FD)" />
|
<ProgressBar :value="pct(neutral)" :max="100" color="linear-gradient(90deg, #7DD3FC, #BAE6FD)" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="sentiment-label">Негативные 10%</div>
|
<div class="sentiment-label">Негативные {{ pct(negative) }}%</div>
|
||||||
<ProgressBar :value="10" :max="100" color="linear-gradient(90deg, #FCA5A5, #FECACA)" />
|
<ProgressBar :value="pct(negative)" :max="100" color="linear-gradient(90deg, #FCA5A5, #FECACA)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
@@ -56,21 +77,15 @@ const ratingTrend = [4.2, 4.5, 4.6, 4.8, 4.7]
|
|||||||
|
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<div class="section-title">Анонимные отзывы</div>
|
<div class="section-title">Анонимные отзывы</div>
|
||||||
<div class="reviews">
|
<EmptyState v-if="!reviews.length" title="Отзывов пока нет" subtitle="Когда студенты оставят отзывы, они появятся здесь." />
|
||||||
<div class="review">
|
<div v-else class="reviews">
|
||||||
«Больше кейсов и примеров из реальной жизни, лекция очень понравилась»
|
<div v-for="review in reviews" :key="review.id" class="review">
|
||||||
</div>
|
«{{ review.text }}»
|
||||||
<div class="review">
|
|
||||||
«Темп быстрый, но структура отличная. Хотелось бы больше практических заданий.»
|
|
||||||
</div>
|
|
||||||
<div class="review">
|
|
||||||
«Отличные слайды и примеры, спасибо за доступное объяснение сложных тем.»
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-title">Топ полезных отзывов</div>
|
<div class="section-title">Топ полезных отзывов</div>
|
||||||
<ul class="top-list">
|
<ul class="top-list">
|
||||||
<li>«Лабораторная часть помогла понять алгоритмы, пожалуйста, добавьте еще 15 минут»</li>
|
<li v-for="review in reviews.slice(0, 2)" :key="review.id">«{{ review.text }}»</li>
|
||||||
<li>«Понравились интерактивные задания, хочется больше времени на Q&A»</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { useLecturesStore } from '@/stores/lectures'
|
import { useLecturesStore } from '@/stores/lectures'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||||
import StatsWidget from '@/components/ui/StatsWidget.vue'
|
import StatsWidget from '@/components/ui/StatsWidget.vue'
|
||||||
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
||||||
|
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||||
|
|
||||||
const lecturesStore = useLecturesStore()
|
const lecturesStore = useLecturesStore()
|
||||||
const upcoming = computed(() => lecturesStore.all.slice(0, 3))
|
const auth = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const teacherLectures = computed(() => {
|
||||||
|
const owned = lecturesStore.all.filter(l => auth.user && l.teacher.includes(auth.user.name))
|
||||||
|
return owned.length ? owned : lecturesStore.all
|
||||||
|
})
|
||||||
|
const upcoming = computed(() => teacherLectures.value.filter(l => l.status !== 'completed').slice(0, 3))
|
||||||
|
const enrolledTotal = computed(() => teacherLectures.value.reduce((sum, l) => sum + (l.totalSeats - l.freeSeats), 0))
|
||||||
|
const visibility = computed(() => (teacherLectures.value.length ? Math.min(100, Math.round(enrolledTotal.value * 4)) : 0))
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!lecturesStore.all.length) void lecturesStore.fetchLectures()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -14,39 +30,39 @@ const upcoming = computed(() => lecturesStore.all.slice(0, 3))
|
|||||||
<div class="header">
|
<div class="header">
|
||||||
<h1 class="page-title">Дашборд преподавателя</h1>
|
<h1 class="page-title">Дашборд преподавателя</h1>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn-primary">Анонсировать лекцию</button>
|
<button class="btn-primary" @click="router.push('/teacher/lectures')">Мои лекции</button>
|
||||||
<button class="btn-secondary">Посмотреть отзывы</button>
|
<button class="btn-secondary" @click="router.push('/teacher/analytics')">Посмотреть отзывы</button>
|
||||||
<button class="btn-secondary">Отметить посещение</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats-row">
|
<div class="stats-row">
|
||||||
<StatsWidget label="Предстоящие лекции" :value="3" icon="📅" color="green" />
|
<StatsWidget label="Предстоящие лекции" :value="upcoming.length" icon="📅" color="green" />
|
||||||
<StatsWidget label="Записавшихся" :value="47" icon="👥" color="aqua" />
|
<StatsWidget label="Записавшихся" :value="enrolledTotal" icon="👥" color="aqua" />
|
||||||
<StatsWidget label="Средняя оценка" :value="4.6" icon="⭐" color="orange" />
|
<StatsWidget label="Средняя оценка" :value="'—'" icon="⭐" color="orange" />
|
||||||
<StatsWidget label="Вовлеченность вне направления" :value="'38%'" icon="🌍" color="purple" />
|
<StatsWidget label="Вовлеченность вне направления" :value="`${visibility}%`" icon="🌍" color="purple" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<div class="section-title">Заметность за пределами направления</div>
|
<div class="section-title">Заметность за пределами направления</div>
|
||||||
<div class="visibility">
|
<div class="visibility">
|
||||||
<div class="visibility-meta">
|
<div class="visibility-meta">
|
||||||
38% студентов из других институтов · Цель 50%
|
{{ visibility }}% студентов из других институтов · Цель 50%
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar :value="38" :max="100" />
|
<ProgressBar :value="visibility" :max="100" />
|
||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<div class="section-title">Ближайшие открытые лекции</div>
|
<div class="section-title">Ближайшие открытые лекции</div>
|
||||||
<div class="upcoming">
|
<EmptyState v-if="!upcoming.length" title="Лекций пока нет" subtitle="После синхронизации или назначения лекции появятся здесь." />
|
||||||
|
<div v-else class="upcoming">
|
||||||
<div class="upcoming-item" v-for="l in upcoming" :key="l.id">
|
<div class="upcoming-item" v-for="l in upcoming" :key="l.id">
|
||||||
<div>
|
<div>
|
||||||
<div class="upcoming-title">{{ l.title }}</div>
|
<div class="upcoming-title">{{ l.title }}</div>
|
||||||
<div class="upcoming-meta">📅 {{ new Date(l.date).toLocaleDateString('ru-RU') }} · {{ l.time }}</div>
|
<div class="upcoming-meta">📅 {{ new Date(l.date).toLocaleDateString('ru-RU') }} · {{ l.time }}</div>
|
||||||
<div class="upcoming-meta">Записалось {{ l.totalSeats - l.freeSeats }} студентов</div>
|
<div class="upcoming-meta">Записалось {{ l.totalSeats - l.freeSeats }} студентов</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-secondary btn-sm">Управлять</button>
|
<button class="btn-secondary btn-sm" @click="router.push('/teacher/lectures')">Управлять</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useLecturesStore } from '@/stores/lectures'
|
||||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||||
import DataTable from '@/components/ui/DataTable.vue'
|
import DataTable from '@/components/ui/DataTable.vue'
|
||||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
|
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const lecturesStore = useLecturesStore()
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'title', label: 'Лекция' },
|
{ key: 'title', label: 'Лекция' },
|
||||||
@@ -11,31 +17,41 @@ const columns = [
|
|||||||
{ key: 'actions', label: 'Действия', align: 'right' },
|
{ key: 'actions', label: 'Действия', align: 'right' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const rows = [
|
const rows = computed(() => {
|
||||||
{ id: '1', title: 'Введение в нейронные сети', date: '07.05 · 14:00', status: 'upcoming', stats: '28 / — / —' },
|
const owned = lecturesStore.all.filter(l => auth.user && l.teacher.includes(auth.user.name))
|
||||||
{ id: '2', title: 'Алгоритмы глубокого обучения', date: '08.05 · 16:00', status: 'ongoing', stats: '31 / 22 / 15' },
|
return (owned.length ? owned : lecturesStore.all).map(l => ({
|
||||||
{ id: '3', title: 'Практика по ML в бизнесе', date: '01.05 · 12:00', status: 'completed', stats: '45 / 39 / 27' },
|
id: l.id,
|
||||||
]
|
title: l.title,
|
||||||
|
date: `${new Date(l.date).toLocaleDateString('ru-RU')} · ${l.time}`,
|
||||||
|
status: l.status ?? 'upcoming',
|
||||||
|
stats: `${l.totalSeats - l.freeSeats} / — / ${l.reviewCount}`,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!lecturesStore.all.length) void lecturesStore.fetchLectures()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="teacher-lectures page-content">
|
<div class="teacher-lectures page-content">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1 class="page-title">Мои лекции</h1>
|
<h1 class="page-title">Мои лекции</h1>
|
||||||
<button class="btn-primary">Создать лекцию</button>
|
<button class="btn-primary" disabled>Создать лекцию</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
|
<EmptyState v-if="!rows.length && !lecturesStore.loading" title="Лекций пока нет" subtitle="Backend не вернул лекции для текущего преподавателя." />
|
||||||
<DataTable :columns="columns" :rows="rows">
|
<DataTable :columns="columns" :rows="rows">
|
||||||
<template #status="{ value }">
|
<template #status="{ value }">
|
||||||
<StatusBadge :status="value" />
|
<StatusBadge :status="value" />
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn-ghost">Редактировать</button>
|
<button class="btn-ghost" disabled>Редактировать</button>
|
||||||
<button class="btn-ghost">Открыть/закрыть запись</button>
|
<button class="btn-ghost" disabled>Открыть/закрыть запись</button>
|
||||||
<button class="btn-ghost">Список записавшихся</button>
|
<button class="btn-ghost" disabled>Список записавшихся</button>
|
||||||
<button class="btn-ghost">Отметить посещение</button>
|
<button class="btn-ghost" disabled>Отметить посещение</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|||||||
Reference in New Issue
Block a user