init
This commit is contained in:
304
frontend/src/views/admin/AdminUsers.vue
Normal file
304
frontend/src/views/admin/AdminUsers.vue
Normal file
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Администраторы</h1>
|
||||
<button @click="openCreate" class="btn-primary flex items-center gap-1.5 text-sm">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zM4 19.235v-.11a6.375 6.375 0 0112.75 0v.109A12.318 12.318 0 0110.374 21c-2.331 0-4.512-.645-6.374-1.766z" />
|
||||
</svg>
|
||||
Добавить админа
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Change own password -->
|
||||
<div class="mb-6 rounded-xl border border-gray-200 bg-white p-5">
|
||||
<h2 class="mb-3 text-sm font-bold uppercase text-gray-500">Сменить свой пароль</h2>
|
||||
<form @submit.prevent="changeOwnPassword" class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="mb-1 block text-xs text-gray-500">Текущий пароль</label>
|
||||
<input v-model="pwd.current" type="password" required class="input-field" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="mb-1 block text-xs text-gray-500">Новый пароль</label>
|
||||
<input v-model="pwd.new_password" type="password" required minlength="6" class="input-field" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="mb-1 block text-xs text-gray-500">Повтор нового пароля</label>
|
||||
<input v-model="pwd.confirm" type="password" required minlength="6" class="input-field" />
|
||||
</div>
|
||||
<button type="submit" :disabled="pwdSaving" class="btn-primary whitespace-nowrap text-sm">
|
||||
{{ pwdSaving ? '...' : 'Сменить' }}
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="pwdError" class="mt-2 text-sm text-red-600">{{ pwdError }}</p>
|
||||
<p v-if="pwdSuccess" class="mt-2 text-sm text-green-600">{{ pwdSuccess }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Users list -->
|
||||
<div v-if="loading" class="text-gray-500">Загрузка...</div>
|
||||
|
||||
<div v-else class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-gray-200 bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600">ID</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600">Имя</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600">Email</th>
|
||||
<th class="px-4 py-3 text-center font-semibold text-gray-600">Активен</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600">Создан</th>
|
||||
<th class="px-4 py-3 text-right font-semibold text-gray-600">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<tr v-for="u in users" :key="u.id" class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-4 py-3 text-gray-400">{{ u.id }}</td>
|
||||
<td class="px-4 py-3 font-medium text-gray-900">{{ u.name }}</td>
|
||||
<td class="px-4 py-3 text-gray-700">{{ u.email }}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span v-if="u.is_active" class="text-green-600">✓</span>
|
||||
<span v-else class="text-red-400">✕</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 text-xs">{{ formatDate(u.created_at) }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button @click="openEdit(u)" class="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-700" title="Редактировать">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="openResetPassword(u)" class="rounded-md p-1.5 text-gray-400 hover:bg-amber-50 hover:text-amber-600" title="Сбросить пароль">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="confirmDelete(u)" class="rounded-md p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600" title="Удалить">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30" @click.self="showModal = false">
|
||||
<div class="w-full max-w-md rounded-xl bg-white shadow-2xl">
|
||||
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-4">
|
||||
<h2 class="text-lg font-bold">{{ editingUser ? 'Редактировать админа' : 'Новый администратор' }}</h2>
|
||||
<button @click="showModal = false" class="rounded-md p-1 text-gray-400 hover:bg-gray-100">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form @submit.prevent="saveUser" class="p-5 space-y-4">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Имя</label>
|
||||
<input v-model="form.name" required class="input-field" placeholder="Иван Иванов" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Email</label>
|
||||
<input v-model="form.email" type="email" required class="input-field" placeholder="admin@filam3d.ru" />
|
||||
</div>
|
||||
<div v-if="!editingUser">
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Пароль</label>
|
||||
<input v-model="form.password" type="password" required minlength="6" class="input-field" />
|
||||
</div>
|
||||
<div v-if="editingUser">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input v-model="form.is_active" type="checkbox" class="rounded border-gray-300" />
|
||||
Активен
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="formError" class="text-sm text-red-600">{{ formError }}</p>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button type="button" @click="showModal = false" class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" :disabled="saving" class="btn-primary text-sm">
|
||||
{{ saving ? 'Сохранение...' : (editingUser ? 'Сохранить' : 'Создать') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Reset password modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="resetUser" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30" @click.self="resetUser = null">
|
||||
<div class="w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-1">Сбросить пароль</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">{{ resetUser.name }} ({{ resetUser.email }})</p>
|
||||
<form @submit.prevent="doResetPassword" class="space-y-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Новый пароль</label>
|
||||
<input v-model="resetNewPassword" type="password" required minlength="6" class="input-field" />
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button type="button" @click="resetUser = null" class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" class="rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700">
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Delete confirm -->
|
||||
<Teleport to="body">
|
||||
<div v-if="deletingUser" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30" @click.self="deletingUser = null">
|
||||
<div class="w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-2">Удалить администратора?</h3>
|
||||
<p class="text-sm text-gray-600 mb-5">
|
||||
<strong>{{ deletingUser.name }}</strong> ({{ deletingUser.email }}) будет удалён.
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button @click="deletingUser = null" class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
Отмена
|
||||
</button>
|
||||
<button @click="doDelete" class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700">
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '../../api/client'
|
||||
|
||||
const users = ref([])
|
||||
const loading = ref(true)
|
||||
const showModal = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingUser = ref(null)
|
||||
const formError = ref('')
|
||||
const deletingUser = ref(null)
|
||||
const resetUser = ref(null)
|
||||
const resetNewPassword = ref('')
|
||||
|
||||
const form = ref({ name: '', email: '', password: '', is_active: true })
|
||||
|
||||
// Change own password
|
||||
const pwd = ref({ current: '', new_password: '', confirm: '' })
|
||||
const pwdSaving = ref(false)
|
||||
const pwdError = ref('')
|
||||
const pwdSuccess = ref('')
|
||||
|
||||
onMounted(() => loadUsers())
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/admin/users')
|
||||
users.value = data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingUser.value = null
|
||||
form.value = { name: '', email: '', password: '', is_active: true }
|
||||
formError.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function openEdit(u) {
|
||||
editingUser.value = u
|
||||
form.value = { name: u.name, email: u.email, password: '', is_active: u.is_active }
|
||||
formError.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
saving.value = true
|
||||
formError.value = ''
|
||||
try {
|
||||
if (editingUser.value) {
|
||||
await api.put(`/admin/users/${editingUser.value.id}`, {
|
||||
name: form.value.name,
|
||||
email: form.value.email,
|
||||
is_active: form.value.is_active,
|
||||
})
|
||||
} else {
|
||||
await api.post('/admin/users', {
|
||||
name: form.value.name,
|
||||
email: form.value.email,
|
||||
password: form.value.password,
|
||||
})
|
||||
}
|
||||
showModal.value = false
|
||||
await loadUsers()
|
||||
} catch (e) {
|
||||
formError.value = e.response?.data?.detail || 'Ошибка сохранения'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openResetPassword(u) {
|
||||
resetUser.value = u
|
||||
resetNewPassword.value = ''
|
||||
}
|
||||
|
||||
async function doResetPassword() {
|
||||
await api.post(`/admin/users/${resetUser.value.id}/reset-password`, {
|
||||
new_password: resetNewPassword.value,
|
||||
})
|
||||
resetUser.value = null
|
||||
}
|
||||
|
||||
function confirmDelete(u) {
|
||||
deletingUser.value = u
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
try {
|
||||
await api.delete(`/admin/users/${deletingUser.value.id}`)
|
||||
deletingUser.value = null
|
||||
await loadUsers()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.detail || 'Ошибка удаления')
|
||||
deletingUser.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function changeOwnPassword() {
|
||||
pwdError.value = ''
|
||||
pwdSuccess.value = ''
|
||||
if (pwd.value.new_password !== pwd.value.confirm) {
|
||||
pwdError.value = 'Пароли не совпадают'
|
||||
return
|
||||
}
|
||||
pwdSaving.value = true
|
||||
try {
|
||||
await api.post('/admin/change-password', {
|
||||
current_password: pwd.value.current,
|
||||
new_password: pwd.value.new_password,
|
||||
})
|
||||
pwdSuccess.value = 'Пароль успешно изменён'
|
||||
pwd.value = { current: '', new_password: '', confirm: '' }
|
||||
} catch (e) {
|
||||
pwdError.value = e.response?.data?.detail || 'Ошибка смены пароля'
|
||||
} finally {
|
||||
pwdSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user