146 lines
4.8 KiB
Vue
146 lines
4.8 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch } 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'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
|
||
const lecturesStore = useLecturesStore()
|
||
const auth = useAuthStore()
|
||
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)
|
||
const pct = (value: number) => (total.value ? Math.round((value / total.value) * 100) : 0)
|
||
const ratio = (value: number) => `${value}/${total.value}`
|
||
|
||
async function fetchTeacherAnalytics() {
|
||
if (!auth.user?.id) return
|
||
await lecturesStore.fetchLectures({ TeacherId: auth.user.id })
|
||
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) : [],
|
||
)
|
||
}
|
||
|
||
onMounted(fetchTeacherAnalytics)
|
||
watch(() => auth.user?.id, fetchTeacherAnalytics)
|
||
</script>
|
||
|
||
<template>
|
||
<div class="teacher-analytics page-content">
|
||
<h1 class="page-title">Аналитика преподавателя</h1>
|
||
|
||
<div class="grid">
|
||
<GlassCard>
|
||
<div class="section-title">Sentiment-анализ отзывов</div>
|
||
<div class="sentiment">
|
||
<div>
|
||
<div class="sentiment-label">Позитивные {{ ratio(positive) }}</div>
|
||
<ProgressBar :value="pct(positive)" :max="100" :text="ratio(positive)" />
|
||
</div>
|
||
<div>
|
||
<div class="sentiment-label">Нейтральные {{ ratio(neutral) }}</div>
|
||
<ProgressBar
|
||
:value="pct(neutral)"
|
||
:max="100"
|
||
:text="ratio(neutral)"
|
||
color="linear-gradient(90deg, #7DD3FC, #BAE6FD)"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<div class="sentiment-label">Негативные {{ ratio(negative) }}</div>
|
||
<ProgressBar
|
||
:value="pct(negative)"
|
||
:max="100"
|
||
:text="ratio(negative)"
|
||
color="linear-gradient(90deg, #FCA5A5, #FECACA)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</GlassCard>
|
||
</div>
|
||
|
||
<GlassCard>
|
||
<div class="section-title">LLM-сводка проблем и рекомендаций</div>
|
||
<p class="summary">
|
||
Студенты отмечают сильную практическую часть, но хотят больше разборов вопросов из
|
||
аудитории. Рекомендуется добавить блок с кейсами из реальных проектов и увеличить время на
|
||
интерактив.
|
||
</p>
|
||
<div class="tags">
|
||
<span class="tag-chip">много практики</span>
|
||
<span class="tag-chip">понятные примеры</span>
|
||
<span class="tag-chip">сложный материал</span>
|
||
<span class="tag-chip">нужны задания</span>
|
||
</div>
|
||
</GlassCard>
|
||
|
||
<GlassCard>
|
||
<div class="section-title">Отзывы</div>
|
||
<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>
|
||
</GlassCard>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.teacher-analytics {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 18px;
|
||
}
|
||
.grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
.sentiment {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
.sentiment-label {
|
||
font-size: 12px;
|
||
color: var(--color-text-secondary);
|
||
margin-bottom: 4px;
|
||
}
|
||
.summary {
|
||
font-size: 14px;
|
||
color: var(--color-text-secondary);
|
||
line-height: 1.5;
|
||
}
|
||
.tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-top: 10px;
|
||
}
|
||
.reviews {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
margin-top: 10px;
|
||
}
|
||
.review {
|
||
background: rgba(255, 255, 255, 0.6);
|
||
border: 1px solid var(--color-border-glass);
|
||
padding: 10px;
|
||
border-radius: var(--radius-sm);
|
||
font-size: 13px;
|
||
}
|
||
</style>
|