360 lines
14 KiB
Vue
360 lines
14 KiB
Vue
<script setup>
|
||
import { ref, reactive, computed } from 'vue'
|
||
import {Head, router, useForm, usePage} from '@inertiajs/vue3'
|
||
import { useAuthStore } from '../../Stores/auth.js'
|
||
import { TbUser, TbLock } from 'vue-icons-plus/tb'
|
||
import {TbHeartRateMonitor} from 'vue-icons-plus/tb'
|
||
import {
|
||
NForm, NFormItem, NInput, NButton, NCheckbox,
|
||
NSpace, NCard, NIcon, NAlert, NModal, darkTheme,
|
||
NConfigProvider, NLayout, NP, useThemeVars
|
||
} from 'naive-ui'
|
||
import Noise from '../../Components/Noise.vue'
|
||
|
||
const topographySvg = '/assets/topography.svg'
|
||
|
||
const themeVars = useThemeVars()
|
||
|
||
const blobBg = computed(() => {
|
||
const p = themeVars.value.primaryColor
|
||
const w = themeVars.value.warningColor
|
||
const i = themeVars.value.infoColor
|
||
return [
|
||
`radial-gradient(circle 18vw at 8% 8%, color-mix(in srgb, ${p} 26%, transparent), transparent 100%)`,
|
||
`radial-gradient(circle 22vw at 100% 18%, color-mix(in srgb, ${i} 22%, transparent), transparent 100%)`,
|
||
`radial-gradient(circle 24vw at 12% 100%, color-mix(in srgb, ${w} 20%, transparent), transparent 100%)`,
|
||
].join(', ')
|
||
})
|
||
|
||
const primaryColor = computed(() => themeVars.value.primaryColor)
|
||
const infoColor = computed(() => themeVars.value.infoColor)
|
||
const cardColor = computed(() => themeVars.value.cardColor)
|
||
const borderColor = computed(() => themeVars.value.borderColor)
|
||
|
||
// Интерактивная текстура фона (следует за курсором/пальцем)
|
||
const texturePos = reactive({ x: 0, y: 0 })
|
||
|
||
const onTexturePointerMove = (e) => {
|
||
const point = e.touches ? e.touches[0] : e
|
||
texturePos.x = point.clientX
|
||
texturePos.y = point.clientY
|
||
}
|
||
|
||
const textureMaskImage = computed(() => {
|
||
return `url(${topographySvg}), radial-gradient(circle 280px at ${texturePos.x}px ${texturePos.y}px, #fff 0%, transparent 100%)`
|
||
})
|
||
|
||
const authStore = useAuthStore()
|
||
const page = usePage()
|
||
const appVersion = page.props.app.version
|
||
const appTag = page.props.app.tag
|
||
|
||
// Состояние формы
|
||
const formRef = ref(null)
|
||
|
||
const form = useForm({
|
||
login: '',
|
||
password: '',
|
||
})
|
||
|
||
|
||
const forgotForm = ref({ email: '' })
|
||
|
||
// Состояние UI
|
||
const loading = ref(false)
|
||
const forgotLoading = ref(false)
|
||
const error = ref('')
|
||
const showForgotPassword = ref(false)
|
||
|
||
// Правила валидации
|
||
const rules = {
|
||
login: [
|
||
{ required: true, message: 'Введите логин', trigger: 'blur' },
|
||
],
|
||
password: [
|
||
{ required: true, message: 'Введите пароль', trigger: 'blur' },
|
||
{ min: 3, message: 'Пароль должен содержать минимум 3 символа', trigger: 'blur' }
|
||
]
|
||
}
|
||
|
||
// Обработка входа
|
||
const handleLogin = async () => {
|
||
error.value = ''
|
||
|
||
try {
|
||
await formRef.value?.validate()
|
||
|
||
loading.value = true
|
||
|
||
form.post(
|
||
'/auth/login',
|
||
{
|
||
onSuccess: () => {},
|
||
onError: (err) => {
|
||
error.value = err[0]
|
||
}
|
||
}
|
||
)
|
||
} catch {
|
||
// валидация не прошла
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// Восстановление пароля
|
||
const handleForgotPassword = async () => {
|
||
forgotLoading.value = true
|
||
// Здесь будет запрос на восстановление пароля
|
||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||
forgotLoading.value = false
|
||
showForgotPassword.value = false
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<NConfigProvider :theme="darkTheme">
|
||
<NLayout embedded>
|
||
<div
|
||
class="relative min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden"
|
||
@mousemove="onTexturePointerMove"
|
||
@touchmove="onTexturePointerMove"
|
||
>
|
||
<Head title="Вход в систему" />
|
||
|
||
<!-- Цветные градиентные пятна фона -->
|
||
<!-- <div class="pointer-events-none fixed inset-0 z-0" :style="`background: ${blobBg};`" /> -->
|
||
|
||
<Noise />
|
||
|
||
<!-- Базовый слой топографической текстуры -->
|
||
<div class="texture-layer texture-base" />
|
||
<!-- Подсветка текстуры вокруг курсора -->
|
||
<div class="texture-layer texture-highlight" :style="{ maskImage: textureMaskImage, WebkitMaskImage: textureMaskImage }" />
|
||
|
||
<div class="auth-enter relative z-10 max-w-md w-full space-y-8">
|
||
<!-- Логотип и заголовок -->
|
||
<div class="text-center">
|
||
<div class="flex justify-center">
|
||
<div class="auth-logo flex items-center justify-center">
|
||
<NIcon size="30" color="#fff">
|
||
<TbHeartRateMonitor />
|
||
</NIcon>
|
||
</div>
|
||
</div>
|
||
<h2 class="mt-6 text-3xl font-bold tracking-tight">
|
||
Метрика
|
||
</h2>
|
||
<NP class="mt-2! text-sm" depth="3">
|
||
Введите данные для входа в систему
|
||
</NP>
|
||
</div>
|
||
|
||
<!-- Форма входа -->
|
||
<n-card class="auth-card" content-style="padding: 28px;">
|
||
<n-form
|
||
ref="formRef"
|
||
:model="form"
|
||
:rules="rules"
|
||
@submit.prevent="handleLogin"
|
||
>
|
||
<n-space vertical size="large">
|
||
<!-- Логин -->
|
||
<n-form-item label="Логин" path="login">
|
||
<n-input
|
||
id="login"
|
||
v-model:value="form.login"
|
||
placeholder="Ваш логин"
|
||
size="large"
|
||
round
|
||
@keydown.enter="handleLogin"
|
||
>
|
||
<template #prefix>
|
||
<n-icon :component="TbUser" depth="3" />
|
||
</template>
|
||
</n-input>
|
||
</n-form-item>
|
||
|
||
<!-- Пароль -->
|
||
<n-form-item label="Пароль" path="password">
|
||
<n-input
|
||
id="password"
|
||
v-model:value="form.password"
|
||
type="password"
|
||
placeholder="Ваш пароль"
|
||
size="large"
|
||
round
|
||
show-password-on="click"
|
||
@keydown.enter="handleLogin"
|
||
>
|
||
<template #prefix>
|
||
<n-icon :component="TbLock" depth="3" />
|
||
</template>
|
||
</n-input>
|
||
</n-form-item>
|
||
|
||
<!-- Запомнить меня -->
|
||
<!-- <n-form-item>-->
|
||
<!-- <n-checkbox v-model:checked="form.remember">-->
|
||
<!-- Запомнить меня-->
|
||
<!-- </n-checkbox>-->
|
||
<!-- </n-form-item>-->
|
||
|
||
<!-- Ошибки -->
|
||
<Transition name="fade-slide">
|
||
<n-alert v-if="error" title="Ошибка входа" type="error" closable @close="error = ''">
|
||
{{ error }}
|
||
</n-alert>
|
||
</Transition>
|
||
|
||
<!-- Кнопка входа -->
|
||
<n-button
|
||
type="primary"
|
||
size="large"
|
||
round
|
||
:loading="loading"
|
||
@click="handleLogin"
|
||
block
|
||
class="auth-submit"
|
||
>
|
||
Войти в систему
|
||
</n-button>
|
||
|
||
<!-- Дополнительные ссылки -->
|
||
<div class="text-center space-y-3">
|
||
<div class="text-sm">
|
||
<n-button text type="primary" @click="showForgotPassword = true">
|
||
Забыли пароль?
|
||
</n-button>
|
||
</div>
|
||
|
||
<!-- <div class="text-sm text-gray-600">-->
|
||
<!-- Нет аккаунта?-->
|
||
<!-- <n-button text type="primary" @click="$inertia.visit(route('register'))">-->
|
||
<!-- Зарегистрироваться-->
|
||
<!-- </n-button>-->
|
||
<!-- </div>-->
|
||
</div>
|
||
</n-space>
|
||
</n-form>
|
||
</n-card>
|
||
|
||
<!-- Информация о системе -->
|
||
<NP class="mt-2! text-xs! text-center" depth="3">
|
||
<p>Метрика v{{ appVersion }}-{{ appTag }}</p>
|
||
<p>Только для авторизованного персонала</p>
|
||
</NP>
|
||
</div>
|
||
|
||
<!-- Модальное окно восстановления пароля -->
|
||
<n-modal v-model:show="showForgotPassword">
|
||
<n-card
|
||
class="auth-card"
|
||
style="width: 400px"
|
||
title="Восстановление пароля"
|
||
:bordered="false"
|
||
size="small"
|
||
>
|
||
<n-form :model="forgotForm" @submit.prevent="handleForgotPassword">
|
||
<n-space vertical>
|
||
<n-form-item label="Email" required>
|
||
<n-input
|
||
v-model:value="forgotForm.email"
|
||
placeholder="Введите ваш email"
|
||
round
|
||
/>
|
||
</n-form-item>
|
||
|
||
<n-button type="primary" round block :loading="forgotLoading" @click="handleForgotPassword">
|
||
Отправить инструкции
|
||
</n-button>
|
||
</n-space>
|
||
</n-form>
|
||
</n-card>
|
||
</n-modal>
|
||
</div>
|
||
</NLayout>
|
||
</NConfigProvider>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.texture-layer {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 0;
|
||
pointer-events: none;
|
||
background-color: currentColor;
|
||
mask-repeat: repeat;
|
||
-webkit-mask-repeat: repeat;
|
||
mask-size: 666px 666px;
|
||
-webkit-mask-size: 666px 666px;
|
||
}
|
||
|
||
.texture-base {
|
||
opacity: 0.04;
|
||
mask-image: url('/assets/topography.svg');
|
||
-webkit-mask-image: url('/assets/topography.svg');
|
||
}
|
||
|
||
.texture-highlight {
|
||
opacity: 0.18;
|
||
mask-repeat: repeat, no-repeat;
|
||
-webkit-mask-repeat: repeat, no-repeat;
|
||
mask-size: 666px 666px, 100% 100%;
|
||
-webkit-mask-size: 666px 666px, 100% 100%;
|
||
mask-composite: intersect;
|
||
-webkit-mask-composite: source-in;
|
||
}
|
||
|
||
/* Логотип в градиентном круге со свечением */
|
||
.auth-logo {
|
||
width: 64px;
|
||
height: 64px;
|
||
border-radius: 9999px;
|
||
background: linear-gradient(135deg, v-bind(primaryColor), v-bind(infoColor));
|
||
box-shadow: 0 8px 28px -10px color-mix(in srgb, v-bind(primaryColor) 65%, transparent);
|
||
}
|
||
|
||
/* Стеклянная карточка с лёгким размытием фона */
|
||
.auth-card {
|
||
background: color-mix(in srgb, v-bind(cardColor) 70%, transparent);
|
||
backdrop-filter: blur(18px);
|
||
-webkit-backdrop-filter: blur(18px);
|
||
border: 1px solid color-mix(in srgb, v-bind(borderColor) 60%, transparent);
|
||
border-radius: 18px;
|
||
box-shadow: 0 24px 60px -28px rgba(0, 0, 0, 0.6);
|
||
}
|
||
|
||
.auth-submit {
|
||
box-shadow: 0 10px 24px -10px color-mix(in srgb, v-bind(primaryColor) 60%, transparent);
|
||
}
|
||
|
||
/* Плавное появление карточки входа */
|
||
.auth-enter {
|
||
animation: auth-enter 0.5s ease-out;
|
||
}
|
||
|
||
@keyframes auth-enter {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(16px) scale(0.985);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0) scale(1);
|
||
}
|
||
}
|
||
|
||
/* Плавное появление блока ошибки */
|
||
.fade-slide-enter-active,
|
||
.fade-slide-leave-active {
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.fade-slide-enter-from,
|
||
.fade-slide-leave-to {
|
||
opacity: 0;
|
||
transform: translateY(-6px);
|
||
}
|
||
</style>
|