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:
+92
-7
@@ -1,11 +1,96 @@
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AppTopbar from '@/components/layout/AppTopbar.vue'
|
||||
import AppSidebar from '@/components/layout/AppSidebar.vue'
|
||||
import AppBottomNav from '@/components/layout/AppBottomNav.vue'
|
||||
import ToastNotification from '@/components/ui/ToastNotification.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
|
||||
const isAuthPage = computed(() => route.path === '/login')
|
||||
|
||||
interface Toast { id: number; message: string; type: 'success' | 'error' | 'info' }
|
||||
const toasts = ref<Toast[]>([])
|
||||
let toastId = 0
|
||||
|
||||
function addToast(msg: string, type: Toast['type'] = 'success') {
|
||||
toasts.value.push({ id: ++toastId, message: msg, type })
|
||||
}
|
||||
|
||||
function removeToast(id: number) {
|
||||
toasts.value = toasts.value.filter(t => t.id !== id)
|
||||
}
|
||||
|
||||
// expose globally via provide
|
||||
import { provide } from 'vue'
|
||||
provide('addToast', addToast)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>You did it!</h1>
|
||||
<p>
|
||||
Visit <a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to read the
|
||||
documentation
|
||||
</p>
|
||||
<div class="app-root">
|
||||
<!-- Auth page: no layout -->
|
||||
<template v-if="isAuthPage">
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<!-- Main layout -->
|
||||
<template v-else-if="auth.isAuthenticated">
|
||||
<AppTopbar />
|
||||
<AppSidebar />
|
||||
<main class="main-content">
|
||||
<RouterView />
|
||||
</main>
|
||||
<AppBottomNav />
|
||||
</template>
|
||||
|
||||
<!-- Redirect to login if not authenticated -->
|
||||
<template v-else>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<!-- Toast container -->
|
||||
<div class="toast-container">
|
||||
<ToastNotification
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
:message="t.message"
|
||||
:type="t.type"
|
||||
@close="removeToast(t.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.app-root {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.main-content {
|
||||
margin-top: var(--topbar-height);
|
||||
margin-left: var(--sidebar-width);
|
||||
min-height: calc(100vh - var(--topbar-height));
|
||||
padding: 28px 28px 80px;
|
||||
}
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
z-index: 300;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
padding: 16px 16px 80px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/* color palette from <https://github.com/vuejs/theme> */
|
||||
:root {
|
||||
--vt-c-white: #ffffff;
|
||||
--vt-c-white-soft: #f8f8f8;
|
||||
--vt-c-white-mute: #f2f2f2;
|
||||
|
||||
--vt-c-black: #181818;
|
||||
--vt-c-black-soft: #222222;
|
||||
--vt-c-black-mute: #282828;
|
||||
|
||||
--vt-c-indigo: #2c3e50;
|
||||
|
||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||
|
||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||
--vt-c-text-dark-1: var(--vt-c-white);
|
||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||
}
|
||||
|
||||
/* semantic color variables for this project */
|
||||
:root {
|
||||
--color-background: var(--vt-c-white);
|
||||
--color-background-soft: var(--vt-c-white-soft);
|
||||
--color-background-mute: var(--vt-c-white-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-light-2);
|
||||
--color-border-hover: var(--vt-c-divider-light-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-light-1);
|
||||
--color-text: var(--vt-c-text-light-1);
|
||||
|
||||
--section-gap: 160px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-background: var(--vt-c-black);
|
||||
--color-background-soft: var(--vt-c-black-soft);
|
||||
--color-background-mute: var(--vt-c-black-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-dark-2);
|
||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--color-text: var(--vt-c-text-dark-2);
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
transition:
|
||||
color 0.5s,
|
||||
background-color 0.5s;
|
||||
line-height: 1.6;
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Fira Sans',
|
||||
'Droid Sans',
|
||||
'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
/* UniVerse – Aero Green Design System */
|
||||
|
||||
:root {
|
||||
--color-primary: #22C55E;
|
||||
--color-primary-dark: #16A34A;
|
||||
--color-primary-light: #86EFAC;
|
||||
--color-aqua: #06B6D4;
|
||||
--color-sky: #7DD3FC;
|
||||
--color-white-glass: rgba(255, 255, 255, 0.75);
|
||||
--color-surface: rgba(255, 255, 255, 0.85);
|
||||
--color-border-glass: rgba(255, 255, 255, 0.8);
|
||||
--color-text: #1E293B;
|
||||
--color-text-secondary: #64748B;
|
||||
--color-bg-start: #E0F2FE;
|
||||
--color-bg-mid: #DCFCE7;
|
||||
--color-error: #EF4444;
|
||||
--color-success: #22C55E;
|
||||
--color-warning: #F59E0B;
|
||||
--gradient-bg: linear-gradient(135deg, #E0F2FE 0%, #DCFCE7 50%, #E0F2FE 100%);
|
||||
--gradient-brand: linear-gradient(135deg, #22C55E 0%, #06B6D4 60%, #7DD3FC 100%);
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--shadow-glass: 0 8px 32px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
--shadow-card: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
--sidebar-width: 240px;
|
||||
--topbar-height: 60px;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--gradient-bg);
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
|
||||
a {
|
||||
color: var(--color-primary-dark);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: rgba(0,0,0,0.04); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(34,197,94,0.3); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(34,197,94,0.5); }
|
||||
|
||||
/* Glass panel utility */
|
||||
.glass-panel {
|
||||
background: var(--color-white-glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--color-border-glass);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-glass);
|
||||
}
|
||||
|
||||
/* Primary glossy button */
|
||||
.btn-primary {
|
||||
background: linear-gradient(180deg, #4ADE80 0%, #22C55E 50%, #16A34A 100%);
|
||||
border: 1px solid #15803D;
|
||||
border-radius: var(--radius-sm);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.btn-primary::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 50%;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 100%);
|
||||
border-radius: 8px 8px 0 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px rgba(34,197,94,0.4);
|
||||
}
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Secondary button */
|
||||
.btn-secondary {
|
||||
background: rgba(255,255,255,0.7);
|
||||
border: 1px solid rgba(34,197,94,0.4);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-primary-dark);
|
||||
padding: 10px 20px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: rgba(34,197,94,0.1);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Danger button */
|
||||
.btn-danger {
|
||||
background: linear-gradient(180deg, #FCA5A5 0%, #EF4444 50%, #DC2626 100%);
|
||||
border: 1px solid #B91C1C;
|
||||
border-radius: var(--radius-sm);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px rgba(239,68,68,0.4);
|
||||
}
|
||||
|
||||
/* Ghost button */
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
padding: 8px 14px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: rgba(0,0,0,0.05);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Glass input */
|
||||
.glass-input {
|
||||
background: rgba(255,255,255,0.6);
|
||||
border: 1px solid rgba(255,255,255,0.8);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
width: 100%;
|
||||
transition: all 0.2s;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.glass-input:focus {
|
||||
background: rgba(255,255,255,0.85);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(34,197,94,0.15);
|
||||
}
|
||||
.glass-input::placeholder {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Badge */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge-green { background: rgba(34,197,94,0.15); color: #15803D; border: 1px solid rgba(34,197,94,0.3); }
|
||||
.badge-blue { background: rgba(6,182,212,0.15); color: #0E7490; border: 1px solid rgba(6,182,212,0.3); }
|
||||
.badge-orange { background: rgba(251,146,60,0.15); color: #C2410C; border: 1px solid rgba(251,146,60,0.3); }
|
||||
.badge-gray { background: rgba(100,116,139,0.1); color: #64748B; border: 1px solid rgba(100,116,139,0.2); }
|
||||
.badge-red { background: rgba(239,68,68,0.12); color: #B91C1C; border: 1px solid rgba(239,68,68,0.2); }
|
||||
.badge-purple { background: rgba(139,92,246,0.12); color: #6D28D9; border: 1px solid rgba(139,92,246,0.2); }
|
||||
|
||||
/* Tag chip */
|
||||
.tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
background: rgba(34,197,94,0.1);
|
||||
border: 1px solid rgba(34,197,94,0.25);
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
color: var(--color-primary-dark);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tag-chip:hover,
|
||||
.tag-chip.active {
|
||||
background: rgba(34,197,94,0.2);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Page layout helpers */
|
||||
.page-content {
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* Grid helpers */
|
||||
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
||||
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
||||
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
|
||||
.page-content { padding: 16px; }
|
||||
}
|
||||
|
||||
/* Flex helpers */
|
||||
.flex { display: flex; }
|
||||
.flex-col { display: flex; flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.gap-2 { gap: 8px; }
|
||||
.gap-3 { gap: 12px; }
|
||||
.gap-4 { gap: 16px; }
|
||||
|
||||
/* Text helpers */
|
||||
.text-sm { font-size: 12px; }
|
||||
.text-secondary { color: var(--color-text-secondary); }
|
||||
.font-bold { font-weight: 700; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
|
||||
/* Stars rating */
|
||||
.stars { color: #FBBF24; font-size: 14px; letter-spacing: 1px; }
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.fade-in { animation: fadeIn 0.3s ease; }
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.spinner {
|
||||
width: 20px; height: 20px;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
.spinner-green {
|
||||
border-color: rgba(34,197,94,0.2);
|
||||
border-top-color: var(--color-primary);
|
||||
}
|
||||
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
msg: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="greetings">
|
||||
<h1 class="green">{{ msg }}</h1>
|
||||
<h3>
|
||||
You’ve successfully created a project with
|
||||
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 {
|
||||
font-weight: 500;
|
||||
font-size: 2.6rem;
|
||||
position: relative;
|
||||
top: -10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import WelcomeItem from './WelcomeItem.vue'
|
||||
import DocumentationIcon from './icons/IconDocumentation.vue'
|
||||
import ToolingIcon from './icons/IconTooling.vue'
|
||||
import EcosystemIcon from './icons/IconEcosystem.vue'
|
||||
import CommunityIcon from './icons/IconCommunity.vue'
|
||||
import SupportIcon from './icons/IconSupport.vue'
|
||||
|
||||
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<DocumentationIcon />
|
||||
</template>
|
||||
<template #heading>Documentation</template>
|
||||
|
||||
Vue’s
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
|
||||
provides you with all information you need to get started.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<ToolingIcon />
|
||||
</template>
|
||||
<template #heading>Tooling</template>
|
||||
|
||||
This project is served and bundled with
|
||||
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
|
||||
recommended IDE setup is
|
||||
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
|
||||
+
|
||||
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener"
|
||||
>Vue - Official</a
|
||||
>. If you need to test your components and web pages, check out
|
||||
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
|
||||
and
|
||||
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
|
||||
/
|
||||
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
|
||||
|
||||
<br />
|
||||
|
||||
More instructions are available in
|
||||
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
|
||||
>.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<EcosystemIcon />
|
||||
</template>
|
||||
<template #heading>Ecosystem</template>
|
||||
|
||||
Get official tools and libraries for your project:
|
||||
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
|
||||
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
|
||||
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
|
||||
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
|
||||
you need more resources, we suggest paying
|
||||
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
|
||||
a visit.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<CommunityIcon />
|
||||
</template>
|
||||
<template #heading>Community</template>
|
||||
|
||||
Got stuck? Ask your question on
|
||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
|
||||
(our official Discord server), or
|
||||
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
|
||||
>StackOverflow</a
|
||||
>. You should also follow the official
|
||||
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
|
||||
Bluesky account or the
|
||||
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
|
||||
X account for latest news in the Vue world.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<SupportIcon />
|
||||
</template>
|
||||
<template #heading>Support Vue</template>
|
||||
|
||||
As an independent project, Vue relies on community backing for its sustainability. You can help
|
||||
us by
|
||||
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
|
||||
</WelcomeItem>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="item">
|
||||
<i>
|
||||
<slot name="icon"></slot>
|
||||
</i>
|
||||
<div class="details">
|
||||
<h3>
|
||||
<slot name="heading"></slot>
|
||||
</h3>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.item {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.item {
|
||||
margin-top: 0;
|
||||
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
i {
|
||||
top: calc(50% - 25px);
|
||||
left: -26px;
|
||||
position: absolute;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.item:before {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:after {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:first-of-type:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item:last-of-type:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
||||
<path
|
||||
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
class="iconify iconify--mdi"
|
||||
width="24"
|
||||
height="24"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
|
||||
const navItems = computed(() => {
|
||||
const role = auth.user?.role ?? 'student'
|
||||
if (role === 'teacher') return [
|
||||
{ label: 'Дашборд', icon: '📊', to: '/teacher' },
|
||||
{ label: 'Лекции', icon: '📖', to: '/teacher/lectures' },
|
||||
{ label: 'Аналитика',icon: '📈', to: '/teacher/analytics' },
|
||||
{ label: 'Профиль', icon: '👤', to: '/profile' },
|
||||
]
|
||||
if (role === 'admin') return [
|
||||
{ label: 'Дашборд', icon: '🛡️', to: '/admin' },
|
||||
{ label: 'Юзеры', icon: '👥', to: '/admin/users' },
|
||||
{ label: 'Лекции', icon: '📚', to: '/admin/lectures' },
|
||||
{ label: 'ИИ', icon: '🤖', to: '/admin/llm-queue' },
|
||||
]
|
||||
return [
|
||||
{ label: 'Главная', icon: '🏠', to: '/' },
|
||||
{ label: 'Лекции', icon: '📚', to: '/catalog' },
|
||||
{ label: 'Мои', icon: '📋', to: '/my-lectures' },
|
||||
{ label: 'Профиль', icon: '👤', to: '/profile' },
|
||||
]
|
||||
})
|
||||
|
||||
function isActive(to: string) {
|
||||
if (to === '/') return route.path === '/'
|
||||
return route.path.startsWith(to) && to !== '/'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="bottom-nav">
|
||||
<RouterLink
|
||||
v-for="item in navItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="bottom-nav-item"
|
||||
:class="{ active: isActive(item.to) }"
|
||||
>
|
||||
<span class="bottom-nav-icon">{{ item.icon }}</span>
|
||||
<span class="bottom-nav-label">{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
background: rgba(255,255,255,0.9);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-top: 1px solid var(--color-border-glass);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
z-index: 100;
|
||||
}
|
||||
.bottom-nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.bottom-nav-item.active { color: var(--color-primary-dark); }
|
||||
.bottom-nav-icon { font-size: 20px; }
|
||||
.bottom-nav-label { font-size: 10px; font-weight: 600; }
|
||||
|
||||
@media (max-width: 768px) { .bottom-nav { display: flex; } }
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
interface NavItem { label: string; icon: string; to: string; roles: string[] }
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ label: 'Главная', icon: '🏠', to: '/', roles: ['student'] },
|
||||
{ label: 'Каталог', icon: '📚', to: '/catalog', roles: ['student'] },
|
||||
{ label: 'Мои лекции', icon: '📋', to: '/my-lectures', roles: ['student'] },
|
||||
{ label: 'Достижения', icon: '🏆', to: '/achievements', roles: ['student'] },
|
||||
{ label: 'Уведомления', icon: '🔔', to: '/notifications', roles: ['student'] },
|
||||
{ label: 'Профиль', icon: '👤', to: '/profile', roles: ['student'] },
|
||||
// Teacher
|
||||
{ label: 'Дашборд', icon: '📊', to: '/teacher', roles: ['teacher'] },
|
||||
{ label: 'Лекции', icon: '📖', to: '/teacher/lectures',roles: ['teacher'] },
|
||||
{ label: 'Аналитика', icon: '📈', to: '/teacher/analytics',roles: ['teacher'] },
|
||||
{ label: 'Профиль', icon: '👤', to: '/profile', roles: ['teacher'] },
|
||||
// Admin
|
||||
{ label: 'Дашборд', icon: '🛡️', to: '/admin', roles: ['admin'] },
|
||||
{ label: 'Пользователи',icon: '👥', to: '/admin/users', roles: ['admin'] },
|
||||
{ label: 'Лекции', icon: '📚', to: '/admin/lectures', roles: ['admin'] },
|
||||
{ label: 'ИИ очередь', icon: '🤖', to: '/admin/llm-queue', roles: ['admin'] },
|
||||
]
|
||||
|
||||
const visible = computed(() =>
|
||||
navItems.filter(n => auth.user && n.roles.includes(auth.user.role))
|
||||
)
|
||||
|
||||
function isActive(to: string) {
|
||||
if (to === '/') return route.path === '/'
|
||||
return route.path.startsWith(to) && to !== '/'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<RouterLink
|
||||
v-for="item in visible"
|
||||
:key="item.to + item.label"
|
||||
:to="item.to"
|
||||
class="nav-item"
|
||||
:class="{ active: isActive(item.to) }"
|
||||
>
|
||||
<span class="nav-icon">{{ item.icon }}</span>
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button class="logout-btn" @click="auth.logout(); router.push('/login')">
|
||||
🚪 Выйти
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: var(--topbar-height);
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: var(--sidebar-width);
|
||||
background: rgba(255,255,255,0.75);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-right: 1px solid var(--color-border-glass);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 90;
|
||||
padding: 16px 0;
|
||||
}
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 0 10px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: rgba(34,197,94,0.1);
|
||||
color: var(--color-primary-dark);
|
||||
}
|
||||
.nav-item.active {
|
||||
background: linear-gradient(135deg, rgba(34,197,94,0.18), rgba(134,239,172,0.12));
|
||||
color: var(--color-primary-dark);
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px rgba(34,197,94,0.12);
|
||||
}
|
||||
.nav-icon { font-size: 17px; flex-shrink: 0; }
|
||||
.sidebar-footer { padding: 10px 18px 8px; }
|
||||
.logout-btn {
|
||||
width: 100%;
|
||||
background: rgba(239,68,68,0.08);
|
||||
border: 1px solid rgba(239,68,68,0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #991B1B;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.logout-btn:hover { background: rgba(239,68,68,0.15); }
|
||||
|
||||
@media (max-width: 768px) { .sidebar { display: none; } }
|
||||
</style>
|
||||
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import CoinChip from '@/components/ui/CoinChip.vue'
|
||||
import SearchInput from '@/components/ui/SearchInput.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
const searchQuery = ref('')
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
student: 'Студент',
|
||||
teacher: 'Преподаватель',
|
||||
admin: 'Администратор',
|
||||
}
|
||||
|
||||
const unreadCount = computed(() => userStore.unreadCount())
|
||||
|
||||
function switchRole() {
|
||||
auth.switchRole()
|
||||
if (auth.user?.role === 'teacher') router.push('/teacher')
|
||||
else if (auth.user?.role === 'admin') router.push('/admin')
|
||||
else router.push('/')
|
||||
}
|
||||
|
||||
function openProfile() {
|
||||
router.push('/profile')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<div class="topbar-brand">
|
||||
<span class="brand-icon">🌍</span>
|
||||
<span class="brand-name">UniVerse</span>
|
||||
</div>
|
||||
|
||||
<div class="topbar-center">
|
||||
<SearchInput v-model="searchQuery" placeholder="Поиск лекций, преподавателей, тегов" />
|
||||
</div>
|
||||
|
||||
<div class="topbar-right">
|
||||
<CoinChip v-if="auth.user" :amount="auth.user.coins" />
|
||||
|
||||
<button class="role-btn" @click="switchRole" v-if="auth.user">
|
||||
{{ roleLabels[auth.user.role] }} ↔
|
||||
</button>
|
||||
|
||||
<button class="notif-btn" @click="$router.push('/notifications')">
|
||||
🔔
|
||||
<span class="notif-dot" v-if="auth.user && unreadCount > 0">
|
||||
{{ unreadCount }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="avatar"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="openProfile"
|
||||
@keydown.enter.prevent="openProfile"
|
||||
@keydown.space.prevent="openProfile"
|
||||
>
|
||||
<span class="avatar-icon">👤</span>
|
||||
<span class="avatar-name" v-if="auth.user">{{ auth.user.name.split(' ')[0] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.topbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--topbar-height);
|
||||
background: rgba(255,255,255,0.8);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid var(--color-border-glass);
|
||||
box-shadow: 0 2px 20px rgba(0,0,0,0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
z-index: 100;
|
||||
gap: 12px;
|
||||
}
|
||||
.topbar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.brand-icon { font-size: 24px; }
|
||||
.brand-name {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
background: var(--gradient-brand);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.topbar-center { flex: 1; max-width: 400px; }
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.role-btn {
|
||||
background: rgba(34,197,94,0.12);
|
||||
border: 1px solid rgba(34,197,94,0.3);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary-dark);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.role-btn:hover { background: rgba(34,197,94,0.2); }
|
||||
.notif-btn {
|
||||
position: relative;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.notif-btn:hover { background: rgba(0,0,0,0.05); }
|
||||
.notif-dot {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
background: var(--color-error);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
padding: 5px 10px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--color-border-glass);
|
||||
background: rgba(255,255,255,0.5);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.avatar:hover { background: rgba(255,255,255,0.8); }
|
||||
.avatar-icon { font-size: 18px; }
|
||||
.avatar-name { font-size: 13px; font-weight: 600; color: var(--color-text); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.topbar-center { display: none; }
|
||||
.brand-name { display: none; }
|
||||
.avatar-name { display: none; }
|
||||
.role-btn { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
unlocked?: boolean
|
||||
unlockedAt?: string
|
||||
coins?: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="badge-card" :class="{ locked: !unlocked }">
|
||||
<div class="badge-icon">{{ icon }}</div>
|
||||
<div class="badge-body">
|
||||
<div class="badge-title">{{ title }}</div>
|
||||
<div class="badge-desc">{{ description }}</div>
|
||||
<div class="badge-meta" v-if="unlocked && unlockedAt">
|
||||
✅ Получено {{ new Date(unlockedAt).toLocaleDateString('ru-RU') }}
|
||||
<span v-if="coins" class="coins-tag">+{{ coins }} 💰</span>
|
||||
</div>
|
||||
<div class="badge-meta locked-msg" v-else-if="!unlocked">🔒 Заблокировано</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.badge-card {
|
||||
background: var(--color-white-glass);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--color-border-glass);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
box-shadow: var(--shadow-card);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.badge-card:not(.locked):hover { transform: translateY(-2px); }
|
||||
.locked {
|
||||
opacity: 0.5;
|
||||
filter: grayscale(0.5);
|
||||
}
|
||||
.badge-icon { font-size: 32px; line-height: 1; flex-shrink: 0; }
|
||||
.badge-title { font-size: 15px; font-weight: 700; color: var(--color-text); margin-bottom: 3px; }
|
||||
.badge-desc { font-size: 12px; color: var(--color-text-secondary); margin-bottom: 6px; }
|
||||
.badge-meta { font-size: 11px; color: var(--color-text-secondary); }
|
||||
.locked-msg { color: #9CA3AF; }
|
||||
.coins-tag {
|
||||
margin-left: 6px;
|
||||
background: rgba(251,191,36,0.15);
|
||||
border-radius: 10px;
|
||||
padding: 1px 8px;
|
||||
color: #92400E;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ amount: number }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="coin-chip">
|
||||
<span class="coin-icon">💰</span>
|
||||
<span class="coin-amount">{{ amount }}</span>
|
||||
<span class="coin-label">монет</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.coin-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: linear-gradient(135deg, rgba(251,191,36,0.2), rgba(245,158,11,0.15));
|
||||
border: 1px solid rgba(245,158,11,0.4);
|
||||
border-radius: 20px;
|
||||
padding: 5px 12px;
|
||||
cursor: default;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.coin-chip:hover {
|
||||
background: linear-gradient(135deg, rgba(251,191,36,0.3), rgba(245,158,11,0.25));
|
||||
}
|
||||
.coin-icon { font-size: 16px; }
|
||||
.coin-amount { font-weight: 800; font-size: 14px; color: #78350F; }
|
||||
.coin-label { font-size: 12px; color: #92400E; }
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
columns: Array<{ key: string; label: string; align?: 'left' | 'center' | 'right' | string }>
|
||||
rows: Record<string, any>[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
:class="`align-${col.align ?? 'left'}`"
|
||||
>{{ col.label }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, i) in rows" :key="i">
|
||||
<td
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
:class="`align-${col.align ?? 'left'}`"
|
||||
>
|
||||
<slot :name="col.key" :row="row" :value="row[col.key]">
|
||||
{{ row[col.key] }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
.data-table th {
|
||||
background: rgba(255,255,255,0.6);
|
||||
border-bottom: 2px solid var(--color-border-glass);
|
||||
padding: 10px 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.data-table td {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--color-border-glass);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.data-table tbody tr:last-child td { border-bottom: none; }
|
||||
.data-table tbody tr:hover td {
|
||||
background: rgba(34,197,94,0.05);
|
||||
}
|
||||
.align-left { text-align: left; }
|
||||
.align-center { text-align: center; }
|
||||
.align-right { text-align: right; }
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
icon?: string
|
||||
title?: string
|
||||
subtitle?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">{{ icon ?? '📭' }}</div>
|
||||
<div class="empty-title">{{ title ?? 'Ничего не найдено' }}</div>
|
||||
<div class="empty-sub">{{ subtitle ?? 'Попробуйте изменить фильтры или вернитесь позже.' }}</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.empty-icon { font-size: 52px; }
|
||||
.empty-title { font-size: 18px; font-weight: 700; color: var(--color-text); }
|
||||
.empty-sub { font-size: 14px; max-width: 320px; }
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
filters: Array<{ label: string; value: string; active?: boolean }>
|
||||
}>()
|
||||
const emit = defineEmits<{ toggle: [value: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter-chips">
|
||||
<button
|
||||
v-for="f in props.filters"
|
||||
:key="f.value"
|
||||
class="tag-chip"
|
||||
:class="{ active: f.active }"
|
||||
@click="emit('toggle', f.value)"
|
||||
>
|
||||
{{ f.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { toRefs } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
padding?: string
|
||||
hoverable?: boolean
|
||||
}>(), {
|
||||
padding: '18px',
|
||||
})
|
||||
|
||||
const { padding, hoverable } = toRefs(props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="glass-card" :class="{ hoverable }">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.glass-card {
|
||||
background: var(--color-white-glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--color-border-glass);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-glass);
|
||||
padding: v-bind(padding);
|
||||
}
|
||||
.hoverable {
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hoverable:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,196 @@
|
||||
<script setup lang="ts">
|
||||
import type { Lecture } from '@/types'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps<{
|
||||
lecture: Lecture
|
||||
registered?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ register: [id: string] }>()
|
||||
const router = useRouter()
|
||||
|
||||
function formatDate(d: string) {
|
||||
const date = new Date(d)
|
||||
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
function starsHtml(rating: number) {
|
||||
const full = Math.floor(rating)
|
||||
return '★'.repeat(full) + '☆'.repeat(5 - full)
|
||||
}
|
||||
|
||||
function goDetail() {
|
||||
router.push(`/lecture/${props.lecture.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lecture-card" @click="goDetail">
|
||||
<div class="card-header">
|
||||
<span class="badge" :class="lecture.format === 'online' ? 'badge-blue' : 'badge-green'">
|
||||
{{ lecture.format === 'online' ? '🌐 Онлайн' : '📍 Офлайн' }}
|
||||
</span>
|
||||
<span
|
||||
class="seats"
|
||||
:class="{
|
||||
'seats-low': lecture.freeSeats <= 3 && lecture.freeSeats > 0,
|
||||
'seats-zero': lecture.freeSeats === 0 || lecture.registrationClosed
|
||||
}"
|
||||
>
|
||||
<template v-if="lecture.registrationClosed">Запись закрыта</template>
|
||||
<template v-else>
|
||||
{{ lecture.freeSeats === 0 ? 'Мест нет' : `${lecture.freeSeats}/${lecture.totalSeats} мест` }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="card-title">{{ lecture.title }}</h3>
|
||||
|
||||
<div class="card-teacher">
|
||||
<span>👤</span>
|
||||
<span>{{ lecture.teacher }}</span>
|
||||
<span class="text-secondary">· {{ lecture.institute }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-meta">
|
||||
<span>📅 {{ formatDate(lecture.date) }}</span>
|
||||
<span>⏰ {{ lecture.time }}</span>
|
||||
<span v-if="lecture.room">🏛 {{ lecture.building }}, ауд. {{ lecture.room }}</span>
|
||||
<span v-else>🏛 {{ lecture.building }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-tags">
|
||||
<span class="tag-chip" v-for="tag in lecture.tags.slice(0, 3)" :key="tag">{{ tag }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="rating">
|
||||
<span class="stars">{{ starsHtml(lecture.rating) }}</span>
|
||||
<span class="rating-value">{{ lecture.rating }}</span>
|
||||
<span class="text-secondary text-sm">({{ lecture.reviewCount }})</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="registered"
|
||||
class="btn-registered"
|
||||
disabled
|
||||
@click.stop
|
||||
>
|
||||
✓ Записан
|
||||
</button>
|
||||
<button
|
||||
v-else-if="lecture.freeSeats > 0 && !lecture.registrationClosed"
|
||||
class="btn-primary btn-sm"
|
||||
@click.stop="emit('register', lecture.id)"
|
||||
>
|
||||
Записаться
|
||||
</button>
|
||||
<button v-else class="btn-disabled" disabled @click.stop>
|
||||
{{ lecture.registrationClosed ? 'Запись закрыта' : 'Мест нет' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lecture-card {
|
||||
background: var(--color-white-glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--color-border-glass);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
min-width: 0;
|
||||
}
|
||||
.lecture-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(34,197,94,0.2);
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.seats {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary-dark);
|
||||
}
|
||||
.seats-low { color: #EA580C; }
|
||||
.seats-zero { color: #DC2626; }
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-teacher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.rating-value {
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 7px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.btn-registered {
|
||||
background: rgba(34,197,94,0.12);
|
||||
border: 1px solid rgba(34,197,94,0.3);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-primary-dark);
|
||||
padding: 7px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-disabled {
|
||||
background: rgba(100,116,139,0.1);
|
||||
border: 1px solid rgba(100,116,139,0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
padding: 7px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ size?: 'sm' | 'md' | 'lg' }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="spinner-wrap">
|
||||
<div class="spinner" :class="size ?? 'md'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.spinner-wrap { display: flex; align-items: center; justify-content: center; padding: 24px; }
|
||||
.spinner {
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(34,197,94,0.2);
|
||||
border-top-color: var(--color-primary);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
.sm { width: 20px; height: 20px; }
|
||||
.md { width: 36px; height: 36px; }
|
||||
.lg { width: 56px; height: 56px; border-width: 4px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title?: string
|
||||
modelValue?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [v: boolean] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="modelValue" class="modal-overlay" @click.self="emit('update:modelValue', false)">
|
||||
<div class="modal-box">
|
||||
<div class="modal-header" v-if="title">
|
||||
<span class="modal-title">{{ title }}</span>
|
||||
<button class="modal-close" @click="emit('update:modelValue', false)">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="modal-footer" v-if="$slots.footer">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.35);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
padding: 16px;
|
||||
}
|
||||
.modal-box {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-glass);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,0.2);
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 22px;
|
||||
border-bottom: 1px solid var(--color-border-glass);
|
||||
}
|
||||
.modal-title { font-size: 17px; font-weight: 700; color: var(--color-text); }
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1;
|
||||
}
|
||||
.modal-close:hover { color: var(--color-text); }
|
||||
.modal-body { padding: 20px 22px; }
|
||||
.modal-footer {
|
||||
padding: 14px 22px;
|
||||
border-top: 1px solid var(--color-border-glass);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.modal-enter-active, .modal-leave-active { transition: all 0.25s ease; }
|
||||
.modal-enter-from, .modal-leave-to { opacity: 0; }
|
||||
.modal-enter-from .modal-box, .modal-leave-to .modal-box { transform: scale(0.93); }
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
value: number
|
||||
max?: number
|
||||
label?: string
|
||||
color?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="progress-wrap">
|
||||
<div v-if="label" class="progress-label">{{ label }}</div>
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
:style="{
|
||||
width: `${Math.min(100, (value / (max ?? 100)) * 100)}%`,
|
||||
background: color ?? 'linear-gradient(90deg, #22C55E, #86EFAC)'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div class="progress-text">{{ value }} / {{ max ?? 100 }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.progress-wrap { display: flex; flex-direction: column; gap: 4px; }
|
||||
.progress-label { font-size: 12px; color: var(--color-text-secondary); font-weight: 500; }
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background: rgba(0,0,0,0.08);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.5s ease;
|
||||
position: relative;
|
||||
}
|
||||
.progress-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 50%;
|
||||
background: rgba(255,255,255,0.4);
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
.progress-text { font-size: 11px; color: var(--color-text-secondary); }
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-wrap">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input
|
||||
class="search-input"
|
||||
type="text"
|
||||
:value="props.modelValue"
|
||||
:placeholder="placeholder ?? 'Поиск...'"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
font-size: 15px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 10px 16px 10px 38px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border-glass);
|
||||
background: rgba(255,255,255,0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.search-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(34,197,94,0.15);
|
||||
}
|
||||
.search-input::placeholder { color: var(--color-text-secondary); }
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string
|
||||
value: string | number
|
||||
icon?: string
|
||||
color?: 'green' | 'aqua' | 'orange' | 'purple'
|
||||
sub?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stats-widget" :class="`color-${color ?? 'green'}`">
|
||||
<div class="widget-icon" v-if="icon">{{ icon }}</div>
|
||||
<div class="widget-body">
|
||||
<div class="widget-value">{{ value }}</div>
|
||||
<div class="widget-label">{{ label }}</div>
|
||||
<div class="widget-sub" v-if="sub">{{ sub }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stats-widget {
|
||||
background: var(--color-white-glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--color-border-glass);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stats-widget::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 3px;
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
}
|
||||
.color-green::after { background: linear-gradient(90deg, #22C55E, #86EFAC); }
|
||||
.color-aqua::after { background: linear-gradient(90deg, #06B6D4, #67E8F9); }
|
||||
.color-orange::after { background: linear-gradient(90deg, #FB923C, #FCD34D); }
|
||||
.color-purple::after { background: linear-gradient(90deg, #A78BFA, #C4B5FD); }
|
||||
|
||||
.widget-icon {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.widget-body { flex: 1; min-width: 0; }
|
||||
.widget-value {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: var(--color-text);
|
||||
line-height: 1;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.widget-label {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.widget-sub {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
status: 'pending' | 'active' | 'done' | 'cancelled' | 'approved' | 'rejected' | string
|
||||
}>()
|
||||
|
||||
const statusMap: Record<string, { label: string; cls: string }> = {
|
||||
pending: { label: 'Ожидание', cls: 'warning' },
|
||||
active: { label: 'Активно', cls: 'success' },
|
||||
done: { label: 'Завершено', cls: 'info' },
|
||||
cancelled: { label: 'Отменено', cls: 'danger' },
|
||||
approved: { label: 'Одобрено', cls: 'success' },
|
||||
rejected: { label: 'Отклонено', cls: 'danger' },
|
||||
open: { label: 'Открыта', cls: 'success' },
|
||||
closed: { label: 'Закрыта', cls: 'danger' },
|
||||
full: { label: 'Нет мест', cls: 'warning' },
|
||||
upcoming: { label: 'Будущая', cls: 'info' },
|
||||
ongoing: { label: 'Идет', cls: 'success' },
|
||||
completed: { label: 'Завершена', cls: 'info' },
|
||||
registered:{ label: 'Записан', cls: 'success' },
|
||||
attended: { label: 'Посещено', cls: 'info' },
|
||||
needsReview: { label: 'Нужен отзыв', cls: 'warning' },
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="status-badge" :class="statusMap[status]?.cls ?? 'info'">
|
||||
{{ statusMap[status]?.label ?? status }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.success { background: rgba(220,252,231,0.9); color: #166534; border: 1px solid #86EFAC; }
|
||||
.warning { background: rgba(254,243,199,0.9); color: #92400E; border: 1px solid #FDE68A; }
|
||||
.danger { background: rgba(254,226,226,0.9); color: #991B1B; border: 1px solid #FCA5A5; }
|
||||
.info { background: rgba(224,242,254,0.9); color: #1E40AF; border: 1px solid #93C5FD; }
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
message: string
|
||||
type?: 'success' | 'error' | 'info'
|
||||
duration?: number
|
||||
}>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const visible = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
requestAnimationFrame(() => { visible.value = true })
|
||||
setTimeout(() => { visible.value = false; setTimeout(() => emit('close'), 350) }, props.duration ?? 3000)
|
||||
})
|
||||
|
||||
const iconMap = { success: '✅', error: '❌', info: 'ℹ️' }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="toast">
|
||||
<div v-if="visible" class="toast" :class="type ?? 'success'">
|
||||
<span>{{ iconMap[type ?? 'success'] }}</span>
|
||||
<span>{{ message }}</span>
|
||||
<button class="toast-close" @click="emit('close')">×</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 18px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
|
||||
pointer-events: all;
|
||||
max-width: 340px;
|
||||
}
|
||||
.success { background: rgba(220,252,231,0.95); border: 1px solid #86EFAC; color: #166534; }
|
||||
.error { background: rgba(254,226,226,0.95); border: 1px solid #FCA5A5; color: #991B1B; }
|
||||
.info { background: rgba(224,242,254,0.95); border: 1px solid #93C5FD; color: #1E40AF; }
|
||||
.toast-close { margin-left: auto; background: none; border: none; font-size: 18px; cursor: pointer; opacity: 0.6; }
|
||||
.toast-close:hover { opacity: 1; }
|
||||
.toast-enter-active, .toast-leave-active { transition: all 0.35s ease; }
|
||||
.toast-enter-from { opacity: 0; transform: translateY(20px); }
|
||||
.toast-leave-to { opacity: 0; transform: translateY(20px); }
|
||||
</style>
|
||||
@@ -1,9 +1,14 @@
|
||||
import './assets/main.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{ path: '/login', name: 'login', component: () => import('@/views/auth/LoginView.vue'), meta: { public: true } },
|
||||
|
||||
// Student
|
||||
{ path: '/', name: 'dashboard', component: () => import('@/views/student/DashboardView.vue'), meta: { role: 'student' } },
|
||||
{ path: '/catalog', name: 'catalog', component: () => import('@/views/student/CatalogView.vue'), meta: { role: 'student' } },
|
||||
{ path: '/lecture/:id', name: 'lecture-detail', component: () => import('@/views/student/LectureDetailView.vue'), meta: { role: 'student' } },
|
||||
{ path: '/my-lectures', name: 'my-lectures', component: () => import('@/views/student/MyLecturesView.vue'), meta: { role: 'student' } },
|
||||
{ path: '/review/:id', name: 'review-form', component: () => import('@/views/student/ReviewFormView.vue'), meta: { role: 'student' } },
|
||||
{ path: '/profile', name: 'profile', component: () => import('@/views/student/ProfileView.vue') },
|
||||
{ path: '/achievements', name: 'achievements', component: () => import('@/views/student/AchievementsView.vue'), meta: { role: 'student' } },
|
||||
{ path: '/notifications', name: 'notifications', component: () => import('@/views/student/NotificationsView.vue') },
|
||||
|
||||
// Teacher
|
||||
{ path: '/teacher', name: 'teacher-dashboard', component: () => import('@/views/teacher/TeacherDashboardView.vue'), meta: { role: 'teacher' } },
|
||||
{ path: '/teacher/lectures', name: 'teacher-lectures', component: () => import('@/views/teacher/TeacherLecturesView.vue'), meta: { role: 'teacher' } },
|
||||
{ path: '/teacher/analytics', name: 'teacher-analytics', component: () => import('@/views/teacher/TeacherAnalyticsView.vue'), meta: { role: 'teacher' } },
|
||||
|
||||
// Admin
|
||||
{ path: '/admin', name: 'admin-dashboard', component: () => import('@/views/admin/AdminDashboardView.vue'), meta: { role: 'admin' } },
|
||||
{ path: '/admin/users', name: 'admin-users', component: () => import('@/views/admin/AdminUsersView.vue'), meta: { role: 'admin' } },
|
||||
{ path: '/admin/lectures', name: 'admin-lectures', component: () => import('@/views/admin/AdminLecturesView.vue'), meta: { role: 'admin' } },
|
||||
{ path: '/admin/llm-queue', name: 'admin-llm', component: () => import('@/views/admin/AdminLLMQueueView.vue'), meta: { role: 'admin' } },
|
||||
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const auth = useAuthStore()
|
||||
if (!to.meta.public && !auth.isAuthenticated) {
|
||||
return '/login'
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,90 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { User, UserRole } from '@/types'
|
||||
|
||||
const defaultUsers: Record<UserRole, User> = {
|
||||
student: {
|
||||
id: 'stu-1',
|
||||
name: 'Алексей Морозов',
|
||||
email: 'a.morozov@sfedu.ru',
|
||||
role: 'student',
|
||||
institute: 'ИКТИБ',
|
||||
department: 'Программная инженерия',
|
||||
year: 3,
|
||||
direction: 'Программная инженерия',
|
||||
coins: 340,
|
||||
level: 3,
|
||||
xp: 120,
|
||||
lecturesAttended: 12,
|
||||
hoursLearned: 18.5,
|
||||
achievements: ['1', '2', '3'],
|
||||
},
|
||||
teacher: {
|
||||
id: 't-1',
|
||||
name: 'Михаил Сергеевич Волков',
|
||||
email: 'm.volkov@sfedu.ru',
|
||||
role: 'teacher',
|
||||
institute: 'ИКТИБ',
|
||||
department: 'каф. Информатики',
|
||||
year: 0,
|
||||
direction: 'Информатика и вычислительная техника',
|
||||
coins: 90,
|
||||
level: 4,
|
||||
xp: 240,
|
||||
lecturesAttended: 24,
|
||||
hoursLearned: 56,
|
||||
achievements: ['1', '2', '3', '4'],
|
||||
},
|
||||
admin: {
|
||||
id: 'adm-1',
|
||||
name: 'Виктор Алексеев',
|
||||
email: 'admin@sfedu.ru',
|
||||
role: 'admin',
|
||||
institute: 'ЮФУ',
|
||||
department: 'Администрация',
|
||||
year: 0,
|
||||
direction: 'Цифровое развитие',
|
||||
coins: 0,
|
||||
level: 5,
|
||||
xp: 500,
|
||||
lecturesAttended: 0,
|
||||
hoursLearned: 0,
|
||||
achievements: [],
|
||||
},
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<User | null>(null)
|
||||
const isAuthenticated = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function login(role: UserRole = 'student', shouldFail = false) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
await new Promise(r => setTimeout(r, 800))
|
||||
if (shouldFail) {
|
||||
loading.value = false
|
||||
error.value = 'Не удалось подтвердить доступ через ЮФУ. Попробуйте еще раз.'
|
||||
return false
|
||||
}
|
||||
user.value = { ...defaultUsers[role] }
|
||||
isAuthenticated.value = true
|
||||
loading.value = false
|
||||
return true
|
||||
}
|
||||
|
||||
function logout() {
|
||||
user.value = null
|
||||
isAuthenticated.value = false
|
||||
}
|
||||
|
||||
function switchRole(role?: UserRole) {
|
||||
if (!user.value) return
|
||||
const roles: UserRole[] = ['student', 'teacher', 'admin']
|
||||
const nextRole = (role ?? roles[(roles.indexOf(user.value.role) + 1) % roles.length]) as UserRole
|
||||
user.value = { ...defaultUsers[nextRole] }
|
||||
}
|
||||
|
||||
return { user, isAuthenticated, loading, error, login, logout, switchRole }
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Lecture } from '@/types'
|
||||
|
||||
export const LECTURES: Lecture[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Введение в нейронные сети и глубокое обучение',
|
||||
description: 'Лекция охватывает базовые концепции нейронных сетей: перцептрон, многослойные сети, метод обратного распространения ошибки, а также современные архитектуры — CNN, RNN, Transformer. Рассматриваются практические примеры на Python с использованием PyTorch.',
|
||||
teacher: 'Волков М.С.',
|
||||
teacherTitle: 'Профессор',
|
||||
department: 'каф. Информатики',
|
||||
institute: 'ИКТИБ',
|
||||
date: '2025-05-07',
|
||||
time: '14:00',
|
||||
duration: 90,
|
||||
building: 'ИКТИБ',
|
||||
room: '305',
|
||||
format: 'offline',
|
||||
totalSeats: 30,
|
||||
freeSeats: 12,
|
||||
tags: ['#ML', '#ИИ', '#Python', '#нейросети'],
|
||||
rating: 4.8,
|
||||
reviewCount: 24,
|
||||
status: 'upcoming',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Квантовые вычисления: от теории к практике',
|
||||
description: 'Обзор квантовых алгоритмов и их применения. Рассматриваются кубиты, суперпозиция, запутанность, алгоритмы Шора и Гровера, а также введение в программирование на Qiskit.',
|
||||
teacher: 'Петров А.И.',
|
||||
teacherTitle: 'Доцент',
|
||||
department: 'каф. Теоретической физики',
|
||||
institute: 'ИФиМКН',
|
||||
date: '2025-05-08',
|
||||
time: '16:00',
|
||||
duration: 120,
|
||||
building: 'ИФиМКН',
|
||||
room: '201',
|
||||
format: 'offline',
|
||||
totalSeats: 25,
|
||||
freeSeats: 5,
|
||||
tags: ['#квантовые-вычисления', '#физика', '#алгоритмы'],
|
||||
rating: 4.6,
|
||||
reviewCount: 18,
|
||||
status: 'upcoming',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Современные методы биоинформатики',
|
||||
description: 'Введение в биоинформатику: анализ последовательностей ДНК/РНК, геномная сборка, аннотация генов, инструменты BLAST, Biopython. Актуальные задачи вычислительной биологии.',
|
||||
teacher: 'Смирнова Е.В.',
|
||||
teacherTitle: 'Доктор биологических наук',
|
||||
department: 'каф. Биологии',
|
||||
institute: 'АГиС',
|
||||
date: '2025-05-09',
|
||||
time: '10:00',
|
||||
duration: 90,
|
||||
building: 'АГиС',
|
||||
room: '118',
|
||||
format: 'offline',
|
||||
totalSeats: 20,
|
||||
freeSeats: 2,
|
||||
tags: ['#биоинформатика', '#генетика', '#Python'],
|
||||
rating: 4.7,
|
||||
reviewCount: 31,
|
||||
status: 'upcoming',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Философия цифровой эпохи',
|
||||
description: 'Как цифровые технологии меняют мышление, идентичность и общество. Тема охватывает этику ИИ, постгуманизм, цифровой дуализм и проблему сознания в эпоху автоматизации.',
|
||||
teacher: 'Дмитриев К.О.',
|
||||
teacherTitle: 'Кандидат философских наук',
|
||||
department: 'каф. Философии',
|
||||
institute: 'ИФиСН',
|
||||
date: '2025-05-10',
|
||||
time: '18:00',
|
||||
duration: 90,
|
||||
building: 'Онлайн',
|
||||
format: 'online',
|
||||
totalSeats: 40,
|
||||
freeSeats: 16,
|
||||
tags: ['#философия', '#этика', '#ИИ'],
|
||||
rating: 4.5,
|
||||
reviewCount: 42,
|
||||
status: 'upcoming',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Право в информационном обществе',
|
||||
description: 'Правовые аспекты работы с данными: GDPR, ФЗ-152, авторское право в сети, кибербезопасность с точки зрения права, ответственность разработчиков и операторов персональных данных.',
|
||||
teacher: 'Захарова Н.А.',
|
||||
teacherTitle: 'Доцент',
|
||||
department: 'каф. Гражданского права',
|
||||
institute: 'ЮФ',
|
||||
date: '2025-05-12',
|
||||
time: '15:30',
|
||||
duration: 90,
|
||||
building: 'ЮФ',
|
||||
room: '412',
|
||||
format: 'offline',
|
||||
totalSeats: 30,
|
||||
freeSeats: 0,
|
||||
registrationClosed: true,
|
||||
tags: ['#право', '#данные', '#GDPR'],
|
||||
rating: 4.4,
|
||||
reviewCount: 15,
|
||||
status: 'upcoming',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Нейромаркетинг и поведение потребителей',
|
||||
description: 'Как нейронауки применяются в маркетинге: eye-tracking, EEG-анализ реакций, влияние UX на покупки, нейропсихология принятия решений и кейсы ведущих брендов.',
|
||||
teacher: 'Орлов П.Р.',
|
||||
teacherTitle: 'Кандидат экономических наук',
|
||||
department: 'каф. Маркетинга',
|
||||
institute: 'ИУЭиП',
|
||||
date: '2025-05-14',
|
||||
time: '11:00',
|
||||
duration: 120,
|
||||
building: 'Онлайн',
|
||||
format: 'online',
|
||||
totalSeats: 35,
|
||||
freeSeats: 27,
|
||||
tags: ['#маркетинг', '#нейронауки', '#поведение'],
|
||||
rating: 4.3,
|
||||
reviewCount: 9,
|
||||
status: 'upcoming',
|
||||
},
|
||||
]
|
||||
|
||||
export const useLecturesStore = defineStore('lectures', () => {
|
||||
const lectures = ref<Lecture[]>(LECTURES)
|
||||
const registered = ref<string[]>(['1', '3'])
|
||||
|
||||
const all = computed(() => lectures.value)
|
||||
const registeredIds = computed(() => registered.value)
|
||||
const registeredLectures = computed(() =>
|
||||
lectures.value.filter(l => registered.value.includes(l.id))
|
||||
)
|
||||
|
||||
function register(lectureId: string) {
|
||||
if (!registered.value.includes(lectureId)) {
|
||||
const l = lectures.value.find(x => x.id === lectureId)
|
||||
if (!l || l.freeSeats === 0 || l.registrationClosed) return
|
||||
registered.value.push(lectureId)
|
||||
l.freeSeats--
|
||||
}
|
||||
}
|
||||
|
||||
function unregister(lectureId: string) {
|
||||
registered.value = registered.value.filter(id => id !== lectureId)
|
||||
const l = lectures.value.find(x => x.id === lectureId)
|
||||
if (l) l.freeSeats++
|
||||
}
|
||||
|
||||
function isRegistered(lectureId: string) {
|
||||
return registered.value.includes(lectureId)
|
||||
}
|
||||
|
||||
return { lectures, registered, all, registeredIds, registeredLectures, register, unregister, isRegistered }
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { Achievement, Notification, CoinTransaction } from '@/types'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const achievements = ref<Achievement[]>([
|
||||
{ id: '1', title: 'Первый отзыв', description: 'Оставьте первый отзыв о лекции', icon: '⭐', unlocked: true, unlockedAt: '2025-04-10', coins: 20 },
|
||||
{ id: '2', title: 'Межфакультетский исследователь', description: 'Посетите лекции 3 разных институтов', icon: '🔭', unlocked: true, unlockedAt: '2025-04-18', coins: 50 },
|
||||
{ id: '3', title: '10 часов лекций', description: 'Наберите 10 часов посещённых лекций', icon: '⏱', unlocked: true, unlockedAt: '2025-04-25', coins: 30 },
|
||||
{ id: '4', title: 'Полезный критик', description: 'Получите 5 монет за качественный отзыв', icon: '💡', unlocked: false },
|
||||
{ id: '5', title: 'Знаток науки', description: 'Посетите лекции по 5 разным тематикам', icon: '🎓', unlocked: false },
|
||||
{ id: '6', title: 'Ранний пташка', description: 'Запишитесь на лекцию за 7 дней до начала', icon: '🌅', unlocked: false },
|
||||
{ id: '7', title: 'Социальная бабочка', description: 'Приведите друга на межфакультетскую лекцию', icon: '🦋', unlocked: false },
|
||||
])
|
||||
|
||||
const notifications = ref<Notification[]>([
|
||||
{ id: '1', type: 'reminder', title: 'Напоминание о лекции', body: 'Завтра в 14:00 — «Введение в нейронные сети». Ауд. 305, ИКТИБ', read: false, createdAt: '2025-05-06T09:00:00' },
|
||||
{ id: '2', type: 'coins', title: 'Начислено 20 монет', body: 'Ваш отзыв о лекции «Квантовые вычисления» признан полезным', read: false, createdAt: '2025-05-05T18:30:00' },
|
||||
{ id: '3', type: 'achievement', title: 'Новое достижение!', body: 'Вы получили значок «Межфакультетский исследователь» 🔭', read: false, createdAt: '2025-05-04T12:00:00' },
|
||||
{ id: '4', type: 'recommendation', title: 'Рекомендация для вас', body: 'Новая лекция «Нейромаркетинг» — может быть интересна вам', read: true, createdAt: '2025-05-03T10:00:00' },
|
||||
{ id: '5', type: 'schedule-change', title: 'Изменение расписания', body: 'Лекция «Философия цифровой эпохи» перенесена с 18:00 на 19:00', read: true, createdAt: '2025-05-02T16:00:00' },
|
||||
{ id: '6', type: 'coins', title: 'Начислено 30 монет', body: 'Поздравляем с достижением «10 часов лекций»!', read: true, createdAt: '2025-04-25T11:00:00' },
|
||||
])
|
||||
|
||||
const coinHistory = ref<CoinTransaction[]>([
|
||||
{ id: '1', date: '2025-05-05', description: 'Полезный отзыв о лекции', amount: 20, type: 'earned' },
|
||||
{ id: '2', date: '2025-04-25', description: 'Достижение «10 часов лекций»', amount: 30, type: 'earned' },
|
||||
{ id: '3', date: '2025-04-18', description: 'Достижение «Исследователь»', amount: 50, type: 'earned' },
|
||||
{ id: '4', date: '2025-04-10', description: 'Первый отзыв', amount: 20, type: 'earned' },
|
||||
{ id: '5', date: '2025-04-05', description: 'Покупка стикерпака ЮФУ', amount: -80, type: 'spent' },
|
||||
{ id: '6', date: '2025-03-20', description: 'Посещение серии лекций', amount: 60, type: 'earned' },
|
||||
{ id: '7', date: '2025-03-10', description: 'Покупка термокружки ЮФУ', amount: -120, type: 'spent' },
|
||||
{ id: '8', date: '2025-02-28', description: 'Первое посещение лекции вне факультета', amount: 40, type: 'earned' },
|
||||
])
|
||||
|
||||
function markAllRead() {
|
||||
notifications.value.forEach(n => (n.read = true))
|
||||
}
|
||||
|
||||
const unreadCount = () => notifications.value.filter(n => !n.read).length
|
||||
|
||||
return { achievements, notifications, coinHistory, markAllRead, unreadCount }
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
export type UserRole = 'student' | 'teacher' | 'admin'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: UserRole
|
||||
avatar?: string
|
||||
institute?: string
|
||||
department?: string
|
||||
year?: number
|
||||
direction?: string
|
||||
coins: number
|
||||
level: number
|
||||
xp?: number
|
||||
lecturesAttended?: number
|
||||
hoursLearned?: number
|
||||
achievements?: string[]
|
||||
}
|
||||
|
||||
export interface Lecture {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
teacher: string
|
||||
teacherTitle?: string
|
||||
department?: string
|
||||
institute: string
|
||||
date: string
|
||||
time: string
|
||||
duration: number
|
||||
building: string
|
||||
room?: string
|
||||
format: 'online' | 'offline'
|
||||
totalSeats: number
|
||||
freeSeats: number
|
||||
registrationClosed?: boolean
|
||||
tags: string[]
|
||||
rating: number
|
||||
reviewCount: number
|
||||
status?: 'upcoming' | 'ongoing' | 'completed'
|
||||
registered?: boolean
|
||||
}
|
||||
|
||||
export interface Review {
|
||||
id: string
|
||||
lectureId: string
|
||||
userId: string
|
||||
userName: string
|
||||
text: string
|
||||
sentiment: 'positive' | 'neutral' | 'negative'
|
||||
coins?: number
|
||||
createdAt: string
|
||||
status: 'pending' | 'analyzing' | 'done' | 'rejected'
|
||||
quality?: number
|
||||
}
|
||||
|
||||
export interface Achievement {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
unlocked: boolean
|
||||
unlockedAt?: string
|
||||
coins?: number
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: string
|
||||
type: 'reminder' | 'schedule-change' | 'achievement' | 'coins' | 'recommendation'
|
||||
title: string
|
||||
body: string
|
||||
read: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface CoinTransaction {
|
||||
id: string
|
||||
date: string
|
||||
description: string
|
||||
amount: number
|
||||
type: 'earned' | 'spent'
|
||||
}
|
||||
@@ -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