Files
UniVerse/frontend/src/views/student/MyLecturesView.vue
T
serega404 136bcce7db
Backend CI / build-and-test (push) Successful in 57s
Frontend CI / build-and-check (push) Failing after 26s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 11s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 2m33s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 33s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 8s
feat: добавил поддержку подписки на календарь и экспорт расписания лекций в формате .ics
2026-06-02 22:10:15 +03:00

313 lines
9.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, inject, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { usersApi } from '@/api'
import { downloadFile } from '@/utils/downloadFile'
import { useLecturesStore } from '@/stores/lectures'
import { useAuthStore } from '@/stores/auth'
import GlassCard from '@/components/ui/GlassCard.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import ModalDialog from '@/components/ui/ModalDialog.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
const lecturesStore = useLecturesStore()
const auth = useAuthStore()
const router = useRouter()
const activeTab = ref<'upcoming' | 'history'>('upcoming')
const cancelModal = ref(false)
const selectedId = ref<string | null>(null)
const calendarSubscriptionUrl = ref<string | null>(null)
const calendarActionPending = ref(false)
const addToast = inject('addToast') as
| ((message: string, type?: 'success' | 'error' | 'info') => void)
| undefined
const upcoming = computed(() =>
lecturesStore.registeredLectures.map((l) => ({ ...l, status: 'registered' })),
)
const history = computed(() => lecturesStore.all.filter((l) => l.status === 'completed'))
onMounted(async () => {
if (!lecturesStore.all.length) await lecturesStore.fetchLectures()
if (auth.user) await lecturesStore.fetchRegisteredForCurrentUser()
})
async function downloadLectureIcs(id: string) {
try {
const blob = await usersApi.downloadEnrollmentIcs(id)
downloadFile(blob, `lecture-${id}.ics`)
} catch (err) {
addToast?.(err instanceof Error ? err.message : 'Не удалось скачать .ics', 'error')
}
}
async function downloadAllIcs() {
try {
const blob = await usersApi.downloadMyEnrollmentsIcs()
downloadFile(blob, 'my-lectures.ics')
} catch (err) {
addToast?.(err instanceof Error ? err.message : 'Не удалось скачать .ics', 'error')
}
}
async function getCalendarSubscriptionUrl() {
if (calendarSubscriptionUrl.value) return calendarSubscriptionUrl.value
const subscription = await usersApi.getCalendarSubscription()
calendarSubscriptionUrl.value = subscription.feedUrl
return subscription.feedUrl
}
async function copyText(value: string) {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value)
return true
} catch {
// Browser may block async clipboard writes after awaiting the subscription request.
}
}
const textarea = document.createElement('textarea')
textarea.value = value
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
const copied = document.execCommand('copy')
textarea.remove()
return copied
}
async function copyCalendarSubscriptionUrl() {
try {
calendarActionPending.value = true
const feedUrl = await getCalendarSubscriptionUrl()
if (!await copyText(feedUrl)) throw new Error('Браузер заблокировал копирование ссылки')
addToast?.('ICS-ссылка скопирована', 'success')
} catch (err) {
addToast?.(err instanceof Error ? err.message : 'Не удалось скопировать ссылку', 'error')
} finally {
calendarActionPending.value = false
}
}
async function syncWithGoogleCalendar() {
const googleWindow = window.open('about:blank', '_blank')
try {
calendarActionPending.value = true
const feedUrl = await getCalendarSubscriptionUrl()
const copied = await copyText(feedUrl)
const googleUrl = `https://calendar.google.com/calendar/r?cid=${encodeURIComponent(feedUrl)}`
if (googleWindow) {
googleWindow.opener = null
googleWindow.location.href = googleUrl
} else {
window.open(googleUrl, '_blank', 'noopener,noreferrer')
}
addToast?.(
copied
? 'ICS-ссылка скопирована. Google Calendar открыт в новой вкладке.'
: 'Google Calendar открыт. Если ссылка не подставилась, скопируйте ICS-ссылку отдельно.',
copied ? 'success' : 'info',
)
} catch (err) {
googleWindow?.close()
addToast?.(
err instanceof Error ? err.message : 'Не удалось подготовить ссылку для Google Calendar',
'error',
)
} finally {
calendarActionPending.value = false
}
}
function openCancel(id: string) {
selectedId.value = id
cancelModal.value = true
}
async function confirmCancel() {
if (selectedId.value) await lecturesStore.unregister(selectedId.value)
cancelModal.value = false
}
</script>
<template>
<div class="my-lectures page-content">
<div class="header">
<div>
<h1 class="page-title">Мои записи</h1>
<p class="text-secondary">
Управляйте регистрациями, экспортируйте расписание и оставляйте отзывы.
</p>
</div>
<div class="calendar-actions">
<button
class="btn-primary"
:disabled="calendarActionPending"
@click="syncWithGoogleCalendar"
>
Синхронизировать с Google Calendar
</button>
<button
class="btn-secondary"
:disabled="calendarActionPending"
@click="copyCalendarSubscriptionUrl"
>
Скопировать ICS-ссылку
</button>
<button class="btn-secondary" @click="downloadAllIcs">
Скачать все мои лекции (.ics)
</button>
</div>
</div>
<div class="tabs">
<button :class="{ active: activeTab === 'upcoming' }" @click="activeTab = 'upcoming'">
Предстоящие
</button>
<button :class="{ active: activeTab === 'history' }" @click="activeTab = 'history'">
История
</button>
</div>
<div v-if="activeTab === 'upcoming'" class="list">
<EmptyState
v-if="!upcoming.length"
title="У вас нет предстоящих лекций"
subtitle="Выберите лекцию в каталоге и запишитесь на неё."
/>
<GlassCard v-for="item in upcoming" :key="item.id" class="lecture-row">
<div>
<div class="lecture-title">{{ item.title }}</div>
<div class="lecture-meta">
{{ new Date(item.date).toLocaleDateString('ru-RU') }} {{ item.time }}
</div>
<div class="lecture-meta">
{{ item.building }} {{ item.room ? `ауд. ${item.room}` : '' }}
</div>
</div>
<div class="lecture-actions">
<StatusBadge status="registered" />
<button class="btn-secondary btn-sm" @click="downloadLectureIcs(item.id)">Скачать .ics</button>
<button class="btn-danger btn-sm" @click="openCancel(item.id)">Отменить</button>
</div>
</GlassCard>
</div>
<div v-else class="list">
<EmptyState
v-if="!history.length"
title="История пока пуста"
subtitle="Завершённые лекции появятся здесь после посещения."
/>
<GlassCard v-for="item in history" :key="item.id" class="lecture-row">
<div>
<div class="lecture-title">{{ item.title }}</div>
<div class="lecture-meta">
{{ new Date(item.date).toLocaleDateString('ru-RU') }} {{ item.time }}
</div>
<div class="lecture-meta">
{{ item.building }} {{ item.room ? `ауд. ${item.room}` : '' }}
</div>
</div>
<div class="lecture-actions">
<StatusBadge :status="item.status ?? 'completed'" />
<button class="btn-primary btn-sm" @click="router.push(`/review/${item.id}`)">
Оставить отзыв
</button>
</div>
</GlassCard>
</div>
<ModalDialog v-model="cancelModal" title="Отменить запись?" icon="alert-triangle" size="sm">
<p>
Вы уверены, что хотите отменить запись на лекцию? Место будет освобождено для других
студентов.
</p>
<template #footer>
<button class="btn-secondary" type="button" @click="cancelModal = false">Нет</button>
<button class="btn-danger" type="button" @click="confirmCancel">Да, отменить</button>
</template>
</ModalDialog>
</div>
</template>
<style scoped>
.my-lectures {
display: flex;
flex-direction: column;
gap: 18px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.calendar-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.tabs {
display: inline-flex;
width: fit-content;
border: 1px solid var(--color-border-glass);
border-radius: 12px;
overflow: hidden;
}
.tabs button {
background: rgba(255, 255, 255, 0.7);
border: none;
padding: 8px 18px;
font-size: 13px;
min-width: 110px;
text-align: center;
cursor: pointer;
color: var(--color-text-secondary);
}
.tabs button.active {
background: rgba(34, 197, 94, 0.18);
color: var(--color-primary-dark);
font-weight: 600;
}
.list {
display: flex;
flex-direction: column;
gap: 12px;
}
.lecture-row {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.lecture-title {
font-weight: 700;
margin-bottom: 4px;
}
.lecture-meta {
font-size: 13px;
color: var(--color-text-secondary);
}
.lecture-actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
</style>