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:
@@ -1,8 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import GlassCard from '@/components/ui/GlassCard.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 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>
|
||||
|
||||
<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="sentiment">
|
||||
<div>
|
||||
<div class="sentiment-label">Позитивные 65%</div>
|
||||
<ProgressBar :value="65" :max="100" />
|
||||
<div class="sentiment-label">Позитивные {{ pct(positive) }}%</div>
|
||||
<ProgressBar :value="pct(positive)" :max="100" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="sentiment-label">Нейтральные 25%</div>
|
||||
<ProgressBar :value="25" :max="100" color="linear-gradient(90deg, #7DD3FC, #BAE6FD)" />
|
||||
<div class="sentiment-label">Нейтральные {{ pct(neutral) }}%</div>
|
||||
<ProgressBar :value="pct(neutral)" :max="100" color="linear-gradient(90deg, #7DD3FC, #BAE6FD)" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="sentiment-label">Негативные 10%</div>
|
||||
<ProgressBar :value="10" :max="100" color="linear-gradient(90deg, #FCA5A5, #FECACA)" />
|
||||
<div class="sentiment-label">Негативные {{ pct(negative) }}%</div>
|
||||
<ProgressBar :value="pct(negative)" :max="100" color="linear-gradient(90deg, #FCA5A5, #FECACA)" />
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
@@ -56,21 +77,15 @@ const ratingTrend = [4.2, 4.5, 4.6, 4.8, 4.7]
|
||||
|
||||
<GlassCard>
|
||||
<div class="section-title">Анонимные отзывы</div>
|
||||
<div class="reviews">
|
||||
<div class="review">
|
||||
«Больше кейсов и примеров из реальной жизни, лекция очень понравилась»
|
||||
</div>
|
||||
<div class="review">
|
||||
«Темп быстрый, но структура отличная. Хотелось бы больше практических заданий.»
|
||||
</div>
|
||||
<div class="review">
|
||||
«Отличные слайды и примеры, спасибо за доступное объяснение сложных тем.»
|
||||
<EmptyState v-if="!reviews.length" title="Отзывов пока нет" subtitle="Когда студенты оставят отзывы, они появятся здесь." />
|
||||
<div v-else class="reviews">
|
||||
<div v-for="review in reviews" :key="review.id" class="review">
|
||||
«{{ review.text }}»
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-title">Топ полезных отзывов</div>
|
||||
<ul class="top-list">
|
||||
<li>«Лабораторная часть помогла понять алгоритмы, пожалуйста, добавьте еще 15 минут»</li>
|
||||
<li>«Понравились интерактивные задания, хочется больше времени на Q&A»</li>
|
||||
<li v-for="review in reviews.slice(0, 2)" :key="review.id">«{{ review.text }}»</li>
|
||||
</ul>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useLecturesStore } from '@/stores/lectures'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||
import StatsWidget from '@/components/ui/StatsWidget.vue'
|
||||
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -14,39 +30,39 @@ const upcoming = computed(() => lecturesStore.all.slice(0, 3))
|
||||
<div class="header">
|
||||
<h1 class="page-title">Дашборд преподавателя</h1>
|
||||
<div class="actions">
|
||||
<button class="btn-primary">Анонсировать лекцию</button>
|
||||
<button class="btn-secondary">Посмотреть отзывы</button>
|
||||
<button class="btn-secondary">Отметить посещение</button>
|
||||
<button class="btn-primary" @click="router.push('/teacher/lectures')">Мои лекции</button>
|
||||
<button class="btn-secondary" @click="router.push('/teacher/analytics')">Посмотреть отзывы</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-row">
|
||||
<StatsWidget label="Предстоящие лекции" :value="3" icon="📅" color="green" />
|
||||
<StatsWidget label="Записавшихся" :value="47" icon="👥" color="aqua" />
|
||||
<StatsWidget label="Средняя оценка" :value="4.6" icon="⭐" color="orange" />
|
||||
<StatsWidget label="Вовлеченность вне направления" :value="'38%'" icon="🌍" color="purple" />
|
||||
<StatsWidget label="Предстоящие лекции" :value="upcoming.length" icon="📅" color="green" />
|
||||
<StatsWidget label="Записавшихся" :value="enrolledTotal" icon="👥" color="aqua" />
|
||||
<StatsWidget label="Средняя оценка" :value="'—'" icon="⭐" color="orange" />
|
||||
<StatsWidget label="Вовлеченность вне направления" :value="`${visibility}%`" icon="🌍" color="purple" />
|
||||
</div>
|
||||
|
||||
<GlassCard>
|
||||
<div class="section-title">Заметность за пределами направления</div>
|
||||
<div class="visibility">
|
||||
<div class="visibility-meta">
|
||||
38% студентов из других институтов · Цель 50%
|
||||
{{ visibility }}% студентов из других институтов · Цель 50%
|
||||
</div>
|
||||
<ProgressBar :value="38" :max="100" />
|
||||
<ProgressBar :value="visibility" :max="100" />
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard>
|
||||
<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>
|
||||
<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">Записалось {{ l.totalSeats - l.freeSeats }} студентов</div>
|
||||
</div>
|
||||
<button class="btn-secondary btn-sm">Управлять</button>
|
||||
<button class="btn-secondary btn-sm" @click="router.push('/teacher/lectures')">Управлять</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<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 DataTable from '@/components/ui/DataTable.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||
const auth = useAuthStore()
|
||||
const lecturesStore = useLecturesStore()
|
||||
|
||||
const columns = [
|
||||
{ key: 'title', label: 'Лекция' },
|
||||
@@ -11,31 +17,41 @@ const columns = [
|
||||
{ key: 'actions', label: 'Действия', align: 'right' },
|
||||
]
|
||||
|
||||
const rows = [
|
||||
{ id: '1', title: 'Введение в нейронные сети', date: '07.05 · 14:00', status: 'upcoming', stats: '28 / — / —' },
|
||||
{ id: '2', title: 'Алгоритмы глубокого обучения', date: '08.05 · 16:00', status: 'ongoing', stats: '31 / 22 / 15' },
|
||||
{ id: '3', title: 'Практика по ML в бизнесе', date: '01.05 · 12:00', status: 'completed', stats: '45 / 39 / 27' },
|
||||
]
|
||||
const rows = computed(() => {
|
||||
const owned = lecturesStore.all.filter(l => auth.user && l.teacher.includes(auth.user.name))
|
||||
return (owned.length ? owned : lecturesStore.all).map(l => ({
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="teacher-lectures page-content">
|
||||
<div class="header">
|
||||
<h1 class="page-title">Мои лекции</h1>
|
||||
<button class="btn-primary">Создать лекцию</button>
|
||||
<button class="btn-primary" disabled>Создать лекцию</button>
|
||||
</div>
|
||||
|
||||
<GlassCard>
|
||||
<EmptyState v-if="!rows.length && !lecturesStore.loading" title="Лекций пока нет" subtitle="Backend не вернул лекции для текущего преподавателя." />
|
||||
<DataTable :columns="columns" :rows="rows">
|
||||
<template #status="{ value }">
|
||||
<StatusBadge :status="value" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="actions">
|
||||
<button class="btn-ghost">Редактировать</button>
|
||||
<button class="btn-ghost">Открыть/закрыть запись</button>
|
||||
<button class="btn-ghost">Список записавшихся</button>
|
||||
<button class="btn-ghost">Отметить посещение</button>
|
||||
<button class="btn-ghost" disabled>Редактировать</button>
|
||||
<button class="btn-ghost" disabled>Открыть/закрыть запись</button>
|
||||
<button class="btn-ghost" disabled>Список записавшихся</button>
|
||||
<button class="btn-ghost" disabled>Отметить посещение</button>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
Reference in New Issue
Block a user