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

This commit is contained in:
2026-05-08 01:06:22 +03:00
parent 655ab1b5c5
commit 047611fd24
54 changed files with 4497 additions and 28 deletions
@@ -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>