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

This commit is contained in:
2026-05-25 02:06:11 +03:00
parent 24df65a13c
commit 98aaa86ec4
43 changed files with 1947 additions and 657 deletions
@@ -503,8 +503,8 @@ onMounted(() => {
{{ syncError || visibleSyncResult.error }}
</template>
<template v-else>
Создано: {{ visibleSyncResult.created }} / обновлено:
{{ visibleSyncResult.updated }} / пропущено: {{ visibleSyncResult.skipped }}
Создано: {{ visibleSyncResult.created }} / обновлено: {{ visibleSyncResult.updated }} /
пропущено: {{ visibleSyncResult.skipped }}
</template>
</div>
<div v-else-if="syncError" class="sync-result failed">
@@ -694,7 +694,9 @@ onMounted(() => {
border-radius: var(--radius-sm);
padding: 12px;
background: var(--color-white-a82);
box-shadow: 0 8px 22px var(--color-black-a04), inset 0 1px 0 var(--color-white-a90);
box-shadow:
0 8px 22px var(--color-black-a04),
inset 0 1px 0 var(--color-white-a90);
}
.sync-advanced-fields .glass-input {
background: var(--color-white-a96);
@@ -359,7 +359,9 @@ onMounted(() => {
></textarea>
<div class="prompt-footer">
<div class="prompt-messages">
<span class="prompt-hint">Обязательные плейсхолдеры: {lectureContext}, {reviewText}</span>
<span class="prompt-hint"
>Обязательные плейсхолдеры: {lectureContext}, {reviewText}</span
>
<span v-if="promptError" class="prompt-error">{{ promptError }}</span>
<span v-else-if="promptSuccess" class="prompt-success">{{ promptSuccess }}</span>
</div>
+70 -17
View File
@@ -33,11 +33,11 @@ const roleBadgeClasses: Record<ApiUserRole, string> = {
}
const rows = computed(() =>
users.value.map(user => ({
users.value.map((user) => ({
id: user.id,
name: user.displayName || user.email,
email: user.email,
role: user.roles.map(role => roleLabels[role]).join(', '),
role: user.roles.map((role) => roleLabels[role]).join(', '),
apiRoles: user.roles,
institute: 'ЮФУ',
activity: user.isActive ? 'Активен' : 'Заблокирован',
@@ -52,7 +52,10 @@ async function fetchUsers() {
try {
users.value = await usersApi.list({
Search: search.value || undefined,
Role: roleFilter.value === 'Все роли' ? undefined : roleApi[roleFilter.value as keyof typeof roleApi],
Role:
roleFilter.value === 'Все роли'
? undefined
: roleApi[roleFilter.value as keyof typeof roleApi],
PageSize: 100,
})
} catch (err) {
@@ -81,14 +84,16 @@ function hasRole(row: Record<string, unknown>, role: ApiUserRole) {
}
function getRoleChipClass(row: Record<string, unknown>, role: ApiUserRole) {
return hasRole(row, role) ? ['badge', roleBadgeClasses[role]] : ['btn-ghost', 'role-chip-inactive']
return hasRole(row, role)
? ['badge', roleBadgeClasses[role]]
: ['btn-ghost', 'role-chip-inactive']
}
async function toggleRole(row: Record<string, unknown>, role: ApiUserRole) {
const id = Number(row.id)
const currentRoles = getRowRoles(row)
const nextRoles = currentRoles.includes(role)
? currentRoles.filter(currentRole => currentRole !== role)
? currentRoles.filter((currentRole) => currentRole !== role)
: [...currentRoles, role]
if (!nextRoles.length) return
@@ -125,7 +130,11 @@ onMounted(fetchUsers)
</div>
<EmptyState v-if="error" title="Не удалось загрузить пользователей" :subtitle="error" />
<EmptyState v-else-if="!rows.length && !loading" title="Пользователей не найдено" subtitle="Попробуйте изменить фильтры." />
<EmptyState
v-else-if="!rows.length && !loading"
title="Пользователей не найдено"
subtitle="Попробуйте изменить фильтры."
/>
<DataTable :columns="columns" :rows="rows">
<template #role="{ row }">
<div class="role-chips">
@@ -142,11 +151,15 @@ onMounted(fetchUsers)
</div>
</template>
<template #activity="{ value }">
<span class="badge" :class="value === 'Активен' ? 'badge-green' : 'badge-orange'">{{ value }}</span>
<span class="badge" :class="value === 'Активен' ? 'badge-green' : 'badge-orange'">{{
value
}}</span>
</template>
<template #actions="{ row }">
<div class="actions">
<button class="btn-ghost" @click="toggleActive(row)">{{ row.isActive ? 'Заблокировать' : 'Активировать' }}</button>
<button class="btn-ghost" @click="toggleActive(row)">
{{ row.isActive ? 'Заблокировать' : 'Активировать' }}
</button>
</div>
</template>
</DataTable>
@@ -155,13 +168,53 @@ onMounted(fetchUsers)
</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; }
.role-chips { display: flex; gap: 6px; justify-content: center; flex-wrap: wrap; }
.role-chip { cursor: pointer; border: 0; transition: transform 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease; }
.role-chip:hover { transform: translateY(-1px); box-shadow: 0 4px 12px var(--color-primary-a15); }
.role-chip:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
.role-chip-inactive { opacity: 0.75; }
.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;
}
.role-chips {
display: flex;
gap: 6px;
justify-content: center;
flex-wrap: wrap;
}
.role-chip {
cursor: pointer;
border: 0;
transition:
transform 0.15s ease,
box-shadow 0.15s ease,
opacity 0.15s ease;
}
.role-chip:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--color-primary-a15);
}
.role-chip:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.role-chip-inactive {
opacity: 0.75;
}
</style>
+19 -7
View File
@@ -11,7 +11,8 @@ const message = ref('Завершаем вход через Microsoft...')
onMounted(async () => {
const code = typeof route.query.code === 'string' ? route.query.code : ''
const state = typeof route.query.state === 'string' ? route.query.state : null
const error = typeof route.query.error_description === 'string' ? route.query.error_description : ''
const error =
typeof route.query.error_description === 'string' ? route.query.error_description : ''
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''))
const accessToken = hashParams.get('access_token')
@@ -28,7 +29,10 @@ onMounted(async () => {
else await router.replace('/')
} catch (err) {
message.value = err instanceof Error ? err.message : 'Не удалось завершить авторизацию.'
window.setTimeout(() => router.replace({ path: '/login', query: { error: message.value } }), 1600)
window.setTimeout(
() => router.replace({ path: '/login', query: { error: message.value } }),
1600,
)
}
})
</script>
@@ -54,14 +58,22 @@ onMounted(async () => {
}
.callback-card {
width: min(420px, 100%);
background: rgba(255,255,255,0.86);
background: rgba(255, 255, 255, 0.86);
border: 1px solid var(--color-border-glass);
border-radius: var(--radius-lg);
padding: 32px;
text-align: center;
box-shadow: 0 24px 70px rgba(0,0,0,0.12);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.12);
}
.spinner {
margin: 0 auto 16px;
}
h1 {
font-size: 24px;
margin: 0 0 8px;
}
p {
color: var(--color-text-secondary);
margin: 0;
}
.spinner { margin: 0 auto 16px; }
h1 { font-size: 24px; margin: 0 0 8px; }
p { color: var(--color-text-secondary); margin: 0; }
</style>
+15 -17
View File
@@ -17,22 +17,19 @@ const featureCards = [
icon: 'search' as const,
tone: 'blue',
title: 'Поиск лекций',
description:
'Найдите самые интересные курсы и открытые лекции от ведущих преподавателей ЮФУ.',
description: 'Найдите самые интересные курсы и открытые лекции от ведущих преподавателей ЮФУ.',
},
{
icon: 'coin' as const,
tone: 'green',
title: 'Зарабатывайте монеты',
description:
'Оставляйте конструктивные отзывы после занятий и получайте вознаграждение.',
description: 'Оставляйте конструктивные отзывы после занятий и получайте вознаграждение.',
},
{
icon: 'trophy' as const,
tone: 'amber',
title: 'Достижения и награды',
description:
'Отслеживайте свой рост и открывайте уникальные достижения за активность.',
description: 'Отслеживайте свой рост и открывайте уникальные достижения за активность.',
},
]
@@ -85,11 +82,13 @@ async function loginViaYufu() {
</section>
<section class="login-brand" aria-label="О платформе UniVerse">
<ul class="feature-list">
<li v-for="(card, index) in featureCards" :key="card.title" class="feature-card"
:style="{ '--stagger': `${index * 80}ms` }">
<li
v-for="(card, index) in featureCards"
:key="card.title"
class="feature-card"
:style="{ '--stagger': `${index * 80}ms` }"
>
<div class="feature-icon" :class="`feature-icon--${card.tone}`" aria-hidden="true">
<AppIcon :icon="card.icon" :size="22" />
</div>
@@ -100,13 +99,10 @@ async function loginViaYufu() {
</li>
</ul>
</section>
</main>
<footer class="login-footer">
<div class="footer-left">
</div>
<div class="footer-left"></div>
<nav class="footer-center" aria-label="Правовая информация">
<a href="#">Политика конфиденциальности</a>
<a href="#">Техническая поддержка</a>
@@ -142,11 +138,13 @@ async function loginViaYufu() {
position: absolute;
inset: 0;
background:
linear-gradient(115deg,
linear-gradient(
115deg,
rgba(224, 242, 254, 0.92) 0%,
rgba(220, 252, 231, 0.78) 38%,
rgba(255, 255, 255, 0.55) 62%,
rgba(224, 242, 254, 0.88) 100%),
rgba(224, 242, 254, 0.88) 100%
),
linear-gradient(to top, rgba(255, 255, 255, 0.35) 0%, transparent 42%);
pointer-events: none;
}
@@ -178,7 +176,7 @@ async function loginViaYufu() {
z-index: 1;
flex: 1;
display: grid;
grid-template-columns: minmax(340px, 440px) minmax(340px, 1fr) ;
grid-template-columns: minmax(340px, 440px) minmax(340px, 1fr);
gap: clamp(32px, 2vw, 72px);
align-content: center;
align-items: stretch;
+173 -47
View File
@@ -21,7 +21,9 @@ const format = ref<'all' | 'online' | 'offline'>('all')
const onlyFree = ref(false)
const filtersOpen = ref(false)
const enrollmentLimitModalOpen = ref(false)
const addToast = inject('addToast') as ((message: string, type?: 'success' | 'error' | 'info') => void) | undefined
const addToast = inject('addToast') as
| ((message: string, type?: 'success' | 'error' | 'info') => void)
| undefined
onMounted(() => {
if (!lecturesStore.all.length) void lecturesStore.fetchLectures()
@@ -48,28 +50,44 @@ const directions = [
'Экономика и маркетинг',
]
const teachers = computed(() => ['Все преподаватели', ...new Set(lecturesStore.all.map(l => l.teacher))])
const buildings = computed(() => ['Все корпуса', ...new Set(lecturesStore.all.map(l => l.building))])
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)
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 activeTags = computed(() => tagFilters.value.filter((t) => t.active).map((t) => t.value))
const filtered = computed(() =>
lecturesStore.all.filter(l => {
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 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 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
})
return (
matchesSearch &&
matchesDirection &&
matchesTeacher &&
matchesBuilding &&
matchesFormat &&
matchesTags &&
matchesFree
)
}),
)
const appliedFilters = computed(() => {
@@ -95,7 +113,7 @@ const tableColumns = [
const calendarGroups = computed(() => {
const groups: Record<string, typeof filtered.value> = {}
filtered.value.forEach(l => {
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)
@@ -130,7 +148,9 @@ function isRegistered(id: string) {
<div class="catalog-header">
<div>
<h1 class="page-title">Каталог открытых лекций</h1>
<p class="text-secondary">Выберите лекцию, фильтруйте по направлениям и регистрируйтесь в один клик.</p>
<p class="text-secondary">
Выберите лекцию, фильтруйте по направлениям и регистрируйтесь в один клик.
</p>
</div>
<div class="header-actions">
<SearchInput v-model="search" placeholder="Поиск по теме лекции" />
@@ -171,8 +191,12 @@ function isRegistered(id: string) {
<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>
<button :class="{ active: format === 'offline' }" @click="format = 'offline'">
Офлайн
</button>
<button :class="{ active: format === 'online' }" @click="format = 'online'">
Онлайн
</button>
</div>
</div>
<div>
@@ -191,9 +215,13 @@ function isRegistered(id: string) {
<div class="view-row">
<div class="segmented">
<button :class="{ active: viewMode === 'cards' }" @click="viewMode = 'cards'">Карточки</button>
<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>
<button :class="{ active: viewMode === 'calendar' }" @click="viewMode = 'calendar'">
Календарь
</button>
</div>
<div class="applied" v-if="appliedFilters.length">
<span class="text-secondary">Фильтры:</span>
@@ -210,7 +238,10 @@ function isRegistered(id: string) {
</div>
<div v-else-if="filtered.length === 0">
<EmptyState title="Нет результатов" subtitle="Попробуйте изменить фильтры или сбросить поиск." />
<EmptyState
title="Нет результатов"
subtitle="Попробуйте изменить фильтры или сбросить поиск."
/>
</div>
<div v-else-if="viewMode === 'cards'" class="cards-grid">
@@ -239,7 +270,9 @@ function isRegistered(id: string) {
</template>
<template #seats="{ row }">
<span :class="row.freeSeats === 0 ? 'badge badge-gray' : 'badge badge-green'">
{{ row.registrationClosed ? 'Запись закрыта' : `${row.enrolledSeats}/${row.totalSeats}` }}
{{
row.registrationClosed ? 'Запись закрыта' : `${row.enrolledSeats}/${row.totalSeats}`
}}
</span>
</template>
<template #action="{ row }">
@@ -256,12 +289,14 @@ function isRegistered(id: string) {
</div>
<div v-else class="calendar-view">
<GlassCard v-for="([date, items]) in calendarGroups" :key="date" class="calendar-day">
<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>
<div class="calendar-meta">
{{ l.time }} {{ l.building }} {{ l.room ? `ауд. ${l.room}` : '' }}
</div>
<button class="btn-secondary btn-sm">Подробнее</button>
</div>
</div>
@@ -292,8 +327,12 @@ function isRegistered(id: string) {
<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>
<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" />
@@ -313,7 +352,11 @@ function isRegistered(id: string) {
</template>
<style scoped>
.catalog { display: flex; flex-direction: column; gap: 20px; }
.catalog {
display: flex;
flex-direction: column;
gap: 20px;
}
.catalog-header {
display: flex;
align-items: center;
@@ -321,14 +364,24 @@ function isRegistered(id: string) {
gap: 16px;
flex-wrap: wrap;
}
.header-actions { display: flex; gap: 12px; align-items: center; flex: 1; justify-content: flex-end; }
.filters-btn { display: none; }
.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;
}
.filters-grid > * { min-width: 0; }
.filters-grid > * {
min-width: 0;
}
.filter-label {
font-size: 12px;
font-weight: 600;
@@ -343,7 +396,7 @@ function isRegistered(id: string) {
overflow: hidden;
}
.segmented button {
background: rgba(255,255,255,0.7);
background: rgba(255, 255, 255, 0.7);
border: none;
padding: 8px 14px;
font-size: 13px;
@@ -351,35 +404,108 @@ function isRegistered(id: string) {
color: var(--color-text-secondary);
}
.segmented button.active {
background: rgba(34,197,94,0.15);
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; min-width: 0; }
.applied .tag-chip { max-width: 100%; min-width: 0; white-space: normal; overflow-wrap: anywhere; word-break: break-word; line-height: 1.25; }
.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;
min-width: 0;
}
.applied .tag-chip {
max-width: 100%;
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.25;
}
.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; min-width: 0; }
.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;
min-width: 0;
}
@media (max-width: 768px) {
.filters-grid { display: none; }
.filters-btn { display: inline-flex; }
.header-actions { width: 100%; justify-content: space-between; }
.filters-grid {
display: none;
}
.filters-btn {
display: inline-flex;
}
.header-actions {
width: 100%;
justify-content: space-between;
}
}
</style>
+139 -36
View File
@@ -18,7 +18,9 @@ const lectures = useLecturesStore()
const userStore = useUserStore()
const router = useRouter()
const enrollmentLimitModalOpen = ref(false)
const addToast = inject('addToast') as ((message: string, type?: 'success' | 'error' | 'info') => void) | undefined
const addToast = inject('addToast') as
| ((message: string, type?: 'success' | 'error' | 'info') => void)
| undefined
const user = computed(() => auth.user!)
@@ -26,21 +28,26 @@ const userMetaLine = computed(() => {
const parts: string[] = []
if (user.value.institute) parts.push(user.value.institute)
if (user.value.direction) parts.push(user.value.direction)
if (Number.isFinite(user.value.year) && (user.value.year as number) > 0) parts.push(`${user.value.year} курс`)
if (Number.isFinite(user.value.year) && (user.value.year as number) > 0)
parts.push(`${user.value.year} курс`)
return parts.join(' · ')
})
const nextLecture = computed(() => lectures.registeredLectures[0] ?? lectures.all[0])
const recommended = computed(() =>
lectures.all.filter(l => !lectures.registeredIds.includes(l.id)).slice(0, 3)
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 achievements = computed(() => userStore.achievements.filter((a) => a.unlocked).slice(0, 3))
const reminders = computed(() => userStore.notifications.slice(0, 3))
const currentLevelXp = computed(() => user.value.currentLevelXp ?? 0)
const nextLevelXp = computed(() => user.value.nextLevelXp)
const userXp = computed(() => user.value.xp ?? 0)
const hasLevelProgress = computed(() => nextLevelXp.value !== undefined)
const hasNextLevel = computed(() => typeof nextLevelXp.value === 'number' && nextLevelXp.value > currentLevelXp.value)
const levelProgressMax = computed(() => hasNextLevel.value ? nextLevelXp.value! - currentLevelXp.value : 1)
const hasNextLevel = computed(
() => typeof nextLevelXp.value === 'number' && nextLevelXp.value > currentLevelXp.value,
)
const levelProgressMax = computed(() =>
hasNextLevel.value ? nextLevelXp.value! - currentLevelXp.value : 1,
)
const levelProgress = computed(() => {
if (!hasLevelProgress.value) return 0
if (!hasNextLevel.value) return 1
@@ -49,10 +56,14 @@ const levelProgress = computed(() => {
const levelProgressLabel = computed(() =>
!hasLevelProgress.value
? `Уровень ${user.value.level}`
: hasNextLevel.value ? `Прогресс до уровня ${user.value.level + 1}` : 'Максимальный уровень'
: hasNextLevel.value
? `Прогресс до уровня ${user.value.level + 1}`
: 'Максимальный уровень',
)
const levelProgressText = computed(() =>
hasNextLevel.value ? `${levelProgress.value} / ${levelProgressMax.value} XP` : `${userXp.value} XP`
hasNextLevel.value
? `${levelProgress.value} / ${levelProgressMax.value} XP`
: `${userXp.value} XP`,
)
onMounted(async () => {
@@ -81,9 +92,7 @@ async function registerLecture(id: string) {
<div class="dashboard page-content">
<div class="dashboard-welcome">
<div>
<h1 class="page-title">
Добрый день, {{ formatUserName(user.name) }}!
</h1>
<h1 class="page-title">Добрый день, {{ formatUserName(user.name) }}!</h1>
<p v-if="userMetaLine" class="text-secondary">{{ userMetaLine }}</p>
</div>
<div class="quick-actions">
@@ -113,12 +122,18 @@ async function registerLecture(id: string) {
</div>
</div>
<div class="next-actions">
<button class="btn-primary" @click="router.push(`/lecture/${nextLecture.id}`)">Открыть</button>
<button class="btn-primary" @click="router.push(`/lecture/${nextLecture.id}`)">
Открыть
</button>
<button class="btn-secondary">Добавить в календарь</button>
</div>
</div>
</GlassCard>
<EmptyState v-else-if="!lectures.loading" title="Пока нет лекций" subtitle="Каталог пуст или данные ещё не синхронизированы." />
<EmptyState
v-else-if="!lectures.loading"
title="Пока нет лекций"
subtitle="Каталог пуст или данные ещё не синхронизированы."
/>
<GlassCard>
<div class="xp-section">
@@ -140,7 +155,11 @@ async function registerLecture(id: string) {
</h2>
<button class="link-btn" @click="router.push('/catalog')">Все лекции </button>
</div>
<EmptyState v-if="lectures.loading" title="Загружаем рекомендации" subtitle="Получаем данные с backend." />
<EmptyState
v-if="lectures.loading"
title="Загружаем рекомендации"
subtitle="Получаем данные с backend."
/>
<div v-else class="cards-grid">
<LectureCard
v-for="l in recommended"
@@ -195,7 +214,11 @@ async function registerLecture(id: string) {
</template>
<style scoped>
.dashboard { display: flex; flex-direction: column; gap: 24px; }
.dashboard {
display: flex;
flex-direction: column;
gap: 24px;
}
.dashboard-welcome {
display: flex;
align-items: flex-start;
@@ -203,25 +226,78 @@ async function registerLecture(id: string) {
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; }
.meta-line { display: inline-flex; align-items: center; gap: 6px; }
.meta-icon { color: var(--color-text-secondary); }
.next-actions { display: flex; gap: 10px; align-items: flex-start; }
.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;
}
.meta-line {
display: inline-flex;
align-items: center;
gap: 6px;
}
.meta-icon {
color: var(--color-text-secondary);
}
.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; }
.title-with-icon { display: inline-flex; align-items: center; gap: 8px; }
.title-icon { color: var(--color-text); }
.inline-icon { color: var(--color-text); vertical-align: middle; }
.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;
}
.title-with-icon {
display: inline-flex;
align-items: center;
gap: 8px;
}
.title-icon {
color: var(--color-text);
}
.inline-icon {
color: var(--color-text);
vertical-align: middle;
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
@@ -236,15 +312,42 @@ async function registerLecture(id: string) {
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; }
.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; }
.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>
@@ -13,16 +13,24 @@ const route = useRoute()
const router = useRouter()
const lecturesStore = useLecturesStore()
const userStore = useUserStore()
const addToast = inject('addToast') as ((message: string, type?: 'success' | 'error' | 'info') => void) | undefined
const addToast = inject('addToast') as
| ((message: string, type?: 'success' | 'error' | 'info') => void)
| undefined
const enrollmentLimitModalOpen = ref(false)
const lectureId = computed(() => String(route.params.id))
const lecture = computed(() => lecturesStore.all.find(l => l.id === lectureId.value))
const isRegistered = computed(() => (lecture.value ? lecturesStore.isRegistered(lecture.value.id) : false))
const slotRegistrationDisabled = computed(() => !userStore.hasEnrollmentSlotAvailable && !isRegistered.value)
const lecture = computed(() => lecturesStore.all.find((l) => l.id === lectureId.value))
const isRegistered = computed(() =>
lecture.value ? lecturesStore.isRegistered(lecture.value.id) : false,
)
const slotRegistrationDisabled = computed(
() => !userStore.hasEnrollmentSlotAvailable && !isRegistered.value,
)
const isAttended = computed(() => lecture.value?.status === 'completed')
const similarLectures = computed(() => lecturesStore.all.filter(l => l.id !== lectureId.value).slice(0, 3))
const similarLectures = computed(() =>
lecturesStore.all.filter((l) => l.id !== lectureId.value).slice(0, 3),
)
onMounted(async () => {
if (!lecturesStore.all.length) await lecturesStore.fetchLectures()
@@ -57,7 +65,10 @@ async function registerLecture() {
</div>
<div v-else-if="!lecture" class="lecture-detail page-content">
<EmptyState title="Лекция не найдена" :subtitle="lecturesStore.error ?? 'Попробуйте открыть каталог и выбрать лекцию заново.'" />
<EmptyState
title="Лекция не найдена"
:subtitle="lecturesStore.error ?? 'Попробуйте открыть каталог и выбрать лекцию заново.'"
/>
</div>
<div v-else class="lecture-detail page-content">
@@ -76,9 +87,13 @@ async function registerLecture() {
>
Записаться
</button>
<button v-else class="btn-secondary" @click="lecturesStore.unregister(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>
<button v-if="isAttended" class="btn-primary" @click="router.push(`/review/${lecture.id}`)">
Оставить отзыв
</button>
</div>
</div>
@@ -86,19 +101,34 @@ async function registerLecture() {
<GlassCard>
<div class="info-section">
<h3>Преподаватель</h3>
<div class="info-value">{{ lecture.teacher }}<span v-if="lecture.teacherTitle"> {{ lecture.teacherTitle }}</span></div>
<div class="info-sub">{{ [lecture.department, lecture.institute].filter(Boolean).join(', ') }}</div>
<div class="info-value">
{{ lecture.teacher
}}<span v-if="lecture.teacherTitle"> {{ lecture.teacherTitle }}</span>
</div>
<div class="info-sub">
{{ [lecture.department, lecture.institute].filter(Boolean).join(', ') }}
</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-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 class="info-sub">
Локация: {{ lecture.building }} {{ lecture.room ? `ауд. ${lecture.room}` : '' }}
</div>
</div>
<div class="info-section">
<h3>Места</h3>
<div class="info-value">Записано {{ lecture.enrolledSeats }} из {{ lecture.totalSeats }}</div>
<StatusBadge :status="lecture.registrationClosed ? 'closed' : lecture.freeSeats === 0 ? 'full' : 'open'" />
<div class="info-value">
Записано {{ lecture.enrolledSeats }} из {{ lecture.totalSeats }}
</div>
<StatusBadge
:status="
lecture.registrationClosed ? 'closed' : lecture.freeSeats === 0 ? 'full' : 'open'
"
/>
</div>
<div class="info-section">
<h3>Теги</h3>
@@ -121,8 +151,16 @@ async function registerLecture() {
</template>
<style scoped>
.lecture-detail { display: flex; flex-direction: column; gap: 24px; }
.breadcrumb { font-size: 12px; color: var(--color-text-secondary); margin-bottom: 6px; }
.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;
@@ -130,14 +168,39 @@ async function registerLecture() {
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; }
.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;
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+93 -24
View File
@@ -16,10 +16,10 @@ const cancelModal = ref(false)
const selectedId = ref<string | null>(null)
const upcoming = computed(() =>
lecturesStore.registeredLectures.map(l => ({ ...l, status: 'registered' }))
lecturesStore.registeredLectures.map((l) => ({ ...l, status: 'registered' })),
)
const history = computed(() => lecturesStore.all.filter(l => l.status === 'completed'))
const history = computed(() => lecturesStore.all.filter((l) => l.status === 'completed'))
onMounted(async () => {
if (!lecturesStore.all.length) await lecturesStore.fetchLectures()
@@ -42,23 +42,37 @@ async function confirmCancel() {
<div class="header">
<div>
<h1 class="page-title">Мои записи</h1>
<p class="text-secondary">Управляйте регистрациями, экспортируйте расписание и оставляйте отзывы.</p>
<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>
<button :class="{ active: activeTab === 'upcoming' }" @click="activeTab = 'upcoming'">
Предстоящие
</button>
<button :class="{ active: activeTab === 'history' }" @click="activeTab = 'history'">
История
</button>
</div>
<div v-if="activeTab === 'upcoming'" class="list">
<EmptyState v-if="!upcoming.length" title="У вас нет предстоящих лекций" subtitle="Выберите лекцию в каталоге и запишитесь на неё." />
<EmptyState
v-if="!upcoming.length"
title="У вас нет предстоящих лекций"
subtitle="Выберите лекцию в каталоге и запишитесь на неё."
/>
<GlassCard v-for="item in upcoming" :key="item.id" class="lecture-row">
<div>
<div class="lecture-title">{{ item.title }}</div>
<div class="lecture-meta">{{ new Date(item.date).toLocaleDateString('ru-RU') }} {{ item.time }}</div>
<div class="lecture-meta">{{ item.building }} {{ item.room ? `ауд. ${item.room}` : '' }}</div>
<div 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" />
@@ -69,12 +83,20 @@ async function confirmCancel() {
</div>
<div v-else class="list">
<EmptyState v-if="!history.length" title="История пока пуста" subtitle="Завершённые лекции появятся здесь после посещения." />
<EmptyState
v-if="!history.length"
title="История пока пуста"
subtitle="Завершённые лекции появятся здесь после посещения."
/>
<GlassCard v-for="item in history" :key="item.id" class="lecture-row">
<div>
<div class="lecture-title">{{ item.title }}</div>
<div class="lecture-meta">{{ new Date(item.date).toLocaleDateString('ru-RU') }} {{ item.time }}</div>
<div class="lecture-meta">{{ item.building }} {{ item.room ? `ауд. ${item.room}` : '' }}</div>
<div class="lecture-meta">
{{ new Date(item.date).toLocaleDateString('ru-RU') }} {{ item.time }}
</div>
<div class="lecture-meta">
{{ item.building }} {{ item.room ? `ауд. ${item.room}` : '' }}
</div>
</div>
<div class="lecture-actions">
<StatusBadge :status="item.status ?? 'completed'" />
@@ -86,7 +108,10 @@ async function confirmCancel() {
</div>
<ModalDialog v-model="cancelModal" title="Отменить запись?" icon="alert-triangle" size="sm">
<p>Вы уверены, что хотите отменить запись на лекцию? Место будет освобождено для других студентов.</p>
<p>
Вы уверены, что хотите отменить запись на лекцию? Место будет освобождено для других
студентов.
</p>
<template #footer>
<button class="btn-secondary" type="button" @click="cancelModal = false">Нет</button>
<button class="btn-danger" type="button" @click="confirmCancel">Да, отменить</button>
@@ -96,24 +121,68 @@ async function confirmCancel() {
</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; width: fit-content; border: 1px solid var(--color-border-glass); border-radius: 12px; overflow: hidden; }
.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;
width: fit-content;
border: 1px solid var(--color-border-glass);
border-radius: 12px;
overflow: hidden;
}
.tabs button {
background: rgba(255,255,255,0.7);
background: rgba(255, 255, 255, 0.7);
border: none;
padding: 8px 18px;
font-size: 13px;
min-width: 110px;
text-align: center;
text-align: center;
cursor: pointer;
color: var(--color-text-secondary);
}
.tabs button.active { background: rgba(34,197,94,0.18); color: var(--color-primary-dark); font-weight: 600; }
.list { display: flex; flex-direction: column; gap: 12px; }
.lecture-row { display: flex; justify-content: space-between; gap: 12px; align-items: center; flex-wrap: wrap; }
.lecture-title { font-weight: 700; margin-bottom: 4px; }
.lecture-meta { font-size: 13px; color: var(--color-text-secondary); }
.lecture-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.btn-sm { padding: 6px 12px; font-size: 12px; }
.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>
@@ -13,7 +13,7 @@ onMounted(() => {
const grouped = computed(() => {
const map: Record<string, typeof userStore.notifications> = {}
userStore.notifications.forEach(n => {
userStore.notifications.forEach((n) => {
const day = new Date(n.createdAt).toLocaleDateString('ru-RU')
map[day] = map[day] || []
map[day].push(n)
@@ -34,7 +34,9 @@ const typeIcon: Record<string, string> = {
<div class="notifications page-content">
<div class="header">
<h1 class="page-title">Уведомления</h1>
<button class="btn-secondary" @click="userStore.markAllRead">Отметить все как прочитанные</button>
<button class="btn-secondary" @click="userStore.markAllRead">
Отметить все как прочитанные
</button>
</div>
<div v-if="userStore.notifications.length === 0" class="empty-wrap">
@@ -46,7 +48,7 @@ const typeIcon: Record<string, string> = {
</div>
<div v-else class="notification-groups">
<GlassCard v-for="([day, items]) in grouped" :key="day" class="group">
<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 }">
@@ -69,16 +71,53 @@ const typeIcon: Record<string, string> = {
gap: 18px;
min-height: calc(100vh - var(--topbar-height) - 28px - 80px);
}
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.header .page-title { margin-bottom: 0; }
.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 { color: var(--color-text); flex-shrink: 0; }
.item-title { font-weight: 600; }
.item-body { font-size: 13px; color: var(--color-text-secondary); }
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.header .page-title {
margin-bottom: 0;
}
.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 {
color: var(--color-text);
flex-shrink: 0;
}
.item-title {
font-weight: 600;
}
.item-body {
font-size: 13px;
color: var(--color-text-secondary);
}
.empty-wrap {
flex: 1;
@@ -88,6 +127,8 @@ const typeIcon: Record<string, string> = {
}
@media (max-width: 768px) {
.notifications { min-height: calc(100vh - var(--topbar-height) - 16px - 80px); }
.notifications {
min-height: calc(100vh - var(--topbar-height) - 16px - 80px);
}
}
</style>
+99 -25
View File
@@ -27,8 +27,12 @@ const currentLevelXp = computed(() => user.value.currentLevelXp ?? 0)
const nextLevelXp = computed(() => user.value.nextLevelXp)
const userXp = computed(() => user.value.xp ?? 0)
const hasLevelProgress = computed(() => nextLevelXp.value !== undefined)
const hasNextLevel = computed(() => typeof nextLevelXp.value === 'number' && nextLevelXp.value > currentLevelXp.value)
const levelProgressMax = computed(() => hasNextLevel.value ? nextLevelXp.value! - currentLevelXp.value : 1)
const hasNextLevel = computed(
() => typeof nextLevelXp.value === 'number' && nextLevelXp.value > currentLevelXp.value,
)
const levelProgressMax = computed(() =>
hasNextLevel.value ? nextLevelXp.value! - currentLevelXp.value : 1,
)
const levelProgress = computed(() => {
if (!hasLevelProgress.value) return 0
if (!hasNextLevel.value) return 1
@@ -37,27 +41,32 @@ const levelProgress = computed(() => {
const levelProgressLabel = computed(() =>
!hasLevelProgress.value
? `Уровень ${user.value.level}`
: hasNextLevel.value ? `Уровень ${user.value.level}` : `Уровень ${user.value.level} · максимум`
: hasNextLevel.value
? `Уровень ${user.value.level}`
: `Уровень ${user.value.level} · максимум`,
)
const levelProgressText = computed(() =>
hasNextLevel.value ? `${levelProgress.value} / ${levelProgressMax.value} XP` : `${userXp.value} XP`
hasNextLevel.value
? `${levelProgress.value} / ${levelProgressMax.value} XP`
: `${userXp.value} XP`,
)
const activeEnrollments = computed(() => user.value.activeEnrollments ?? 0)
const enrollmentSlotLimit = computed(() => user.value.enrollmentSlotLimit ?? 0)
const enrollmentSlotsRemaining = computed(() =>
Math.max(enrollmentSlotLimit.value - activeEnrollments.value, 0)
Math.max(enrollmentSlotLimit.value - activeEnrollments.value, 0),
)
const enrollmentSlotRules = computed(() => user.value.enrollmentSlotRules ?? [])
const enrollmentSlotText = computed(() =>
enrollmentSlotLimit.value ? `${activeEnrollments.value} / ${enrollmentSlotLimit.value}` : '...'
enrollmentSlotLimit.value ? `${activeEnrollments.value} / ${enrollmentSlotLimit.value}` : '...',
)
const enrollmentSlotHint = computed(() => {
if (!enrollmentSlotLimit.value) return 'Загружаем лимит активных записей.'
if (enrollmentSlotsRemaining.value === 0) return 'Все слоты заняты. Отмените запись, дождитесь отметки посещения или повышайте уровень.'
if (enrollmentSlotsRemaining.value === 0)
return 'Все слоты заняты. Отмените запись, дождитесь отметки посещения или повышайте уровень.'
return `Можно записаться еще на ${formatLectureCount(enrollmentSlotsRemaining.value)}.`
})
const unlockedAchievements = computed(() => userStore.achievements.filter(a => a.unlocked))
const lockedAchievements = computed(() => userStore.achievements.filter(a => !a.unlocked))
const unlockedAchievements = computed(() => userStore.achievements.filter((a) => a.unlocked))
const lockedAchievements = computed(() => userStore.achievements.filter((a) => !a.unlocked))
const interestTags = ref([
{ label: '#ML', active: true },
{ label: '#ИИ', active: true },
@@ -73,7 +82,8 @@ function formatSlotCount(slots: number) {
const lastDigit = slots % 10
const lastTwoDigits = slots % 100
if (lastDigit === 1 && lastTwoDigits !== 11) return `${slots} слот`
if (lastDigit >= 2 && lastDigit <= 4 && (lastTwoDigits < 12 || lastTwoDigits > 14)) return `${slots} слота`
if (lastDigit >= 2 && lastDigit <= 4 && (lastTwoDigits < 12 || lastTwoDigits > 14))
return `${slots} слота`
return `${slots} слотов`
}
@@ -81,7 +91,8 @@ function formatLectureCount(count: number) {
const lastDigit = count % 10
const lastTwoDigits = count % 100
if (lastDigit === 1 && lastTwoDigits !== 11) return `${count} лекцию`
if (lastDigit >= 2 && lastDigit <= 4 && (lastTwoDigits < 12 || lastTwoDigits > 14)) return `${count} лекции`
if (lastDigit >= 2 && lastDigit <= 4 && (lastTwoDigits < 12 || lastTwoDigits > 14))
return `${count} лекции`
return `${count} лекций`
}
@@ -210,16 +221,60 @@ onMounted(() => {
</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; }
.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;
}
.slot-overview {
display: flex;
align-items: center;
@@ -273,10 +328,29 @@ onMounted(() => {
border-color: var(--color-primary-a30);
background: var(--color-primary-a08);
}
.settings { display: flex; flex-direction: column; gap: 8px; }
.setting { font-size: 13px; color: var(--color-text-secondary); display: flex; gap: 8px; align-items: center; }
.achievements-section { display: flex; flex-direction: column; gap: 12px; margin-top: 18px; }
.achievements-list { display: flex; flex-direction: column; gap: 12px; }
.settings {
display: flex;
flex-direction: column;
gap: 8px;
}
.setting {
font-size: 13px;
color: var(--color-text-secondary);
display: flex;
gap: 8px;
align-items: center;
}
.achievements-section {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 18px;
}
.achievements-list {
display: flex;
flex-direction: column;
gap: 12px;
}
@media (max-width: 520px) {
.slot-overview {
+89 -22
View File
@@ -21,7 +21,9 @@ const ratingMap = {
negative: 'Dislike',
} as const
const lectureTitle = computed(() => lecture.value?.title || lecture.value?.courseName || 'Отзыв о лекции')
const lectureTitle = computed(
() => lecture.value?.title || lecture.value?.courseName || 'Отзыв о лекции',
)
const lectureMeta = computed(() => {
if (!lecture.value) return ''
@@ -75,7 +77,9 @@ onMounted(() => {
<div>
<h1 class="page-title">{{ lectureLoading ? 'Загрузка лекции...' : lectureTitle }}</h1>
<p class="text-secondary">
{{ lectureMeta || 'Ваш отзыв помогает улучшать качество лекций и приносит бонусные монеты.' }}
{{
lectureMeta || 'Ваш отзыв помогает улучшать качество лекций и приносит бонусные монеты.'
}}
</p>
</div>
</div>
@@ -85,7 +89,8 @@ onMounted(() => {
<AppIcon class="success-icon" icon="circle-check" :size="32" />
<div class="success-title">Отзыв отправлен и будет обработан</div>
<div class="success-sub">
Спасибо! Оценка полезности отзыва рассчитывается автоматически. Студентам не показывается техническая оценка LLM.
Спасибо! Оценка полезности отзыва рассчитывается автоматически. Студентам не показывается
техническая оценка LLM.
</div>
</div>
@@ -95,18 +100,39 @@ onMounted(() => {
<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>
<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="error" v-if="error">{{ error }}</div>
<div class="form-actions">
<button class="btn-primary" type="submit" :disabled="loading">{{ loading ? 'Отправляем...' : 'Отправить отзыв' }}</button>
<button class="btn-primary" type="submit" :disabled="loading">
{{ loading ? 'Отправляем...' : 'Отправить отзыв' }}
</button>
</div>
</form>
</GlassCard>
@@ -114,37 +140,78 @@ onMounted(() => {
</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); }
.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);
background: rgba(255, 255, 255, 0.8);
font-size: 14px;
resize: vertical;
}
.rating-options { display: flex; gap: 10px; flex-wrap: wrap; }
.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);
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);
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 { color: var(--color-primary); }
.success-title { font-size: 16px; font-weight: 700; }
.success-sub { font-size: 13px; color: var(--color-text-secondary); }
.error { color: var(--color-error); font-size: 13px; }
.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 {
color: var(--color-primary);
}
.success-title {
font-size: 16px;
font-weight: 700;
}
.success-sub {
font-size: 13px;
color: var(--color-text-secondary);
}
.error {
color: var(--color-error);
font-size: 13px;
}
</style>
@@ -14,9 +14,9 @@ const lecturesStore = useLecturesStore()
const auth = useAuthStore()
const reviews = ref<Review[]>([])
const positive = computed(() => reviews.value.filter(r => r.sentiment === 'positive').length)
const neutral = computed(() => reviews.value.filter(r => r.sentiment === 'neutral').length)
const negative = computed(() => reviews.value.filter(r => r.sentiment === 'negative').length)
const positive = computed(() => reviews.value.filter((r) => r.sentiment === 'positive').length)
const neutral = computed(() => reviews.value.filter((r) => r.sentiment === 'neutral').length)
const negative = computed(() => reviews.value.filter((r) => r.sentiment === 'negative').length)
const total = computed(() => reviews.value.length || 1)
const pct = (value: number) => Math.round((value / total.value) * 100)
@@ -24,8 +24,10 @@ async function fetchTeacherAnalytics() {
if (!auth.user?.id) return
await lecturesStore.fetchLectures({ TeacherId: auth.user.id })
const targetLectures = lecturesStore.all.slice(0, 5)
const payload = await Promise.allSettled(targetLectures.map(l => lecturesApi.reviews(l.id)))
reviews.value = payload.flatMap(result => (result.status === 'fulfilled' ? result.value.map(mapApiReview) : []))
const payload = await Promise.allSettled(targetLectures.map((l) => lecturesApi.reviews(l.id)))
reviews.value = payload.flatMap((result) =>
result.status === 'fulfilled' ? result.value.map(mapApiReview) : [],
)
}
onMounted(fetchTeacherAnalytics)
@@ -57,11 +59,19 @@ watch(() => auth.user?.id, fetchTeacherAnalytics)
</div>
<div>
<div class="sentiment-label">Нейтральные {{ pct(neutral) }}%</div>
<ProgressBar :value="pct(neutral)" :max="100" color="linear-gradient(90deg, #7DD3FC, #BAE6FD)" />
<ProgressBar
:value="pct(neutral)"
:max="100"
color="linear-gradient(90deg, #7DD3FC, #BAE6FD)"
/>
</div>
<div>
<div class="sentiment-label">Негативные {{ pct(negative) }}%</div>
<ProgressBar :value="pct(negative)" :max="100" color="linear-gradient(90deg, #FCA5A5, #FECACA)" />
<ProgressBar
:value="pct(negative)"
:max="100"
color="linear-gradient(90deg, #FCA5A5, #FECACA)"
/>
</div>
</div>
</GlassCard>
@@ -70,8 +80,9 @@ watch(() => auth.user?.id, fetchTeacherAnalytics)
<GlassCard>
<div class="section-title">LLM-сводка проблем и рекомендаций</div>
<p class="summary">
Студенты отмечают сильную практическую часть, но хотят больше разборов вопросов из аудитории.
Рекомендуется добавить блок с кейсами из реальных проектов и увеличить время на интерактив.
Студенты отмечают сильную практическую часть, но хотят больше разборов вопросов из
аудитории. Рекомендуется добавить блок с кейсами из реальных проектов и увеличить время на
интерактив.
</p>
<div class="tags">
<span class="tag-chip">много практики</span>
@@ -83,29 +94,92 @@ watch(() => auth.user?.id, fetchTeacherAnalytics)
<GlassCard>
<div class="section-title">Отзывы</div>
<EmptyState v-if="!reviews.length" title="Отзывов пока нет" subtitle="Когда студенты оставят отзывы, они появятся здесь." />
<EmptyState
v-if="!reviews.length"
title="Отзывов пока нет"
subtitle="Когда студенты оставят отзывы, они появятся здесь."
/>
<div v-else class="reviews">
<div v-for="review in reviews" :key="review.id" class="review">
«{{ review.text }}»
</div>
<div v-for="review in reviews" :key="review.id" class="review">«{{ review.text }}»</div>
</div>
</GlassCard>
</div>
</template>
<style scoped>
.teacher-analytics { display: flex; flex-direction: column; gap: 18px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; }
.chart { display: flex; gap: 12px; align-items: flex-end; height: 160px; padding: 10px 0; }
.bar { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.bar-fill { width: 26px; border-radius: 6px 6px 0 0; background: linear-gradient(180deg, #22C55E, #86EFAC); }
.bar-label { font-size: 11px; color: var(--color-text-secondary); }
.avg { margin-top: 6px; font-weight: 600; }
.sentiment { display: flex; flex-direction: column; gap: 12px; }
.sentiment-label { font-size: 12px; color: var(--color-text-secondary); margin-bottom: 4px; }
.summary { font-size: 14px; color: var(--color-text-secondary); line-height: 1.5; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.reviews { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
.review { background: rgba(255,255,255,0.6); border: 1px solid var(--color-border-glass); padding: 10px; border-radius: var(--radius-sm); font-size: 13px; }
.top-list { padding-left: 18px; color: var(--color-text-secondary); font-size: 13px; }
.teacher-analytics {
display: flex;
flex-direction: column;
gap: 18px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.chart {
display: flex;
gap: 12px;
align-items: flex-end;
height: 160px;
padding: 10px 0;
}
.bar {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.bar-fill {
width: 26px;
border-radius: 6px 6px 0 0;
background: linear-gradient(180deg, #22c55e, #86efac);
}
.bar-label {
font-size: 11px;
color: var(--color-text-secondary);
}
.avg {
margin-top: 6px;
font-weight: 600;
}
.sentiment {
display: flex;
flex-direction: column;
gap: 12px;
}
.sentiment-label {
font-size: 12px;
color: var(--color-text-secondary);
margin-bottom: 4px;
}
.summary {
font-size: 14px;
color: var(--color-text-secondary);
line-height: 1.5;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.reviews {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 10px;
}
.review {
background: rgba(255, 255, 255, 0.6);
border: 1px solid var(--color-border-glass);
padding: 10px;
border-radius: var(--radius-sm);
font-size: 13px;
}
.top-list {
padding-left: 18px;
color: var(--color-text-secondary);
font-size: 13px;
}
</style>
@@ -14,9 +14,15 @@ const router = useRouter()
const teacherLectures = computed(() => {
return lecturesStore.all
})
const upcoming = computed(() => teacherLectures.value.filter(l => l.status !== 'completed').slice(0, 3))
const enrolledTotal = computed(() => teacherLectures.value.reduce((sum, l) => sum + l.enrolledSeats, 0))
const visibility = computed(() => (teacherLectures.value.length ? Math.min(100, Math.round(enrolledTotal.value * 4)) : 0))
const upcoming = computed(() =>
teacherLectures.value.filter((l) => l.status !== 'completed').slice(0, 3),
)
const enrolledTotal = computed(() =>
teacherLectures.value.reduce((sum, l) => sum + l.enrolledSeats, 0),
)
const visibility = computed(() =>
teacherLectures.value.length ? Math.min(100, Math.round(enrolledTotal.value * 4)) : 0,
)
function fetchTeacherLectures() {
if (!auth.user?.id) return
@@ -33,7 +39,9 @@ watch(() => auth.user?.id, fetchTeacherLectures)
<h1 class="page-title">Дашборд преподавателя</h1>
<div class="actions">
<button class="btn-primary" @click="router.push('/teacher/lectures')">Мои лекции</button>
<button class="btn-secondary" @click="router.push('/teacher/analytics')">Посмотреть отзывы</button>
<button class="btn-secondary" @click="router.push('/teacher/analytics')">
Посмотреть отзывы
</button>
</div>
</div>
@@ -41,20 +49,33 @@ watch(() => auth.user?.id, fetchTeacherLectures)
<StatsWidget label="Предстоящие лекции" :value="upcoming.length" icon="📅" color="green" />
<StatsWidget label="Записавшихся" :value="enrolledTotal" icon="👥" color="aqua" />
<StatsWidget label="Средняя оценка" :value="'—'" icon="⭐" color="orange" />
<StatsWidget label="Вовлеченность вне направления" :value="`${visibility}%`" icon="🌍" color="purple" />
<StatsWidget
label="Вовлеченность вне направления"
:value="`${visibility}%`"
icon="🌍"
color="purple"
/>
</div>
<GlassCard>
<div class="section-title">Ближайшие лекции</div>
<EmptyState v-if="!upcoming.length" title="Лекций пока нет" subtitle="После синхронизации или назначения лекции появятся здесь." />
<EmptyState
v-if="!upcoming.length"
title="Лекций пока нет"
subtitle="После синхронизации или назначения лекции появятся здесь."
/>
<div v-else class="upcoming">
<div class="upcoming-item" v-for="l in upcoming" :key="l.id">
<div>
<div class="upcoming-title">{{ l.title }}</div>
<div class="upcoming-meta">{{ new Date(l.date).toLocaleDateString('ru-RU') }} {{ l.time }}</div>
<div class="upcoming-meta">
{{ new Date(l.date).toLocaleDateString('ru-RU') }} {{ l.time }}
</div>
<div class="upcoming-meta">Записалось {{ l.enrolledSeats }} студентов</div>
</div>
<button class="btn-secondary btn-sm" @click="router.push('/teacher/lectures')">Управлять</button>
<button class="btn-secondary btn-sm" @click="router.push('/teacher/lectures')">
Управлять
</button>
</div>
</div>
</GlassCard>
@@ -62,16 +83,63 @@ watch(() => auth.user?.id, fetchTeacherLectures)
</template>
<style scoped>
.teacher-dashboard { display: flex; flex-direction: column; gap: 18px; }
.header { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; }
.visibility { display: flex; flex-direction: column; gap: 8px; }
.visibility-meta { font-size: 13px; color: var(--color-text-secondary); }
.upcoming { display: flex; flex-direction: column; gap: 12px; margin-top: 10px; }
.upcoming-item { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--color-border-glass); }
.upcoming-item:last-child { border-bottom: none; padding-bottom: 0; }
.upcoming-title { font-weight: 700; }
.upcoming-meta { font-size: 13px; color: var(--color-text-secondary); }
.btn-sm { padding: 6px 12px; font-size: 12px; }
.teacher-dashboard {
display: flex;
flex-direction: column;
gap: 18px;
}
.header {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
.visibility {
display: flex;
flex-direction: column;
gap: 8px;
}
.visibility-meta {
font-size: 13px;
color: var(--color-text-secondary);
}
.upcoming {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 10px;
}
.upcoming-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--color-border-glass);
}
.upcoming-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.upcoming-title {
font-weight: 700;
}
.upcoming-meta {
font-size: 13px;
color: var(--color-text-secondary);
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
</style>
@@ -18,7 +18,7 @@ const columns = [
]
const rows = computed(() => {
return lecturesStore.all.map(l => ({
return lecturesStore.all.map((l) => ({
id: l.id,
title: l.title,
date: `${new Date(l.date).toLocaleDateString('ru-RU')} ${l.time}`,
@@ -44,7 +44,11 @@ watch(() => auth.user?.id, fetchTeacherLectures)
</div>
<GlassCard>
<EmptyState v-if="!rows.length && !lecturesStore.loading" title="Лекций пока нет" subtitle="Backend не вернул лекции для текущего преподавателя." />
<EmptyState
v-if="!rows.length && !lecturesStore.loading"
title="Лекций пока нет"
subtitle="Backend не вернул лекции для текущего преподавателя."
/>
<DataTable :columns="columns" :rows="rows">
<template #status="{ value }">
<StatusBadge :status="value" />
@@ -63,7 +67,22 @@ watch(() => auth.user?.id, fetchTeacherLectures)
</template>
<style scoped>
.teacher-lectures { display: flex; flex-direction: column; gap: 16px; }
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.actions { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; }
.teacher-lectures {
display: flex;
flex-direction: column;
gap: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
</style>