refactor: натравил форматтер на весь фронт

This commit is contained in:
2026-05-25 02:06:11 +03:00
parent 24df65a13c
commit 98aaa86ec4
43 changed files with 1947 additions and 657 deletions
@@ -14,9 +14,9 @@ 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 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)
@@ -24,8 +24,10 @@ 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) : []))
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)
@@ -57,11 +59,19 @@ watch(() => auth.user?.id, fetchTeacherAnalytics)
</div>
<div>
<div class="sentiment-label">Нейтральные {{ pct(neutral) }}%</div>
<ProgressBar :value="pct(neutral)" :max="100" color="linear-gradient(90deg, #7DD3FC, #BAE6FD)" />
<ProgressBar
:value="pct(neutral)"
:max="100"
color="linear-gradient(90deg, #7DD3FC, #BAE6FD)"
/>
</div>
<div>
<div class="sentiment-label">Негативные {{ pct(negative) }}%</div>
<ProgressBar :value="pct(negative)" :max="100" color="linear-gradient(90deg, #FCA5A5, #FECACA)" />
<ProgressBar
:value="pct(negative)"
:max="100"
color="linear-gradient(90deg, #FCA5A5, #FECACA)"
/>
</div>
</div>
</GlassCard>
@@ -70,8 +80,9 @@ watch(() => auth.user?.id, fetchTeacherAnalytics)
<GlassCard>
<div class="section-title">LLM-сводка проблем и рекомендаций</div>
<p class="summary">
Студенты отмечают сильную практическую часть, но хотят больше разборов вопросов из аудитории.
Рекомендуется добавить блок с кейсами из реальных проектов и увеличить время на интерактив.
Студенты отмечают сильную практическую часть, но хотят больше разборов вопросов из
аудитории. Рекомендуется добавить блок с кейсами из реальных проектов и увеличить время на
интерактив.
</p>
<div class="tags">
<span class="tag-chip">много практики</span>
@@ -83,29 +94,92 @@ watch(() => auth.user?.id, fetchTeacherAnalytics)
<GlassCard>
<div class="section-title">Отзывы</div>
<EmptyState v-if="!reviews.length" title="Отзывов пока нет" subtitle="Когда студенты оставят отзывы, они появятся здесь." />
<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 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; }
.chart { display: flex; gap: 12px; align-items: flex-end; height: 160px; padding: 10px 0; }
.bar { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.bar-fill { width: 26px; border-radius: 6px 6px 0 0; background: linear-gradient(180deg, #22C55E, #86EFAC); }
.bar-label { font-size: 11px; color: var(--color-text-secondary); }
.avg { margin-top: 6px; font-weight: 600; }
.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; }
.top-list { padding-left: 18px; color: var(--color-text-secondary); font-size: 13px; }
.teacher-analytics {
display: flex;
flex-direction: column;
gap: 18px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.chart {
display: flex;
gap: 12px;
align-items: flex-end;
height: 160px;
padding: 10px 0;
}
.bar {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.bar-fill {
width: 26px;
border-radius: 6px 6px 0 0;
background: linear-gradient(180deg, #22c55e, #86efac);
}
.bar-label {
font-size: 11px;
color: var(--color-text-secondary);
}
.avg {
margin-top: 6px;
font-weight: 600;
}
.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;
}
.top-list {
padding-left: 18px;
color: var(--color-text-secondary);
font-size: 13px;
}
</style>
@@ -14,9 +14,15 @@ const router = useRouter()
const teacherLectures = computed(() => {
return 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.enrolledSeats, 0))
const visibility = computed(() => (teacherLectures.value.length ? Math.min(100, Math.round(enrolledTotal.value * 4)) : 0))
const upcoming = computed(() =>
teacherLectures.value.filter((l) => l.status !== 'completed').slice(0, 3),
)
const enrolledTotal = computed(() =>
teacherLectures.value.reduce((sum, l) => sum + l.enrolledSeats, 0),
)
const visibility = computed(() =>
teacherLectures.value.length ? Math.min(100, Math.round(enrolledTotal.value * 4)) : 0,
)
function fetchTeacherLectures() {
if (!auth.user?.id) return
@@ -33,7 +39,9 @@ watch(() => auth.user?.id, fetchTeacherLectures)
<h1 class="page-title">Дашборд преподавателя</h1>
<div class="actions">
<button class="btn-primary" @click="router.push('/teacher/lectures')">Мои лекции</button>
<button class="btn-secondary" @click="router.push('/teacher/analytics')">Посмотреть отзывы</button>
<button class="btn-secondary" @click="router.push('/teacher/analytics')">
Посмотреть отзывы
</button>
</div>
</div>
@@ -41,20 +49,33 @@ watch(() => auth.user?.id, fetchTeacherLectures)
<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" />
<StatsWidget
label="Вовлеченность вне направления"
:value="`${visibility}%`"
icon="🌍"
color="purple"
/>
</div>
<GlassCard>
<div class="section-title">Ближайшие лекции</div>
<EmptyState v-if="!upcoming.length" title="Лекций пока нет" subtitle="После синхронизации или назначения лекции появятся здесь." />
<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">
{{ new Date(l.date).toLocaleDateString('ru-RU') }} {{ l.time }}
</div>
<div class="upcoming-meta">Записалось {{ l.enrolledSeats }} студентов</div>
</div>
<button class="btn-secondary btn-sm" @click="router.push('/teacher/lectures')">Управлять</button>
<button class="btn-secondary btn-sm" @click="router.push('/teacher/lectures')">
Управлять
</button>
</div>
</div>
</GlassCard>
@@ -62,16 +83,63 @@ watch(() => auth.user?.id, fetchTeacherLectures)
</template>
<style scoped>
.teacher-dashboard { display: flex; flex-direction: column; gap: 18px; }
.header { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; }
.visibility { display: flex; flex-direction: column; gap: 8px; }
.visibility-meta { font-size: 13px; color: var(--color-text-secondary); }
.upcoming { display: flex; flex-direction: column; gap: 12px; margin-top: 10px; }
.upcoming-item { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--color-border-glass); }
.upcoming-item:last-child { border-bottom: none; padding-bottom: 0; }
.upcoming-title { font-weight: 700; }
.upcoming-meta { font-size: 13px; color: var(--color-text-secondary); }
.btn-sm { padding: 6px 12px; font-size: 12px; }
.teacher-dashboard {
display: flex;
flex-direction: column;
gap: 18px;
}
.header {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
.visibility {
display: flex;
flex-direction: column;
gap: 8px;
}
.visibility-meta {
font-size: 13px;
color: var(--color-text-secondary);
}
.upcoming {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 10px;
}
.upcoming-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--color-border-glass);
}
.upcoming-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.upcoming-title {
font-weight: 700;
}
.upcoming-meta {
font-size: 13px;
color: var(--color-text-secondary);
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
</style>
@@ -18,7 +18,7 @@ const columns = [
]
const rows = computed(() => {
return lecturesStore.all.map(l => ({
return lecturesStore.all.map((l) => ({
id: l.id,
title: l.title,
date: `${new Date(l.date).toLocaleDateString('ru-RU')} ${l.time}`,
@@ -44,7 +44,11 @@ watch(() => auth.user?.id, fetchTeacherLectures)
</div>
<GlassCard>
<EmptyState v-if="!rows.length && !lecturesStore.loading" title="Лекций пока нет" subtitle="Backend не вернул лекции для текущего преподавателя." />
<EmptyState
v-if="!rows.length && !lecturesStore.loading"
title="Лекций пока нет"
subtitle="Backend не вернул лекции для текущего преподавателя."
/>
<DataTable :columns="columns" :rows="rows">
<template #status="{ value }">
<StatusBadge :status="value" />
@@ -63,7 +67,22 @@ watch(() => auth.user?.id, fetchTeacherLectures)
</template>
<style scoped>
.teacher-lectures { display: flex; flex-direction: column; gap: 16px; }
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.actions { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; }
.teacher-lectures {
display: flex;
flex-direction: column;
gap: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
</style>