221 lines
6.5 KiB
Vue
221 lines
6.5 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from 'vue'
|
|
import { usersApi } from '@/api'
|
|
import type { ApiUserRole, UserDto } from '@/api/types'
|
|
import GlassCard from '@/components/ui/GlassCard.vue'
|
|
import DataTable from '@/components/ui/DataTable.vue'
|
|
import EmptyState from '@/components/ui/EmptyState.vue'
|
|
|
|
const search = ref('')
|
|
const roleFilter = ref('Все роли')
|
|
const instituteFilter = ref('Все институты')
|
|
const users = ref<UserDto[]>([])
|
|
const loading = ref(false)
|
|
const error = 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 allRoles: ApiUserRole[] = ['Student', 'Teacher', 'Admin']
|
|
const roleLabels = { Student: 'Студент', Teacher: 'Преподаватель', Admin: 'Администратор' } as const
|
|
const roleApi = { Студент: 'Student', Преподаватель: 'Teacher', Администратор: 'Admin' } as const
|
|
const roleBadgeClasses: Record<ApiUserRole, string> = {
|
|
Student: 'badge-green',
|
|
Teacher: 'badge-blue',
|
|
Admin: 'badge-purple',
|
|
}
|
|
|
|
const rows = computed(() =>
|
|
users.value.map((user) => ({
|
|
id: user.id,
|
|
name: user.displayName || user.email,
|
|
email: user.email,
|
|
role: user.roles.map((role) => roleLabels[role]).join(', '),
|
|
apiRoles: user.roles,
|
|
institute: 'ЮФУ',
|
|
activity: user.isActive ? 'Активен' : 'Заблокирован',
|
|
isActive: user.isActive,
|
|
created: new Date(user.createdAt).toLocaleDateString('ru-RU'),
|
|
})),
|
|
)
|
|
|
|
async function fetchUsers() {
|
|
loading.value = true
|
|
error.value = ''
|
|
try {
|
|
users.value = await usersApi.list({
|
|
Search: search.value || undefined,
|
|
Role:
|
|
roleFilter.value === 'Все роли'
|
|
? undefined
|
|
: roleApi[roleFilter.value as keyof typeof roleApi],
|
|
PageSize: 100,
|
|
})
|
|
} catch (err) {
|
|
error.value = err instanceof Error ? err.message : 'Не удалось загрузить пользователей.'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function toggleActive(row: Record<string, unknown>) {
|
|
const id = Number(row.id)
|
|
const isActive = Boolean(row.isActive)
|
|
await usersApi.setActive(id, !isActive)
|
|
await fetchUsers()
|
|
}
|
|
|
|
function getRowRoles(row: Record<string, unknown>) {
|
|
const apiRoles = Array.isArray(row.apiRoles) ? row.apiRoles : []
|
|
return Array.from(
|
|
new Set(apiRoles.filter((role): role is ApiUserRole => allRoles.includes(role as ApiUserRole))),
|
|
)
|
|
}
|
|
|
|
function hasRole(row: Record<string, unknown>, role: ApiUserRole) {
|
|
return getRowRoles(row).includes(role)
|
|
}
|
|
|
|
function getRoleChipClass(row: Record<string, unknown>, role: ApiUserRole) {
|
|
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, role]
|
|
|
|
if (!nextRoles.length) return
|
|
|
|
await usersApi.setRole(id, nextRoles)
|
|
await fetchUsers()
|
|
}
|
|
|
|
onMounted(fetchUsers)
|
|
</script>
|
|
|
|
<template>
|
|
<div class="admin-users page-content">
|
|
<div class="header">
|
|
<h1 class="page-title">Пользователи</h1>
|
|
</div>
|
|
|
|
<GlassCard>
|
|
<div class="filters">
|
|
<input v-model="search" class="glass-input" placeholder="Поиск по имени или email" />
|
|
<select v-model="roleFilter" class="glass-input" @change="fetchUsers">
|
|
<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>
|
|
|
|
<EmptyState v-if="error" title="Не удалось загрузить пользователей" :subtitle="error" />
|
|
<EmptyState
|
|
v-else-if="!rows.length && !loading"
|
|
title="Пользователей не найдено"
|
|
subtitle="Попробуйте изменить фильтры."
|
|
/>
|
|
<DataTable :columns="columns" :rows="rows">
|
|
<template #role="{ row }">
|
|
<div class="role-chips">
|
|
<button
|
|
v-for="role in allRoles"
|
|
:key="role"
|
|
class="role-chip"
|
|
:class="getRoleChipClass(row, role)"
|
|
type="button"
|
|
@click="toggleRole(row, role)"
|
|
>
|
|
{{ roleLabels[role] }}
|
|
</button>
|
|
</div>
|
|
</template>
|
|
<template #activity="{ value }">
|
|
<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>
|
|
</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;
|
|
}
|
|
.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>
|