Files
filam3d/frontend/src/views/admin/AdminUsers.vue
2026-03-23 12:48:44 +03:00

305 lines
14 KiB
Vue

<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">&#10003;</span>
<span v-else class="text-red-400">&#10005;</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@bamburussia.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>