feat: подготовил дизайн (изменения из другого репозитория)
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 5s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 8s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 3s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 5s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 8s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 3s
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="about">
|
||||
<h1>This is an about page</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@media (min-width: 1024px) {
|
||||
.about {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import TheWelcome from '../components/TheWelcome.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<TheWelcome />
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||
import StatsWidget from '@/components/ui/StatsWidget.vue'
|
||||
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
|
||||
const disciplines = [
|
||||
{ name: 'Информатика и ИИ', value: 80 },
|
||||
{ name: 'Экономика и маркетинг', value: 55 },
|
||||
{ name: 'Философия и этика', value: 42 },
|
||||
{ name: 'Право и политика', value: 36 },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-dashboard page-content">
|
||||
<h1 class="page-title">Дашборд администратора</h1>
|
||||
|
||||
<div class="stats-row">
|
||||
<StatsWidget label="Пользователей" :value="1247" icon="👥" color="green" />
|
||||
<StatsWidget label="Лекций" :value="89" icon="📚" color="aqua" />
|
||||
<StatsWidget label="Записей" :value="3421" icon="🗓️" color="orange" />
|
||||
<StatsWidget label="Отзывов" :value="1089" icon="💬" color="purple" />
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<GlassCard>
|
||||
<div class="section-title">Популярные дисциплины</div>
|
||||
<div class="bars">
|
||||
<div class="bar-row" v-for="d in disciplines" :key="d.name">
|
||||
<span>{{ d.name }}</span>
|
||||
<div class="bar">
|
||||
<div class="bar-fill" :style="{ width: `${d.value}%` }"></div>
|
||||
</div>
|
||||
<span class="percent">{{ d.value }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard>
|
||||
<div class="section-title">Межфакультетская вовлеченность</div>
|
||||
<div class="metric">46% студентов посещают лекции вне своего института</div>
|
||||
<ProgressBar :value="46" :max="100" />
|
||||
<div class="section-title">Активность студентов</div>
|
||||
<div class="activity">
|
||||
<div class="day" v-for="n in 7" :key="n">
|
||||
<div class="day-bar" :style="{ height: `${40 + n * 6}px` }"></div>
|
||||
<span>Д{{ n }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<GlassCard>
|
||||
<div class="section-title">Состояние синхронизации расписания</div>
|
||||
<StatusBadge status="open" />
|
||||
<div class="sync-meta">Последняя синхронизация: сегодня, 09:15</div>
|
||||
<div class="sync-error">Ошибка: 2 аудитории не сопоставлены с корпусами</div>
|
||||
</GlassCard>
|
||||
<GlassCard>
|
||||
<div class="section-title">Очередь LLM-анализа</div>
|
||||
<div class="queue-meta">В очереди: 24 отзыва · Обработка: 6/час</div>
|
||||
<ProgressBar :value="60" :max="100" />
|
||||
<div class="queue-status">Следующая проверка через 12 минут</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-dashboard { display: flex; flex-direction: column; gap: 18px; }
|
||||
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
|
||||
.bars { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
|
||||
.bar-row { display: grid; grid-template-columns: 1fr 2fr auto; align-items: center; gap: 8px; font-size: 13px; }
|
||||
.bar { background: rgba(0,0,0,0.08); border-radius: 6px; height: 8px; overflow: hidden; }
|
||||
.bar-fill { background: linear-gradient(90deg, #22C55E, #86EFAC); height: 100%; }
|
||||
.percent { color: var(--color-text-secondary); font-size: 12px; }
|
||||
.metric { margin-bottom: 10px; color: var(--color-text-secondary); }
|
||||
.activity { display: flex; gap: 10px; margin-top: 12px; align-items: flex-end; }
|
||||
.day { display: flex; flex-direction: column; align-items: center; gap: 4px; font-size: 11px; color: var(--color-text-secondary); }
|
||||
.day-bar { width: 16px; background: linear-gradient(180deg, #7DD3FC, #BAE6FD); border-radius: 6px 6px 0 0; }
|
||||
.sync-meta { font-size: 12px; color: var(--color-text-secondary); margin-top: 6px; }
|
||||
.sync-error { font-size: 12px; color: var(--color-error); margin-top: 8px; }
|
||||
.queue-meta { font-size: 12px; color: var(--color-text-secondary); margin-bottom: 8px; }
|
||||
.queue-status { font-size: 12px; color: var(--color-text-secondary); margin-top: 6px; }
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||
import DataTable from '@/components/ui/DataTable.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
|
||||
const columns = [
|
||||
{ key: 'id', label: 'ID' },
|
||||
{ key: 'lecture', label: 'Лекция' },
|
||||
{ key: 'student', label: 'Студент' },
|
||||
{ key: 'date', label: 'Дата' },
|
||||
{ key: 'status', label: 'Статус', align: 'center' },
|
||||
{ key: 'sentiment', label: 'Sentiment', align: 'center' },
|
||||
{ key: 'quality', label: 'Качество', align: 'center' },
|
||||
{ key: 'coins', label: 'Монеты', align: 'center' },
|
||||
{ key: 'actions', label: 'Действия', align: 'right' },
|
||||
]
|
||||
|
||||
const rows = [
|
||||
{ id: 'RV-1024', lecture: 'Нейронные сети', student: 'А. Морозов', date: '06.05', status: 'pending', sentiment: 'Позитивный', quality: 0.82, coins: 20 },
|
||||
{ id: 'RV-1025', lecture: 'Квантовые вычисления', student: 'Н. Иванова', date: '05.05', status: 'active', sentiment: 'Нейтральный', quality: 0.63, coins: 10 },
|
||||
{ id: 'RV-1026', lecture: 'Право в информационном обществе', student: 'Д. Комаров', date: '04.05', status: 'done', sentiment: 'Негативный', quality: 0.41, coins: 0 },
|
||||
{ id: 'RV-1027', lecture: 'Философия цифровой эпохи', student: 'С. Орлова', date: '03.05', status: 'rejected', sentiment: 'Нейтральный', quality: 0.22, coins: 0 },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-llm page-content">
|
||||
<div class="header">
|
||||
<h1 class="page-title">Очередь LLM-анализа отзывов</h1>
|
||||
<button class="btn-primary">Запустить повторный анализ</button>
|
||||
</div>
|
||||
|
||||
<GlassCard>
|
||||
<DataTable :columns="columns" :rows="rows">
|
||||
<template #status="{ value }">
|
||||
<StatusBadge :status="value" />
|
||||
</template>
|
||||
<template #quality="{ value }">
|
||||
<span :class="value >= 0.7 ? 'badge badge-green' : value >= 0.4 ? 'badge badge-orange' : 'badge badge-red'">
|
||||
{{ value }}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<button class="btn-ghost">Повторить</button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-llm { display: flex; flex-direction: column; gap: 16px; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||
import DataTable from '@/components/ui/DataTable.vue'
|
||||
|
||||
type TabKey = 'lectures' | 'courses' | 'rooms' | 'tags'
|
||||
type TabConfig = {
|
||||
title: string
|
||||
columns: Array<{ key: string; label: string; align?: string }>
|
||||
rows: Record<string, any>[]
|
||||
}
|
||||
|
||||
const activeTab = ref<TabKey>('lectures')
|
||||
|
||||
const tabConfig: Record<TabKey, TabConfig> = {
|
||||
lectures: {
|
||||
title: 'Лекции',
|
||||
columns: [
|
||||
{ key: 'title', label: 'Название' },
|
||||
{ key: 'teacher', label: 'Преподаватель' },
|
||||
{ key: 'format', label: 'Формат' },
|
||||
{ key: 'status', label: 'Синхронизация', align: 'center' },
|
||||
],
|
||||
rows: [
|
||||
{ id: '1', title: 'Введение в нейронные сети', teacher: 'Волков М.С.', format: 'Офлайн', status: 'Синхронизировано' },
|
||||
{ id: '2', title: 'Квантовые вычисления', teacher: 'Петров А.И.', format: 'Офлайн', status: 'Синхронизировано' },
|
||||
{ id: '3', title: 'Философия цифровой эпохи', teacher: 'Дмитриев К.О.', format: 'Онлайн', status: 'Ошибка' },
|
||||
],
|
||||
},
|
||||
courses: {
|
||||
title: 'Курсы',
|
||||
columns: [
|
||||
{ key: 'title', label: 'Курс' },
|
||||
{ key: 'institute', label: 'Институт' },
|
||||
{ key: 'tags', label: 'Теги' },
|
||||
],
|
||||
rows: [
|
||||
{ id: '1', title: 'Машинное обучение', institute: 'ИКТИБ', tags: '#ML #ИИ #Python' },
|
||||
{ id: '2', title: 'Цифровая этика', institute: 'ИФиСН', tags: '#философия #этика' },
|
||||
],
|
||||
},
|
||||
rooms: {
|
||||
title: 'Аудитории',
|
||||
columns: [
|
||||
{ key: 'building', label: 'Корпус' },
|
||||
{ key: 'room', label: 'Аудитория' },
|
||||
{ key: 'capacity', label: 'Вместимость', align: 'center' },
|
||||
],
|
||||
rows: [
|
||||
{ id: '1', building: 'ИКТИБ', room: '305', capacity: 30 },
|
||||
{ id: '2', building: 'ИФиМКН', room: '201', capacity: 25 },
|
||||
],
|
||||
},
|
||||
tags: {
|
||||
title: 'Теги',
|
||||
columns: [
|
||||
{ key: 'tag', label: 'Тег' },
|
||||
{ key: 'category', label: 'Категория' },
|
||||
{ key: 'linked', label: 'Привязки', align: 'center' },
|
||||
],
|
||||
rows: [
|
||||
{ id: '1', tag: '#ML', category: 'Data Science', linked: 12 },
|
||||
{ id: '2', tag: '#философия', category: 'Гуманитарные', linked: 6 },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const current = computed(() => tabConfig[activeTab.value])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-lectures page-content">
|
||||
<div class="header">
|
||||
<h1 class="page-title">Управление лекциями и справочниками</h1>
|
||||
<button class="btn-primary">Создать запись</button>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button :class="{ active: activeTab === 'lectures' }" @click="activeTab = 'lectures'">Лекции</button>
|
||||
<button :class="{ active: activeTab === 'courses' }" @click="activeTab = 'courses'">Курсы</button>
|
||||
<button :class="{ active: activeTab === 'rooms' }" @click="activeTab = 'rooms'">Аудитории</button>
|
||||
<button :class="{ active: activeTab === 'tags' }" @click="activeTab = 'tags'">Теги</button>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<GlassCard>
|
||||
<div class="section-title">{{ current.title }}</div>
|
||||
<DataTable :columns="current.columns" :rows="current.rows" />
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard>
|
||||
<div class="section-title">Создать / редактировать</div>
|
||||
<form class="form">
|
||||
<label>Название</label>
|
||||
<input class="glass-input" placeholder="Введите название" />
|
||||
<label>Описание</label>
|
||||
<textarea rows="4" placeholder="Описание записи"></textarea>
|
||||
<label>Статус синхронизации</label>
|
||||
<select class="glass-input">
|
||||
<option>Синхронизировано</option>
|
||||
<option>Ожидает</option>
|
||||
<option>Ошибка</option>
|
||||
</select>
|
||||
<div class="form-actions">
|
||||
<button class="btn-primary" type="button">Сохранить</button>
|
||||
<button class="btn-secondary" type="button">Отменить</button>
|
||||
</div>
|
||||
</form>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-lectures { display: flex; flex-direction: column; gap: 16px; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.tabs { display: inline-flex; 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; 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; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
|
||||
.form { display: flex; flex-direction: column; gap: 10px; }
|
||||
textarea { padding: 10px; border-radius: var(--radius-sm); border: 1px solid var(--color-border-glass); background: rgba(255,255,255,0.8); }
|
||||
.form-actions { display: flex; gap: 10px; }
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||
import DataTable from '@/components/ui/DataTable.vue'
|
||||
|
||||
const search = ref('')
|
||||
const roleFilter = ref('Все роли')
|
||||
const instituteFilter = ref('Все институты')
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Имя' },
|
||||
{ key: 'email', label: 'Email' },
|
||||
{ key: 'role', label: 'Роль', align: 'center' },
|
||||
{ key: 'institute', label: 'Институт' },
|
||||
{ key: 'activity', label: 'Активность', align: 'center' },
|
||||
{ key: 'created', label: 'Дата регистрации' },
|
||||
{ key: 'actions', label: 'Действия', align: 'right' },
|
||||
]
|
||||
|
||||
const rows = [
|
||||
{ id: '1', name: 'Алексей Морозов', email: 'a.morozov@sfedu.ru', role: 'Студент', institute: 'ИКТИБ', activity: 'Высокая', created: '12.03.2024' },
|
||||
{ id: '2', name: 'Елена Смирнова', email: 'e.smirnova@sfedu.ru', role: 'Преподаватель', institute: 'АГиС', activity: 'Средняя', created: '05.02.2023' },
|
||||
{ id: '3', name: 'Виктор Алексеев', email: 'admin@sfedu.ru', role: 'Администратор', institute: 'ЮФУ', activity: 'Высокая', created: '01.09.2022' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-users page-content">
|
||||
<div class="header">
|
||||
<h1 class="page-title">Пользователи</h1>
|
||||
<button class="btn-primary">Добавить пользователя</button>
|
||||
</div>
|
||||
|
||||
<GlassCard>
|
||||
<div class="filters">
|
||||
<input v-model="search" class="glass-input" placeholder="Поиск по имени или email" />
|
||||
<select v-model="roleFilter" class="glass-input">
|
||||
<option>Все роли</option>
|
||||
<option>Студент</option>
|
||||
<option>Преподаватель</option>
|
||||
<option>Администратор</option>
|
||||
</select>
|
||||
<select v-model="instituteFilter" class="glass-input">
|
||||
<option>Все институты</option>
|
||||
<option>ИКТИБ</option>
|
||||
<option>ИФиМКН</option>
|
||||
<option>АГиС</option>
|
||||
<option>ЮФ</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<DataTable :columns="columns" :rows="rows">
|
||||
<template #role="{ value }">
|
||||
<span :class="value === 'Студент' ? 'badge badge-green' : value === 'Преподаватель' ? 'badge badge-blue' : 'badge badge-purple'">{{ value }}</span>
|
||||
</template>
|
||||
<template #activity="{ value }">
|
||||
<span class="badge" :class="value === 'Высокая' ? 'badge-green' : 'badge-orange'">{{ value }}</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="actions">
|
||||
<button class="btn-ghost">Назначить роль</button>
|
||||
<button class="btn-ghost">Заблокировать</button>
|
||||
<button class="btn-ghost">Профиль</button>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-users { display: flex; flex-direction: column; gap: 16px; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 12px; }
|
||||
.actions { display: flex; gap: 6px; justify-content: flex-end; flex-wrap: wrap; }
|
||||
</style>
|
||||
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { UserRole } from '@/types'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const selectedRole = ref<UserRole>('student')
|
||||
const simulateError = ref(false)
|
||||
|
||||
const roleOptions: Array<{ label: string; role: UserRole }> = [
|
||||
{ label: '🎓 Студент', role: 'student' },
|
||||
{ label: '👩🏫 Преподаватель', role: 'teacher' },
|
||||
{ label: '🛡️ Администратор', role: 'admin' },
|
||||
]
|
||||
|
||||
async function login() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
const ok = await auth.login(selectedRole.value, simulateError.value)
|
||||
loading.value = false
|
||||
if (ok) {
|
||||
if (selectedRole.value === 'teacher') router.push('/teacher')
|
||||
else if (selectedRole.value === 'admin') router.push('/admin')
|
||||
else router.push('/')
|
||||
} else {
|
||||
error.value = auth.error ?? 'Ошибка авторизации. Проверьте доступ и попробуйте снова.'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-bg">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="logo-mark">🌍</div>
|
||||
<h1 class="brand">UniVerse</h1>
|
||||
<p class="brand-sub">«Откройте для себя вселенную знаний»</p>
|
||||
</div>
|
||||
|
||||
<div class="login-desc">
|
||||
UniVerse — единая платформа ЮФУ для поиска, записи и участия в открытых межнаправленческих лекциях.
|
||||
Получайте рекомендации, оставляйте отзывы и зарабатывайте монеты за полезную обратную связь.
|
||||
</div>
|
||||
|
||||
<div class="role-select">
|
||||
<p class="demo-label">Роль для демонстрации:</p>
|
||||
<div class="role-options">
|
||||
<button
|
||||
v-for="opt in roleOptions"
|
||||
:key="opt.role"
|
||||
class="role-option"
|
||||
:class="{ active: selectedRole === opt.role }"
|
||||
@click="selectedRole = opt.role"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="login-actions">
|
||||
<button class="btn-primary btn-full" type="button" :disabled="loading" @click="login">
|
||||
<span v-if="loading" class="spinner-inline">
|
||||
<span class="spinner"></span>
|
||||
</span>
|
||||
{{ loading ? 'Вход...' : 'Войти через ЮФУ (Microsoft Entra ID)' }}
|
||||
</button>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" v-model="simulateError" />
|
||||
Показать ошибку авторизации
|
||||
</label>
|
||||
<div class="error" v-if="error">⚠️ {{ error }}</div>
|
||||
</div>
|
||||
|
||||
<div class="login-footer">
|
||||
Вход осуществляется через корпоративный аккаунт ЮФУ. При первом входе требуется подтверждение доступа.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-bg {
|
||||
min-height: 100vh;
|
||||
background: var(--gradient-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
background: rgba(255,255,255,0.82);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--color-border-glass);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 32px 80px rgba(0,0,0,0.12);
|
||||
padding: 40px 36px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.login-header { text-align: center; }
|
||||
.logo-mark { font-size: 52px; }
|
||||
.brand {
|
||||
font-size: 34px;
|
||||
font-weight: 900;
|
||||
background: var(--gradient-brand);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin: 0;
|
||||
}
|
||||
.brand-sub { font-size: 14px; color: var(--color-text-secondary); margin: 6px 0 0; }
|
||||
.login-desc {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
background: rgba(255,255,255,0.6);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--color-border-glass);
|
||||
}
|
||||
.demo-label {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.role-options { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.role-option {
|
||||
background: rgba(255,255,255,0.6);
|
||||
border: 1px solid var(--color-border-glass);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.role-option.active {
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(34,197,94,0.12);
|
||||
color: var(--color-primary-dark);
|
||||
}
|
||||
.login-actions { display: flex; flex-direction: column; gap: 10px; }
|
||||
.btn-full { width: 100%; justify-content: center; }
|
||||
.toggle {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.error {
|
||||
font-size: 13px;
|
||||
color: var(--color-error);
|
||||
background: rgba(239,68,68,0.1);
|
||||
border: 1px solid rgba(239,68,68,0.3);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.spinner-inline { display: inline-flex; margin-right: 6px; }
|
||||
.spinner-inline .spinner { width: 16px; height: 16px; border-width: 2px; }
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||
import AchievementBadge from '@/components/ui/AchievementBadge.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const unlocked = computed(() => userStore.achievements.filter(a => a.unlocked))
|
||||
const locked = computed(() => userStore.achievements.filter(a => !a.unlocked))
|
||||
|
||||
const rewards = [
|
||||
{ id: 'r1', title: 'Стикерпак UniVerse', price: 80, available: true },
|
||||
{ id: 'r2', title: 'Термокружка ЮФУ', price: 150, available: true },
|
||||
{ id: 'r3', title: 'Доп. консультация преподавателя', price: 220, available: false },
|
||||
{ id: 'r4', title: 'Цифровой бейдж «Research Explorer»', price: 60, available: true },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="achievements page-content">
|
||||
<h1 class="page-title">Достижения и магазин наград</h1>
|
||||
|
||||
<section>
|
||||
<h2 class="section-title">Полученные достижения</h2>
|
||||
<div class="list">
|
||||
<AchievementBadge
|
||||
v-for="a in unlocked"
|
||||
:key="a.id"
|
||||
:icon="a.icon"
|
||||
:title="a.title"
|
||||
:description="a.description"
|
||||
:unlocked="a.unlocked"
|
||||
:unlockedAt="a.unlockedAt"
|
||||
:coins="a.coins"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="section-title">Заблокированные</h2>
|
||||
<div class="list">
|
||||
<AchievementBadge
|
||||
v-for="a in locked"
|
||||
:key="a.id"
|
||||
:icon="a.icon"
|
||||
:title="a.title"
|
||||
:description="a.description"
|
||||
:unlocked="a.unlocked"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="section-title">Магазин наград</h2>
|
||||
<div class="rewards">
|
||||
<GlassCard v-for="r in rewards" :key="r.id" class="reward-card">
|
||||
<div class="reward-title">{{ r.title }}</div>
|
||||
<div class="reward-price">{{ r.price }} монет</div>
|
||||
<button class="btn-primary" :disabled="!r.available">
|
||||
{{ r.available ? 'Купить' : 'Недоступно' }}
|
||||
</button>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.achievements { display: flex; flex-direction: column; gap: 20px; }
|
||||
.list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.rewards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; }
|
||||
.reward-card { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
|
||||
.reward-title { font-weight: 700; }
|
||||
.reward-price { color: var(--color-text-secondary); font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,345 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { useLecturesStore } from '@/stores/lectures'
|
||||
import SearchInput from '@/components/ui/SearchInput.vue'
|
||||
import LectureCard from '@/components/ui/LectureCard.vue'
|
||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||
import FilterChips from '@/components/ui/FilterChips.vue'
|
||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||
import DataTable from '@/components/ui/DataTable.vue'
|
||||
import ModalDialog from '@/components/ui/ModalDialog.vue'
|
||||
|
||||
const lecturesStore = useLecturesStore()
|
||||
const search = ref('')
|
||||
const viewMode = ref<'cards' | 'list' | 'calendar'>('cards')
|
||||
const dateFilter = ref('Любая дата')
|
||||
const direction = ref('Все направления')
|
||||
const teacher = ref('Все преподаватели')
|
||||
const building = ref('Все корпуса')
|
||||
const format = ref<'all' | 'online' | 'offline'>('all')
|
||||
const onlyFree = ref(false)
|
||||
const filtersOpen = ref(false)
|
||||
const addToast = inject('addToast') as ((message: string, type?: 'success' | 'error' | 'info') => void) | undefined
|
||||
|
||||
const tagFilters = ref([
|
||||
{ label: '#ML', value: '#ML', active: false },
|
||||
{ label: '#ИИ', value: '#ИИ', active: false },
|
||||
{ label: '#Python', value: '#Python', active: false },
|
||||
{ label: '#квантовые-вычисления', value: '#квантовые-вычисления', active: false },
|
||||
{ label: '#биоинформатика', value: '#биоинформатика', active: false },
|
||||
{ label: '#философия', value: '#философия', active: false },
|
||||
{ label: '#право', value: '#право', active: false },
|
||||
{ label: '#маркетинг', value: '#маркетинг', active: false },
|
||||
])
|
||||
|
||||
const directions = [
|
||||
'Все направления',
|
||||
'Информатика и вычислительная техника',
|
||||
'Физика',
|
||||
'Биология',
|
||||
'Философия',
|
||||
'Право',
|
||||
'Экономика и маркетинг',
|
||||
]
|
||||
|
||||
const teachers = computed(() => ['Все преподаватели', ...new Set(lecturesStore.all.map(l => l.teacher))])
|
||||
const buildings = computed(() => ['Все корпуса', ...new Set(lecturesStore.all.map(l => l.building))])
|
||||
|
||||
function toggleTag(value: string) {
|
||||
const target = tagFilters.value.find(t => t.value === value)
|
||||
if (target) target.active = !target.active
|
||||
}
|
||||
|
||||
const activeTags = computed(() => tagFilters.value.filter(t => t.active).map(t => t.value))
|
||||
|
||||
const filtered = computed(() =>
|
||||
lecturesStore.all.filter(l => {
|
||||
const matchesSearch = l.title.toLowerCase().includes(search.value.toLowerCase())
|
||||
const directionKey = direction.value.split(' ')[0] || ''
|
||||
const matchesDirection = direction.value === 'Все направления' || l.institute.includes(directionKey)
|
||||
const matchesTeacher = teacher.value === 'Все преподаватели' || l.teacher === teacher.value
|
||||
const matchesBuilding = building.value === 'Все корпуса' || l.building === building.value
|
||||
const matchesFormat = format.value === 'all' || l.format === format.value
|
||||
const matchesTags = activeTags.value.length === 0 || activeTags.value.some(tag => l.tags.includes(tag))
|
||||
const matchesFree = !onlyFree.value || l.freeSeats > 0
|
||||
return matchesSearch && matchesDirection && matchesTeacher && matchesBuilding && matchesFormat && matchesTags && matchesFree
|
||||
})
|
||||
)
|
||||
|
||||
const appliedFilters = computed(() => {
|
||||
const filters: string[] = []
|
||||
if (dateFilter.value !== 'Любая дата') filters.push(dateFilter.value)
|
||||
if (direction.value !== 'Все направления') filters.push(direction.value)
|
||||
if (teacher.value !== 'Все преподаватели') filters.push(teacher.value)
|
||||
if (building.value !== 'Все корпуса') filters.push(building.value)
|
||||
if (format.value !== 'all') filters.push(format.value === 'online' ? 'Онлайн' : 'Офлайн')
|
||||
if (onlyFree.value) filters.push('Есть места')
|
||||
filters.push(...activeTags.value)
|
||||
return filters
|
||||
})
|
||||
|
||||
const tableColumns = [
|
||||
{ key: 'title', label: 'Лекция' },
|
||||
{ key: 'teacher', label: 'Преподаватель' },
|
||||
{ key: 'date', label: 'Дата' },
|
||||
{ key: 'place', label: 'Локация' },
|
||||
{ key: 'seats', label: 'Места', align: 'center' },
|
||||
{ key: 'action', label: 'Действия', align: 'right' },
|
||||
]
|
||||
|
||||
const calendarGroups = computed(() => {
|
||||
const groups: Record<string, typeof filtered.value> = {}
|
||||
filtered.value.forEach(l => {
|
||||
const date = new Date(l.date).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })
|
||||
groups[date] = groups[date] || []
|
||||
groups[date].push(l)
|
||||
})
|
||||
return Object.entries(groups)
|
||||
})
|
||||
|
||||
function registerLecture(id: string) {
|
||||
lecturesStore.register(id)
|
||||
addToast?.('Вы записаны на лекцию. Напоминание придет за сутки.', 'success')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="catalog page-content">
|
||||
<div class="catalog-header">
|
||||
<div>
|
||||
<h1 class="page-title">Каталог открытых лекций</h1>
|
||||
<p class="text-secondary">Выберите лекцию, фильтруйте по направлениям и регистрируйтесь в один клик.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<SearchInput v-model="search" placeholder="Поиск по теме лекции" />
|
||||
<button class="btn-secondary filters-btn" @click="filtersOpen = true">Фильтры</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GlassCard>
|
||||
<div class="filters-grid">
|
||||
<div>
|
||||
<label class="filter-label">Дата</label>
|
||||
<select v-model="dateFilter" class="glass-input">
|
||||
<option>Любая дата</option>
|
||||
<option>Сегодня</option>
|
||||
<option>Завтра</option>
|
||||
<option>На этой неделе</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="filter-label">Направление</label>
|
||||
<select v-model="direction" class="glass-input">
|
||||
<option v-for="d in directions" :key="d">{{ d }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="filter-label">Преподаватель</label>
|
||||
<select v-model="teacher" class="glass-input">
|
||||
<option v-for="t in teachers" :key="t">{{ t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="filter-label">Корпус</label>
|
||||
<select v-model="building" class="glass-input">
|
||||
<option v-for="b in buildings" :key="b">{{ b }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="filter-label">Формат</label>
|
||||
<div class="segmented">
|
||||
<button :class="{ active: format === 'all' }" @click="format = 'all'">Все</button>
|
||||
<button :class="{ active: format === 'offline' }" @click="format = 'offline'">Офлайн</button>
|
||||
<button :class="{ active: format === 'online' }" @click="format = 'online'">Онлайн</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="filter-label">Теги</label>
|
||||
<FilterChips :filters="tagFilters" @toggle="toggleTag" />
|
||||
</div>
|
||||
<div class="free-toggle">
|
||||
<label class="filter-label">Наличие мест</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="onlyFree" />
|
||||
<span>Только свободные</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<div class="view-row">
|
||||
<div class="segmented">
|
||||
<button :class="{ active: viewMode === 'cards' }" @click="viewMode = 'cards'">Карточки</button>
|
||||
<button :class="{ active: viewMode === 'list' }" @click="viewMode = 'list'">Список</button>
|
||||
<button :class="{ active: viewMode === 'calendar' }" @click="viewMode = 'calendar'">Календарь</button>
|
||||
</div>
|
||||
<div class="applied" v-if="appliedFilters.length">
|
||||
<span class="text-secondary">Фильтры:</span>
|
||||
<span v-for="f in appliedFilters" :key="f" class="tag-chip active">{{ f }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filtered.length === 0">
|
||||
<EmptyState title="Нет результатов" subtitle="Попробуйте изменить фильтры или сбросить поиск." />
|
||||
</div>
|
||||
|
||||
<div v-else-if="viewMode === 'cards'" class="cards-grid">
|
||||
<LectureCard
|
||||
v-for="l in filtered"
|
||||
:key="l.id"
|
||||
:lecture="l"
|
||||
:registered="lecturesStore.registeredIds.includes(l.id)"
|
||||
@register="registerLecture"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="viewMode === 'list'" class="list-view">
|
||||
<GlassCard>
|
||||
<DataTable :columns="tableColumns" :rows="filtered">
|
||||
<template #title="{ row }">
|
||||
<div class="list-title">{{ row.title }}</div>
|
||||
<div class="text-secondary text-sm">{{ row.tags.join(' ') }}</div>
|
||||
</template>
|
||||
<template #date="{ row }">
|
||||
{{ new Date(row.date).toLocaleDateString('ru-RU') }} · {{ row.time }}
|
||||
</template>
|
||||
<template #place="{ row }">
|
||||
{{ row.building }} {{ row.room ? `· ауд. ${row.room}` : '' }}
|
||||
</template>
|
||||
<template #seats="{ row }">
|
||||
<span :class="row.freeSeats === 0 ? 'badge badge-gray' : 'badge badge-green'">
|
||||
{{ row.registrationClosed ? 'Запись закрыта' : `${row.freeSeats}/${row.totalSeats}` }}
|
||||
</span>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<button class="btn-primary btn-sm" :disabled="row.freeSeats === 0 || row.registrationClosed">Записаться</button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
<div v-else class="calendar-view">
|
||||
<GlassCard v-for="([date, items]) in calendarGroups" :key="date" class="calendar-day">
|
||||
<div class="calendar-date">{{ date }}</div>
|
||||
<div class="calendar-items">
|
||||
<div v-for="l in items" :key="l.id" class="calendar-item">
|
||||
<div class="calendar-title">{{ l.title }}</div>
|
||||
<div class="calendar-meta">{{ l.time }} · {{ l.building }} {{ l.room ? `· ауд. ${l.room}` : '' }}</div>
|
||||
<button class="btn-secondary btn-sm">Подробнее</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
<ModalDialog v-model="filtersOpen" title="Фильтры">
|
||||
<div class="modal-filters">
|
||||
<label>Дата</label>
|
||||
<select v-model="dateFilter" class="glass-input">
|
||||
<option>Любая дата</option>
|
||||
<option>Сегодня</option>
|
||||
<option>Завтра</option>
|
||||
<option>На этой неделе</option>
|
||||
</select>
|
||||
<label>Направление</label>
|
||||
<select v-model="direction" class="glass-input">
|
||||
<option v-for="d in directions" :key="d">{{ d }}</option>
|
||||
</select>
|
||||
<label>Преподаватель</label>
|
||||
<select v-model="teacher" class="glass-input">
|
||||
<option v-for="t in teachers" :key="t">{{ t }}</option>
|
||||
</select>
|
||||
<label>Корпус</label>
|
||||
<select v-model="building" class="glass-input">
|
||||
<option v-for="b in buildings" :key="b">{{ b }}</option>
|
||||
</select>
|
||||
<label>Формат</label>
|
||||
<div class="segmented">
|
||||
<button :class="{ active: format === 'all' }" @click="format = 'all'">Все</button>
|
||||
<button :class="{ active: format === 'offline' }" @click="format = 'offline'">Офлайн</button>
|
||||
<button :class="{ active: format === 'online' }" @click="format = 'online'">Онлайн</button>
|
||||
</div>
|
||||
<label>Теги</label>
|
||||
<FilterChips :filters="tagFilters" @toggle="toggleTag" />
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="onlyFree" />
|
||||
<span>Только свободные места</span>
|
||||
</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="btn-secondary" @click="filtersOpen = false">Закрыть</button>
|
||||
<button class="btn-primary" @click="filtersOpen = false">Применить</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.catalog { display: flex; flex-direction: column; gap: 20px; }
|
||||
.catalog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.header-actions { display: flex; gap: 12px; align-items: center; flex: 1; justify-content: flex-end; }
|
||||
.filters-btn { display: none; }
|
||||
.filters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.filter-label { font-size: 12px; font-weight: 600; color: var(--color-text-secondary); margin-bottom: 6px; display: block; }
|
||||
.segmented {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--color-border-glass);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.segmented button {
|
||||
background: rgba(255,255,255,0.7);
|
||||
border: none;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.segmented button.active {
|
||||
background: rgba(34,197,94,0.15);
|
||||
color: var(--color-primary-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
.free-toggle { display: flex; flex-direction: column; gap: 6px; }
|
||||
.switch { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--color-text-secondary); }
|
||||
.view-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.applied { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.list-title { font-weight: 600; }
|
||||
.list-view { margin-top: 6px; }
|
||||
.calendar-view { display: flex; flex-direction: column; gap: 14px; }
|
||||
.calendar-day { padding: 16px; }
|
||||
.calendar-date { font-weight: 700; margin-bottom: 8px; }
|
||||
.calendar-items { display: flex; flex-direction: column; gap: 10px; }
|
||||
.calendar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--color-border-glass);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.calendar-item:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.calendar-title { font-weight: 600; }
|
||||
.calendar-meta { font-size: 12px; color: var(--color-text-secondary); }
|
||||
.modal-filters { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filters-grid { display: none; }
|
||||
.filters-btn { display: inline-flex; }
|
||||
.header-actions { width: 100%; justify-content: space-between; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useLecturesStore } from '@/stores/lectures'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||
import StatsWidget from '@/components/ui/StatsWidget.vue'
|
||||
import LectureCard from '@/components/ui/LectureCard.vue'
|
||||
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
||||
import AchievementBadge from '@/components/ui/AchievementBadge.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const lectures = useLecturesStore()
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
const user = computed(() => auth.user!)
|
||||
const nextLecture = computed(() => lectures.registeredLectures[0] ?? lectures.all[0]!)
|
||||
const recommended = computed(() =>
|
||||
lectures.all.filter(l => !lectures.registeredIds.includes(l.id)).slice(0, 3)
|
||||
)
|
||||
const achievements = computed(() => userStore.achievements.filter(a => a.unlocked).slice(0, 3))
|
||||
const reminders = computed(() => userStore.notifications.slice(0, 3))
|
||||
const xpToNext = 200
|
||||
const xpProgress = computed(() => user.value.xp ?? 120)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard page-content">
|
||||
<div class="dashboard-welcome">
|
||||
<div>
|
||||
<h1 class="page-title">Добрый день, {{ user.name.split(' ')[0] }}! 👋</h1>
|
||||
<p class="text-secondary">{{ user.institute }} · {{ user.direction }} · {{ user.year }} курс</p>
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
<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(`/review/${nextLecture?.id ?? '1'}`)">Оставить отзыв</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GlassCard>
|
||||
<div class="next-lecture">
|
||||
<div>
|
||||
<div class="section-title">Ближайшая лекция</div>
|
||||
<div class="next-title">{{ nextLecture.title }}</div>
|
||||
<div class="next-meta">
|
||||
<span>📅 Завтра, {{ nextLecture.time }}</span>
|
||||
<span>🏛 {{ nextLecture.building }}, ауд. {{ nextLecture.room ?? 'онлайн' }}</span>
|
||||
<span>👤 {{ nextLecture.teacher }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="next-actions">
|
||||
<button class="btn-primary" @click="router.push(`/lecture/${nextLecture.id}`)">Открыть</button>
|
||||
<button class="btn-secondary">Добавить в календарь</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<div class="stats-row">
|
||||
<StatsWidget label="Посещено лекций" :value="user.lecturesAttended ?? 12" icon="📚" color="green" />
|
||||
<StatsWidget label="Часов обучения" :value="user.hoursLearned ?? 18.5" icon="⏱" color="aqua" />
|
||||
<StatsWidget label="Монет" :value="user.coins" icon="💰" color="orange" />
|
||||
<StatsWidget label="Уровень" :value="user.level" icon="⭐" color="purple" sub="текущий уровень" />
|
||||
</div>
|
||||
|
||||
<GlassCard>
|
||||
<div class="xp-section">
|
||||
<div class="xp-header">
|
||||
<span class="xp-label">Прогресс до уровня {{ user.level + 1 }}</span>
|
||||
<span class="xp-val">{{ xpProgress }} / {{ xpToNext }} XP</span>
|
||||
</div>
|
||||
<ProgressBar :value="xpProgress" :max="xpToNext" />
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<section>
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">✨ Рекомендуемые лекции</h2>
|
||||
<button class="link-btn" @click="router.push('/catalog')">Все лекции →</button>
|
||||
</div>
|
||||
<div class="cards-grid">
|
||||
<LectureCard
|
||||
v-for="l in recommended"
|
||||
:key="l.id"
|
||||
:lecture="l"
|
||||
:registered="lectures.registeredIds.includes(l.id)"
|
||||
@register="lectures.register"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="two-column">
|
||||
<GlassCard>
|
||||
<div class="section-title">🏆 Достижения</div>
|
||||
<div class="achievements">
|
||||
<AchievementBadge
|
||||
v-for="a in achievements"
|
||||
:key="a.id"
|
||||
:icon="a.icon"
|
||||
:title="a.title"
|
||||
:description="a.description"
|
||||
:unlocked="a.unlocked"
|
||||
:unlockedAt="a.unlockedAt"
|
||||
:coins="a.coins"
|
||||
/>
|
||||
</div>
|
||||
</GlassCard>
|
||||
<GlassCard>
|
||||
<div class="section-title">🔔 Напоминания</div>
|
||||
<div class="reminders">
|
||||
<div class="reminder-item" v-for="n in reminders" :key="n.id">
|
||||
<div class="reminder-title">{{ n.title }}</div>
|
||||
<div class="reminder-body">{{ n.body }}</div>
|
||||
<div class="reminder-date">{{ new Date(n.createdAt).toLocaleDateString('ru-RU') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard { display: flex; flex-direction: column; gap: 24px; }
|
||||
.dashboard-welcome {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.quick-actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.next-lecture { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
||||
.next-title { font-size: 18px; font-weight: 700; margin: 6px 0; }
|
||||
.next-meta { display: flex; flex-direction: column; gap: 4px; color: var(--color-text-secondary); font-size: 13px; }
|
||||
.next-actions { display: flex; gap: 10px; align-items: flex-start; }
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.xp-section { display: flex; flex-direction: column; gap: 10px; }
|
||||
.xp-header { display: flex; justify-content: space-between; font-size: 13px; font-weight: 600; }
|
||||
.xp-val { color: var(--color-text-secondary); }
|
||||
.section-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary-dark);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.two-column { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
|
||||
.achievements { display: flex; flex-direction: column; gap: 12px; margin-top: 10px; }
|
||||
.reminders { display: flex; flex-direction: column; gap: 12px; margin-top: 10px; }
|
||||
.reminder-item {
|
||||
border-bottom: 1px solid var(--color-border-glass);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.reminder-item:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.reminder-title { font-weight: 700; margin-bottom: 4px; }
|
||||
.reminder-body { font-size: 13px; color: var(--color-text-secondary); }
|
||||
.reminder-date { font-size: 11px; color: var(--color-text-secondary); margin-top: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useLecturesStore } from '@/stores/lectures'
|
||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||
import LectureCard from '@/components/ui/LectureCard.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const lecturesStore = useLecturesStore()
|
||||
|
||||
const lecture = computed(() => lecturesStore.all.find(l => l.id === route.params.id) ?? lecturesStore.all[0]!)
|
||||
const isRegistered = computed(() => lecturesStore.isRegistered(lecture.value.id))
|
||||
const attendedLectures = ['1']
|
||||
const isAttended = computed(() => attendedLectures.includes(lecture.value.id))
|
||||
|
||||
const similarLectures = computed(() => lecturesStore.all.filter(l => l.id !== lecture.value.id).slice(0, 3))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lecture-detail page-content">
|
||||
<div class="header">
|
||||
<div>
|
||||
<div class="breadcrumb">Каталог / {{ lecture.title }}</div>
|
||||
<h1 class="page-title">{{ lecture.title }}</h1>
|
||||
<p class="text-secondary">{{ lecture.description }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button
|
||||
v-if="!isRegistered"
|
||||
class="btn-primary"
|
||||
:disabled="lecture.freeSeats === 0 || lecture.registrationClosed"
|
||||
@click="lecturesStore.register(lecture.id)"
|
||||
>
|
||||
Записаться
|
||||
</button>
|
||||
<button v-else class="btn-secondary" @click="lecturesStore.unregister(lecture.id)">Отменить запись</button>
|
||||
<button class="btn-secondary">Добавить в календарь</button>
|
||||
<button v-if="isAttended" class="btn-primary" @click="router.push(`/review/${lecture.id}`)">Оставить отзыв</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-grid">
|
||||
<GlassCard>
|
||||
<div class="info-section">
|
||||
<h3>Преподаватель</h3>
|
||||
<div class="info-value">{{ lecture.teacher }} · {{ lecture.teacherTitle }}</div>
|
||||
<div class="info-sub">{{ lecture.department }}, {{ lecture.institute }}</div>
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<h3>Детали занятия</h3>
|
||||
<div class="info-value">📅 {{ new Date(lecture.date).toLocaleDateString('ru-RU') }} · {{ lecture.time }}</div>
|
||||
<div class="info-sub">Длительность: {{ lecture.duration }} мин</div>
|
||||
<div class="info-sub">Локация: {{ lecture.building }} {{ lecture.room ? `· ауд. ${lecture.room}` : '' }}</div>
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<h3>Места</h3>
|
||||
<div class="info-value">Свободно {{ lecture.freeSeats }} из {{ lecture.totalSeats }}</div>
|
||||
<StatusBadge :status="lecture.registrationClosed ? 'closed' : lecture.freeSeats === 0 ? 'full' : 'open'" />
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<h3>Теги</h3>
|
||||
<div class="tags">
|
||||
<span class="tag-chip" v-for="tag in lecture.tags" :key="tag">{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard>
|
||||
<div class="section-title">LLM-сводка отзывов</div>
|
||||
<p class="summary">
|
||||
Студенты отмечают «понятные примеры» и «много практики». Предлагается добавить больше времени на вопросы и
|
||||
прикладные кейсы. Средняя оценка — 4.8/5.
|
||||
</p>
|
||||
<div class="reviews">
|
||||
<div class="review">
|
||||
<div class="review-head">Анонимный отзыв · 5 ⭐</div>
|
||||
<div class="review-body">Очень структурно, понравились живые примеры и объяснение базовых концепций.</div>
|
||||
</div>
|
||||
<div class="review">
|
||||
<div class="review-head">Анонимный отзыв · 4 ⭐</div>
|
||||
<div class="review-body">Полезно, но хотелось больше времени на практику и разбор домашних заданий.</div>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 class="section-title">Похожие лекции</h2>
|
||||
<div class="cards-grid">
|
||||
<LectureCard v-for="l in similarLectures" :key="l.id" :lecture="l" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lecture-detail { display: flex; flex-direction: column; gap: 24px; }
|
||||
.breadcrumb { font-size: 12px; color: var(--color-text-secondary); margin-bottom: 6px; }
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
|
||||
.info-section { margin-bottom: 16px; }
|
||||
.info-section:last-child { margin-bottom: 0; }
|
||||
.info-section h3 { font-size: 14px; margin-bottom: 8px; }
|
||||
.info-value { font-weight: 700; }
|
||||
.info-sub { font-size: 13px; color: var(--color-text-secondary); margin-top: 4px; }
|
||||
.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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useLecturesStore } from '@/stores/lectures'
|
||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import ModalDialog from '@/components/ui/ModalDialog.vue'
|
||||
|
||||
const lecturesStore = useLecturesStore()
|
||||
const router = useRouter()
|
||||
const activeTab = ref<'upcoming' | 'history'>('upcoming')
|
||||
const cancelModal = ref(false)
|
||||
const selectedId = ref<string | null>(null)
|
||||
|
||||
const upcoming = computed(() =>
|
||||
lecturesStore.registeredLectures.map(l => ({ ...l, status: 'registered' }))
|
||||
)
|
||||
|
||||
const history = ref([
|
||||
{ id: '1', title: 'Введение в нейронные сети и глубокое обучение', date: '2025-04-20', time: '14:00', building: 'ИКТИБ', room: '305', status: 'attended' },
|
||||
{ id: '4', title: 'Философия цифровой эпохи', date: '2025-04-12', time: '18:00', building: 'Онлайн', room: '', status: 'needsReview' },
|
||||
{ id: '5', title: 'Право в информационном обществе', date: '2025-04-05', time: '15:30', building: 'ЮФ', room: '412', status: 'cancelled' },
|
||||
])
|
||||
|
||||
function openCancel(id: string) {
|
||||
selectedId.value = id
|
||||
cancelModal.value = true
|
||||
}
|
||||
|
||||
function confirmCancel() {
|
||||
if (selectedId.value) 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>
|
||||
<button class="btn-secondary">Экспорт в календарь</button>
|
||||
</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">
|
||||
<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">Добавить в календарь</button>
|
||||
<button class="btn-danger btn-sm" @click="openCancel(item.id)">Отменить</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
<div v-else class="list">
|
||||
<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" />
|
||||
<button v-if="item.status === 'needsReview'" class="btn-primary btn-sm" @click="router.push(`/review/${item.id}`)">
|
||||
Оставить отзыв
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
<ModalDialog v-model="cancelModal" title="Отменить запись?">
|
||||
<p>Вы уверены, что хотите отменить запись на лекцию? Место будет освобождено для других студентов.</p>
|
||||
<template #footer>
|
||||
<button class="btn-secondary" @click="cancelModal = false">Нет</button>
|
||||
<button class="btn-danger" @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; }
|
||||
.tabs { display: inline-flex; 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;
|
||||
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>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const grouped = computed(() => {
|
||||
const map: Record<string, typeof userStore.notifications> = {}
|
||||
userStore.notifications.forEach(n => {
|
||||
const day = new Date(n.createdAt).toLocaleDateString('ru-RU')
|
||||
map[day] = map[day] || []
|
||||
map[day].push(n)
|
||||
})
|
||||
return Object.entries(map)
|
||||
})
|
||||
|
||||
const typeIcon: Record<string, string> = {
|
||||
reminder: '⏰',
|
||||
'schedule-change': '🗓️',
|
||||
achievement: '🏆',
|
||||
coins: '💰',
|
||||
recommendation: '✨',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notifications page-content">
|
||||
<div class="header">
|
||||
<h1 class="page-title">Уведомления</h1>
|
||||
<button class="btn-secondary" @click="userStore.markAllRead">Отметить все как прочитанные</button>
|
||||
</div>
|
||||
|
||||
<div class="notification-groups">
|
||||
<GlassCard v-for="([day, items]) in grouped" :key="day" class="group">
|
||||
<div class="group-title">{{ day }}</div>
|
||||
<div class="items">
|
||||
<div v-for="n in items" :key="n.id" class="item" :class="{ unread: !n.read }">
|
||||
<div class="icon">{{ typeIcon[n.type] }}</div>
|
||||
<div>
|
||||
<div class="item-title">{{ n.title }}</div>
|
||||
<div class="item-body">{{ n.body }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notifications { display: flex; flex-direction: column; gap: 18px; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.notification-groups { display: flex; flex-direction: column; gap: 14px; }
|
||||
.group-title { font-weight: 700; margin-bottom: 10px; }
|
||||
.items { display: flex; flex-direction: column; gap: 10px; }
|
||||
.item { display: flex; gap: 12px; padding: 10px; border-radius: var(--radius-sm); background: rgba(255,255,255,0.6); border: 1px solid var(--color-border-glass); }
|
||||
.item.unread { border-color: rgba(34,197,94,0.4); background: rgba(34,197,94,0.08); }
|
||||
.icon { font-size: 20px; }
|
||||
.item-title { font-weight: 600; }
|
||||
.item-body { font-size: 13px; color: var(--color-text-secondary); }
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||
import CoinChip from '@/components/ui/CoinChip.vue'
|
||||
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
||||
import AchievementBadge from '@/components/ui/AchievementBadge.vue'
|
||||
import DataTable from '@/components/ui/DataTable.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const userStore = useUserStore()
|
||||
const user = computed(() => auth.user!)
|
||||
const interestTags = ref([
|
||||
{ label: '#ML', active: true },
|
||||
{ label: '#ИИ', active: true },
|
||||
{ label: '#дизайн', active: false },
|
||||
{ label: '#право', active: false },
|
||||
{ label: '#биоинформатика', active: false },
|
||||
{ label: '#маркетинг', active: true },
|
||||
])
|
||||
|
||||
const notificationSettings = ref({ email: true, web: true, telegram: false })
|
||||
|
||||
const historyColumns = [
|
||||
{ key: 'date', label: 'Дата' },
|
||||
{ key: 'description', label: 'Описание' },
|
||||
{ key: 'amount', label: 'Монеты', align: 'right' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="profile page-content">
|
||||
<div class="header">
|
||||
<h1 class="page-title">Профиль студента</h1>
|
||||
<CoinChip :amount="user.coins" />
|
||||
</div>
|
||||
|
||||
<div class="profile-grid">
|
||||
<GlassCard>
|
||||
<div class="user-info">
|
||||
<div class="avatar">👤</div>
|
||||
<div>
|
||||
<div class="name">{{ user.name }}</div>
|
||||
<div class="email">{{ user.email }}</div>
|
||||
<div class="meta">{{ user.institute }} · {{ user.direction }}</div>
|
||||
<div class="meta">{{ user.year }} курс</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level">
|
||||
<div class="level-header">
|
||||
<span>Уровень {{ user.level }}</span>
|
||||
<span>{{ user.xp }} / 200 XP</span>
|
||||
</div>
|
||||
<ProgressBar :value="user.xp ?? 0" :max="200" />
|
||||
</div>
|
||||
<div class="tags">
|
||||
<div class="section-title">Интересы</div>
|
||||
<div class="tags-grid">
|
||||
<button
|
||||
v-for="tag in interestTags"
|
||||
:key="tag.label"
|
||||
class="tag-chip"
|
||||
:class="{ active: tag.active }"
|
||||
@click="tag.active = !tag.active"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard>
|
||||
<div class="section-title">Настройки уведомлений</div>
|
||||
<div class="settings">
|
||||
<label class="setting">
|
||||
<input type="checkbox" v-model="notificationSettings.email" />
|
||||
Email уведомления
|
||||
</label>
|
||||
<label class="setting">
|
||||
<input type="checkbox" v-model="notificationSettings.web" />
|
||||
Web push
|
||||
</label>
|
||||
<label class="setting">
|
||||
<input type="checkbox" v-model="notificationSettings.telegram" />
|
||||
Telegram бот @universe_sfedu
|
||||
</label>
|
||||
</div>
|
||||
<div class="section-title">Достижения</div>
|
||||
<div class="achievements">
|
||||
<AchievementBadge
|
||||
v-for="a in userStore.achievements.slice(0, 3)"
|
||||
:key="a.id"
|
||||
:icon="a.icon"
|
||||
:title="a.title"
|
||||
:description="a.description"
|
||||
:unlocked="a.unlocked"
|
||||
:unlockedAt="a.unlockedAt"
|
||||
:coins="a.coins"
|
||||
/>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
<GlassCard>
|
||||
<div class="section-title">История начисления монет</div>
|
||||
<DataTable :columns="historyColumns" :rows="userStore.coinHistory">
|
||||
<template #amount="{ value }">
|
||||
<span :class="value > 0 ? 'positive' : 'negative'">{{ value > 0 ? `+${value}` : value }}</span>
|
||||
</template>
|
||||
</DataTable>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile { display: flex; flex-direction: column; gap: 20px; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.profile-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
|
||||
.user-info { display: flex; gap: 14px; align-items: center; margin-bottom: 16px; }
|
||||
.avatar { font-size: 38px; background: rgba(34,197,94,0.15); border-radius: 16px; padding: 12px; }
|
||||
.name { font-weight: 700; font-size: 18px; }
|
||||
.email, .meta { font-size: 13px; color: var(--color-text-secondary); }
|
||||
.level { margin: 16px 0; }
|
||||
.level-header { display: flex; justify-content: space-between; font-size: 12px; color: var(--color-text-secondary); margin-bottom: 6px; }
|
||||
.tags-grid { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
.settings { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
|
||||
.setting { font-size: 13px; color: var(--color-text-secondary); display: flex; gap: 8px; align-items: center; }
|
||||
.achievements { display: flex; flex-direction: column; gap: 12px; margin-top: 10px; }
|
||||
.positive { color: #166534; font-weight: 600; }
|
||||
.negative { color: #991B1B; font-weight: 600; }
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const rating = ref<'positive' | 'neutral' | 'negative'>('positive')
|
||||
const text = ref('Лекция была хорошо структурирована, особенно понравились практические примеры и разбор кейсов.')
|
||||
const submitted = ref(false)
|
||||
const editing = ref(false)
|
||||
|
||||
function submit() {
|
||||
submitted.value = true
|
||||
editing.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="review page-content">
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1 class="page-title">Отзыв о лекции #{{ route.params.id }}</h1>
|
||||
<p class="text-secondary">Ваш отзыв помогает улучшать качество лекций и приносит бонусные монеты.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GlassCard>
|
||||
<div v-if="submitted && !editing" class="success-state">
|
||||
<div class="success-icon">✅</div>
|
||||
<div class="success-title">Отзыв отправлен и будет обработан</div>
|
||||
<div class="success-sub">
|
||||
Спасибо! Оценка полезности отзыва рассчитывается автоматически. Студентам не показывается техническая оценка LLM.
|
||||
</div>
|
||||
<button class="btn-secondary" @click="editing = true">Редактировать отзыв</button>
|
||||
</div>
|
||||
|
||||
<form v-else @submit.prevent="submit" class="form">
|
||||
<label class="field-label">Ваш отзыв о лекции</label>
|
||||
<textarea v-model="text" rows="6" placeholder="Опишите, что было полезно, а что можно улучшить"></textarea>
|
||||
|
||||
<label class="field-label">Оценка впечатлений</label>
|
||||
<div class="rating-options">
|
||||
<button type="button" :class="{ active: rating === 'positive' }" @click="rating = 'positive'">👍 Положительный</button>
|
||||
<button type="button" :class="{ active: rating === 'neutral' }" @click="rating = 'neutral'">😐 Нейтральный</button>
|
||||
<button type="button" :class="{ active: rating === 'negative' }" @click="rating = 'negative'">👎 Отрицательный</button>
|
||||
</div>
|
||||
|
||||
<div class="hint">
|
||||
💡 Постарайтесь быть конкретными: что понравилось, где было сложно, какие темы хотите раскрыть глубже.
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn-primary" type="submit">Отправить отзыв</button>
|
||||
<button class="btn-secondary" type="button" :disabled="submitted">Сохранить черновик</button>
|
||||
</div>
|
||||
</form>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.review { display: flex; flex-direction: column; gap: 16px; }
|
||||
.form { display: flex; flex-direction: column; gap: 12px; }
|
||||
.field-label { font-weight: 600; font-size: 13px; color: var(--color-text-secondary); }
|
||||
textarea {
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border-glass);
|
||||
background: rgba(255,255,255,0.8);
|
||||
font-size: 14px;
|
||||
resize: vertical;
|
||||
}
|
||||
.rating-options { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.rating-options button {
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border-glass);
|
||||
background: rgba(255,255,255,0.6);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.rating-options button.active {
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(34,197,94,0.15);
|
||||
color: var(--color-primary-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
.hint { font-size: 12px; color: var(--color-text-secondary); background: rgba(255,255,255,0.6); padding: 8px 12px; border-radius: var(--radius-sm); }
|
||||
.form-actions { display: flex; gap: 10px; }
|
||||
.success-state { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
|
||||
.success-icon { font-size: 28px; }
|
||||
.success-title { font-size: 16px; font-weight: 700; }
|
||||
.success-sub { font-size: 13px; color: var(--color-text-secondary); }
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
||||
|
||||
const ratingTrend = [4.2, 4.5, 4.6, 4.8, 4.7]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="teacher-analytics page-content">
|
||||
<h1 class="page-title">Аналитика преподавателя</h1>
|
||||
|
||||
<div class="grid">
|
||||
<GlassCard>
|
||||
<div class="section-title">Динамика оценок</div>
|
||||
<div class="chart">
|
||||
<div v-for="(value, i) in ratingTrend" :key="i" class="bar">
|
||||
<div class="bar-fill" :style="{ height: `${value * 18}px` }"></div>
|
||||
<span class="bar-label">Нед {{ i + 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="avg">Средняя оценка: 4.6</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard>
|
||||
<div class="section-title">Sentiment-анализ отзывов</div>
|
||||
<div class="sentiment">
|
||||
<div>
|
||||
<div class="sentiment-label">Позитивные 65%</div>
|
||||
<ProgressBar :value="65" :max="100" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="sentiment-label">Нейтральные 25%</div>
|
||||
<ProgressBar :value="25" :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>
|
||||
</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>
|
||||
<div class="reviews">
|
||||
<div class="review">
|
||||
«Больше кейсов и примеров из реальной жизни, лекция очень понравилась»
|
||||
</div>
|
||||
<div class="review">
|
||||
«Темп быстрый, но структура отличная. Хотелось бы больше практических заданий.»
|
||||
</div>
|
||||
<div class="review">
|
||||
«Отличные слайды и примеры, спасибо за доступное объяснение сложных тем.»
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-title">Топ полезных отзывов</div>
|
||||
<ul class="top-list">
|
||||
<li>«Лабораторная часть помогла понять алгоритмы, пожалуйста, добавьте еще 15 минут»</li>
|
||||
<li>«Понравились интерактивные задания, хочется больше времени на Q&A»</li>
|
||||
</ul>
|
||||
</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; }
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useLecturesStore } from '@/stores/lectures'
|
||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||
import StatsWidget from '@/components/ui/StatsWidget.vue'
|
||||
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
||||
|
||||
const lecturesStore = useLecturesStore()
|
||||
const upcoming = computed(() => lecturesStore.all.slice(0, 3))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="teacher-dashboard page-content">
|
||||
<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>
|
||||
</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" />
|
||||
</div>
|
||||
|
||||
<GlassCard>
|
||||
<div class="section-title">Заметность за пределами направления</div>
|
||||
<div class="visibility">
|
||||
<div class="visibility-meta">
|
||||
38% студентов из других институтов · Цель 50%
|
||||
</div>
|
||||
<ProgressBar :value="38" :max="100" />
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard>
|
||||
<div class="section-title">Ближайшие открытые лекции</div>
|
||||
<div 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>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</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; }
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||
import DataTable from '@/components/ui/DataTable.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
|
||||
const columns = [
|
||||
{ key: 'title', label: 'Лекция' },
|
||||
{ key: 'date', label: 'Дата' },
|
||||
{ key: 'status', label: 'Статус', align: 'center' },
|
||||
{ key: 'stats', label: 'Записи/Посещения/Отзывы', align: 'center' },
|
||||
{ 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' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="teacher-lectures page-content">
|
||||
<div class="header">
|
||||
<h1 class="page-title">Мои лекции</h1>
|
||||
<button class="btn-primary">Создать лекцию</button>
|
||||
</div>
|
||||
|
||||
<GlassCard>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</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; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user