Dev #11

Merged
serega404 merged 87 commits from dev into main 2026-05-25 03:22:55 +03:00
6 changed files with 51 additions and 72 deletions
Showing only changes of commit a8a20f9b0b - Show all commits
+2 -13
View File
@@ -1,15 +1,14 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { lecturesApi, usersApi } from '@/api' import { lecturesApi, usersApi } from '@/api'
import { mapApiLecture, mapApiReview } from '@/api/mappers' import { mapApiLecture } from '@/api/mappers'
import type { Lecture, Review } from '@/types' import type { Lecture } from '@/types'
import type { LectureQuery } from '@/api/types' import type { LectureQuery } from '@/api/types'
import { useUserStore } from './user' import { useUserStore } from './user'
export const useLecturesStore = defineStore('lectures', () => { export const useLecturesStore = defineStore('lectures', () => {
const lectures = ref<Lecture[]>([]) const lectures = ref<Lecture[]>([])
const registered = ref<string[]>([]) const registered = ref<string[]>([])
const reviewsByLecture = ref<Record<string, Review[]>>({})
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
@@ -65,14 +64,6 @@ export const useLecturesStore = defineStore('lectures', () => {
} }
} }
async function fetchReviews(lectureId: string) {
try {
reviewsByLecture.value[lectureId] = (await lecturesApi.reviews(lectureId)).map(mapApiReview)
} catch {
reviewsByLecture.value[lectureId] = []
}
}
async function register(lectureId: string) { async function register(lectureId: string) {
const lecture = lectures.value.find(item => item.id === lectureId) const lecture = lectures.value.find(item => item.id === lectureId)
if (!lecture || lecture.freeSeats === 0 || lecture.registrationClosed || registered.value.includes(lectureId)) return if (!lecture || lecture.freeSeats === 0 || lecture.registrationClosed || registered.value.includes(lectureId)) return
@@ -111,7 +102,6 @@ export const useLecturesStore = defineStore('lectures', () => {
return { return {
lectures, lectures,
registered, registered,
reviewsByLecture,
loading, loading,
error, error,
all, all,
@@ -120,7 +110,6 @@ export const useLecturesStore = defineStore('lectures', () => {
fetchLectures, fetchLectures,
fetchLecture, fetchLecture,
fetchRegisteredForCurrentUser, fetchRegisteredForCurrentUser,
fetchReviews,
register, register,
unregister, unregister,
isRegistered, isRegistered,
+1 -10
View File
@@ -5,7 +5,6 @@ import { useAuthStore } from '@/stores/auth'
import { useLecturesStore } from '@/stores/lectures' import { useLecturesStore } from '@/stores/lectures'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import GlassCard from '@/components/ui/GlassCard.vue' import GlassCard from '@/components/ui/GlassCard.vue'
import StatsWidget from '@/components/ui/StatsWidget.vue'
import LectureCard from '@/components/ui/LectureCard.vue' import LectureCard from '@/components/ui/LectureCard.vue'
import ProgressBar from '@/components/ui/ProgressBar.vue' import ProgressBar from '@/components/ui/ProgressBar.vue'
import AchievementBadge from '@/components/ui/AchievementBadge.vue' import AchievementBadge from '@/components/ui/AchievementBadge.vue'
@@ -90,7 +89,6 @@ async function registerLecture(id: string) {
<div class="quick-actions"> <div class="quick-actions">
<button class="btn-primary" @click="router.push('/catalog')">Найти лекцию</button> <button class="btn-primary" @click="router.push('/catalog')">Найти лекцию</button>
<button class="btn-secondary" @click="router.push('/my-lectures')">Мои записи</button> <button class="btn-secondary" @click="router.push('/my-lectures')">Мои записи</button>
<button class="btn-secondary" :disabled="!nextLecture" @click="nextLecture && router.push(`/review/${nextLecture.id}`)">Оставить отзыв</button>
</div> </div>
</div> </div>
@@ -122,13 +120,6 @@ async function registerLecture(id: string) {
</GlassCard> </GlassCard>
<EmptyState v-else-if="!lectures.loading" title="Пока нет лекций" subtitle="Каталог пуст или данные ещё не синхронизированы." /> <EmptyState v-else-if="!lectures.loading" title="Пока нет лекций" subtitle="Каталог пуст или данные ещё не синхронизированы." />
<div class="stats-row">
<StatsWidget label="Посещено лекций" :value="user.lecturesAttended ?? 12" icon="books" color="green" />
<StatsWidget label="Часов обучения" :value="user.hoursLearned ?? 18.5" icon="stopwatch" color="aqua" />
<StatsWidget label="Монет" :value="user.coins" icon="coin" color="orange" />
<StatsWidget label="Уровень" :value="user.level" icon="star" color="purple" sub="текущий уровень" />
</div>
<GlassCard> <GlassCard>
<div class="xp-section"> <div class="xp-section">
<div class="xp-header"> <div class="xp-header">
@@ -186,7 +177,7 @@ async function registerLecture(id: string) {
<div class="section-title"> <div class="section-title">
<span class="title-with-icon"> <span class="title-with-icon">
<AppIcon class="title-icon" icon="bell" :size="18" /> <AppIcon class="title-icon" icon="bell" :size="18" />
Напоминания Увидомления
</span> </span>
</div> </div>
<div class="reminders"> <div class="reminders">
@@ -21,14 +21,12 @@ const lecture = computed(() => lecturesStore.all.find(l => l.id === lectureId.va
const isRegistered = computed(() => (lecture.value ? lecturesStore.isRegistered(lecture.value.id) : false)) const isRegistered = computed(() => (lecture.value ? lecturesStore.isRegistered(lecture.value.id) : false))
const slotRegistrationDisabled = computed(() => !userStore.hasEnrollmentSlotAvailable && !isRegistered.value) const slotRegistrationDisabled = computed(() => !userStore.hasEnrollmentSlotAvailable && !isRegistered.value)
const isAttended = computed(() => lecture.value?.status === 'completed') const isAttended = computed(() => lecture.value?.status === 'completed')
const reviews = computed(() => lecturesStore.reviewsByLecture[lectureId.value] ?? [])
const similarLectures = computed(() => lecturesStore.all.filter(l => l.id !== lectureId.value).slice(0, 3)) const similarLectures = computed(() => lecturesStore.all.filter(l => l.id !== lectureId.value).slice(0, 3))
onMounted(async () => { onMounted(async () => {
if (!lecturesStore.all.length) await lecturesStore.fetchLectures() if (!lecturesStore.all.length) await lecturesStore.fetchLectures()
await lecturesStore.fetchLecture(lectureId.value) await lecturesStore.fetchLecture(lectureId.value)
await lecturesStore.fetchReviews(lectureId.value)
}) })
async function registerLecture() { async function registerLecture() {
@@ -109,21 +107,6 @@ async function registerLecture() {
</div> </div>
</div> </div>
</GlassCard> </GlassCard>
<GlassCard>
<div class="section-title">LLM-сводка отзывов</div>
<p class="summary">
Студенты отмечают «понятные примеры» и «много практики». Предлагается добавить больше времени на вопросы и
прикладные кейсы. Средняя оценка 4.8/5.
</p>
<div class="reviews" v-if="reviews.length">
<div v-for="review in reviews" :key="review.id" class="review">
<div class="review-head">{{ review.userName }} {{ review.sentiment }}</div>
<div class="review-body">{{ review.text }}</div>
</div>
</div>
<p v-else class="text-secondary text-sm">Отзывов пока нет.</p>
</GlassCard>
</div> </div>
<section> <section>
@@ -155,11 +138,6 @@ async function registerLecture() {
.info-value { font-weight: 700; } .info-value { font-weight: 700; }
.info-sub { font-size: 13px; color: var(--color-text-secondary); margin-top: 4px; } .info-sub { font-size: 13px; color: var(--color-text-secondary); margin-top: 4px; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; } .tags { display: flex; flex-wrap: wrap; gap: 6px; }
.summary { font-size: 14px; color: var(--color-text-secondary); line-height: 1.5; }
.reviews { margin-top: 12px; display: flex; flex-direction: column; gap: 10px; }
.review { padding: 10px; border-radius: var(--radius-sm); background: rgba(255,255,255,0.6); border: 1px solid var(--color-border-glass); }
.review-head { font-weight: 600; margin-bottom: 4px; }
.review-body { font-size: 13px; color: var(--color-text-secondary); }
.cards-grid { .cards-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+46 -11
View File
@@ -1,17 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import GlassCard from '@/components/ui/GlassCard.vue' import GlassCard from '@/components/ui/GlassCard.vue'
import AppIcon from '@/components/ui/AppIcon.vue' import AppIcon from '@/components/ui/AppIcon.vue'
import { reviewsApi } from '@/api' import { lecturesApi, reviewsApi } from '@/api'
import type { LectureDto } from '@/api/types'
const route = useRoute() const route = useRoute()
const rating = ref<'positive' | 'neutral' | 'negative'>('positive') const rating = ref<'positive' | 'neutral' | 'negative'>('positive')
const text = ref('Лекция была хорошо структурирована, особенно понравились практические примеры и разбор кейсов.') const text = ref('')
const submitted = ref(false) const submitted = ref(false)
const editing = ref(false)
const loading = ref(false) const loading = ref(false)
const lectureLoading = ref(false)
const error = ref('') const error = ref('')
const lecture = ref<LectureDto | null>(null)
const ratingMap = { const ratingMap = {
positive: 'Like', positive: 'Like',
@@ -19,43 +21,77 @@ const ratingMap = {
negative: 'Dislike', negative: 'Dislike',
} as const } as const
const lectureTitle = computed(() => lecture.value?.title || lecture.value?.courseName || 'Отзыв о лекции')
const lectureMeta = computed(() => {
if (!lecture.value) return ''
const startsAt = new Date(lecture.value.startsAt)
const date = Number.isNaN(startsAt.getTime())
? ''
: startsAt.toLocaleString('ru-RU', {
day: '2-digit',
month: 'long',
hour: '2-digit',
minute: '2-digit',
})
const teacher = lecture.value.teacherName ? `Преподаватель: ${lecture.value.teacherName}` : ''
const location = lecture.value.format === 'Online' ? 'Онлайн' : lecture.value.locationName
return [date, teacher, location].filter(Boolean).join(' ')
})
async function fetchLecture() {
lectureLoading.value = true
try {
lecture.value = await lecturesApi.get(String(route.params.id))
} catch {
lecture.value = null
} finally {
lectureLoading.value = false
}
}
async function submit() { async function submit() {
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
await reviewsApi.create(String(route.params.id), ratingMap[rating.value], text.value) await reviewsApi.create(String(route.params.id), ratingMap[rating.value], text.value)
submitted.value = true submitted.value = true
editing.value = false
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Не удалось отправить отзыв.' error.value = err instanceof Error ? err.message : 'Не удалось отправить отзыв.'
} finally { } finally {
loading.value = false loading.value = false
} }
} }
onMounted(() => {
void fetchLecture()
})
</script> </script>
<template> <template>
<div class="review page-content"> <div class="review page-content">
<div class="header"> <div class="header">
<div> <div>
<h1 class="page-title">Отзыв о лекции #{{ route.params.id }}</h1> <h1 class="page-title">{{ lectureLoading ? 'Загрузка лекции...' : lectureTitle }}</h1>
<p class="text-secondary">Ваш отзыв помогает улучшать качество лекций и приносит бонусные монеты.</p> <p class="text-secondary">
{{ lectureMeta || 'Ваш отзыв помогает улучшать качество лекций и приносит бонусные монеты.' }}
</p>
</div> </div>
</div> </div>
<GlassCard> <GlassCard>
<div v-if="submitted && !editing" class="success-state"> <div v-if="submitted" class="success-state">
<AppIcon class="success-icon" icon="circle-check" :size="32" /> <AppIcon class="success-icon" icon="circle-check" :size="32" />
<div class="success-title">Отзыв отправлен и будет обработан</div> <div class="success-title">Отзыв отправлен и будет обработан</div>
<div class="success-sub"> <div class="success-sub">
Спасибо! Оценка полезности отзыва рассчитывается автоматически. Студентам не показывается техническая оценка LLM. Спасибо! Оценка полезности отзыва рассчитывается автоматически. Студентам не показывается техническая оценка LLM.
</div> </div>
<button class="btn-secondary" @click="editing = true">Редактировать отзыв</button>
</div> </div>
<form v-else @submit.prevent="submit" class="form"> <form v-else @submit.prevent="submit" class="form">
<label class="field-label">Ваш отзыв о лекции</label> <label class="field-label">Ваш отзыв о лекции</label>
<textarea v-model="text" rows="6" placeholder="Опишите, что было полезно, а что можно улучшить"></textarea> <textarea v-model="text" rows="6"></textarea>
<label class="field-label">Оценка впечатлений</label> <label class="field-label">Оценка впечатлений</label>
<div class="rating-options"> <div class="rating-options">
@@ -71,7 +107,6 @@ async function submit() {
<div class="form-actions"> <div class="form-actions">
<button class="btn-primary" type="submit" :disabled="loading">{{ loading ? 'Отправляем...' : 'Отправить отзыв' }}</button> <button class="btn-primary" type="submit" :disabled="loading">{{ loading ? 'Отправляем...' : 'Отправить отзыв' }}</button>
<button class="btn-secondary" type="button" :disabled="submitted">Сохранить черновик</button>
</div> </div>
</form> </form>
</GlassCard> </GlassCard>
@@ -82,17 +82,13 @@ watch(() => auth.user?.id, fetchTeacherAnalytics)
</GlassCard> </GlassCard>
<GlassCard> <GlassCard>
<div class="section-title">Анонимные отзывы</div> <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-else class="reviews">
<div v-for="review in reviews" :key="review.id" class="review"> <div v-for="review in reviews" :key="review.id" class="review">
«{{ review.text }}» «{{ review.text }}»
</div> </div>
</div> </div>
<div class="section-title">Топ полезных отзывов</div>
<ul class="top-list">
<li v-for="review in reviews.slice(0, 2)" :key="review.id">«{{ review.text }}»</li>
</ul>
</GlassCard> </GlassCard>
</div> </div>
</template> </template>
@@ -46,17 +46,7 @@ watch(() => auth.user?.id, fetchTeacherLectures)
</div> </div>
<GlassCard> <GlassCard>
<div class="section-title">Заметность за пределами направления</div> <div class="section-title">Ближайшие лекции</div>
<div class="visibility">
<div class="visibility-meta">
{{ visibility }}% студентов из других институтов
</div>
<ProgressBar :value="visibility" :max="100" />
</div>
</GlassCard>
<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 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">