Большое обновление
1. Создание личных проектов 2. Управление командой 3. Приглашение участников 4. Уведомления и многое другое...
This commit is contained in:
@@ -21,7 +21,7 @@
|
||||
<div class="bg-glow glow-3"></div>
|
||||
|
||||
<!-- Контент авторизации -->
|
||||
<div class="login-content" :class="{ 'is-loading': isAuthenticating, 'is-success': showSuccess }">
|
||||
<div class="login-content" :class="{ 'is-loading': isProcessing, 'is-success': showSuccess }">
|
||||
|
||||
<!-- Состояние успеха -->
|
||||
<Transition name="success-fade">
|
||||
@@ -29,15 +29,15 @@
|
||||
<div class="success-icon">
|
||||
<i data-lucide="check"></i>
|
||||
</div>
|
||||
<h1 class="success-title">Добро пожаловать!</h1>
|
||||
<p class="success-text">Авторизация прошла успешно</p>
|
||||
<h1 class="success-title">{{ isRegisterMode ? 'Регистрация завершена!' : 'Добро пожаловать!' }}</h1>
|
||||
<p class="success-text">{{ isRegisterMode ? 'Сейчас вы будете перенаправлены' : 'Авторизация прошла успешно' }}</p>
|
||||
<div class="success-loader">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Форма входа -->
|
||||
<!-- Форма входа / регистрации -->
|
||||
<Transition name="form-fade">
|
||||
<div v-if="!showSuccess" class="login-form-wrapper">
|
||||
<!-- Логотип -->
|
||||
@@ -46,10 +46,13 @@
|
||||
<i data-lucide="layout-grid"></i>
|
||||
</div>
|
||||
<h1 class="login-title">TaskBoard</h1>
|
||||
<p class="login-subtitle">Войдите в систему управления задачами</p>
|
||||
<p class="login-subtitle">
|
||||
{{ isRegisterMode ? 'Создайте аккаунт для работы с задачами' : 'Войдите в систему управления задачами' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="login-form">
|
||||
<!-- Форма входа -->
|
||||
<form v-if="!isRegisterMode" @submit.prevent="handleLogin" class="login-form">
|
||||
<!-- Поле логина -->
|
||||
<div class="input-group" :class="{ 'has-value': login, 'has-focus': loginFocused }">
|
||||
<div class="input-icon">
|
||||
@@ -62,7 +65,7 @@
|
||||
@blur="loginFocused = false"
|
||||
placeholder="Введите логин"
|
||||
autocomplete="username"
|
||||
:disabled="isAuthenticating"
|
||||
:disabled="isProcessing"
|
||||
>
|
||||
<div class="input-line"></div>
|
||||
</div>
|
||||
@@ -79,7 +82,7 @@
|
||||
@blur="passwordFocused = false"
|
||||
placeholder="Введите пароль"
|
||||
autocomplete="current-password"
|
||||
:disabled="isAuthenticating"
|
||||
:disabled="isProcessing"
|
||||
>
|
||||
<div class="input-line"></div>
|
||||
</div>
|
||||
@@ -96,8 +99,8 @@
|
||||
<button
|
||||
type="submit"
|
||||
class="login-btn"
|
||||
:class="{ 'is-loading': isAuthenticating }"
|
||||
:disabled="isAuthenticating || !login || !password"
|
||||
:class="{ 'is-loading': isProcessing }"
|
||||
:disabled="isProcessing || !login || !password"
|
||||
>
|
||||
<span class="btn-text">Войти</span>
|
||||
<span class="btn-loader">
|
||||
@@ -105,6 +108,118 @@
|
||||
</span>
|
||||
<i data-lucide="arrow-right" class="btn-arrow"></i>
|
||||
</button>
|
||||
|
||||
<!-- Ссылка на регистрацию -->
|
||||
<div class="form-switch">
|
||||
<span>Нет аккаунта?</span>
|
||||
<button type="button" class="switch-btn" @click="switchToRegister">Зарегистрироваться</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Форма регистрации -->
|
||||
<form v-else @submit.prevent="handleRegister" class="login-form">
|
||||
<!-- Логин -->
|
||||
<div class="input-group" :class="{ 'has-value': regUsername, 'has-focus': regUsernameFocused, 'has-error': errors.username, 'shake': shakeUsername }">
|
||||
<div class="input-icon">
|
||||
<i data-lucide="at-sign"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
v-model="regUsername"
|
||||
@focus="regUsernameFocused = true"
|
||||
@blur="regUsernameFocused = false"
|
||||
@input="filterUsername"
|
||||
placeholder="Придумайте логин"
|
||||
autocomplete="username"
|
||||
maxlength="32"
|
||||
:disabled="isProcessing"
|
||||
>
|
||||
<div class="input-line"></div>
|
||||
</div>
|
||||
|
||||
<!-- Имя -->
|
||||
<div class="input-group" :class="{ 'has-value': regName, 'has-focus': regNameFocused, 'has-error': errors.name, 'shake': shakeName }">
|
||||
<div class="input-icon">
|
||||
<i data-lucide="user"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
v-model="regName"
|
||||
@focus="regNameFocused = true"
|
||||
@blur="regNameFocused = false"
|
||||
@input="filterName"
|
||||
placeholder="Ваше имя"
|
||||
autocomplete="name"
|
||||
maxlength="50"
|
||||
:disabled="isProcessing"
|
||||
>
|
||||
<div class="input-line"></div>
|
||||
</div>
|
||||
|
||||
<!-- Пароль -->
|
||||
<div class="input-group" :class="{ 'has-value': regPassword, 'has-focus': regPasswordFocused, 'has-error': errors.password }">
|
||||
<div class="input-icon">
|
||||
<i data-lucide="lock"></i>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
v-model="regPassword"
|
||||
@focus="regPasswordFocused = true"
|
||||
@blur="regPasswordFocused = false"
|
||||
@input="clearPasswordError"
|
||||
placeholder="Придумайте пароль"
|
||||
autocomplete="new-password"
|
||||
:disabled="isProcessing"
|
||||
>
|
||||
<div class="input-line"></div>
|
||||
</div>
|
||||
|
||||
<!-- Telegram (опционально) -->
|
||||
<div class="input-group" :class="{ 'has-value': regTelegram.length > 0, 'has-focus': regTelegramFocused, 'shake': shakeTelegram }">
|
||||
<div class="input-icon">
|
||||
<i data-lucide="send"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
v-model="regTelegram"
|
||||
@focus="regTelegramFocused = true"
|
||||
@blur="regTelegramFocused = false"
|
||||
@input="filterTelegram"
|
||||
@keydown="preventDeleteAt"
|
||||
placeholder="@telegram (необязательно)"
|
||||
autocomplete="off"
|
||||
:disabled="isProcessing"
|
||||
>
|
||||
<div class="input-line"></div>
|
||||
</div>
|
||||
|
||||
<!-- Ошибка -->
|
||||
<Transition name="error-shake">
|
||||
<div v-if="error" class="error-message">
|
||||
<i data-lucide="alert-circle"></i>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Кнопка регистрации -->
|
||||
<button
|
||||
type="submit"
|
||||
class="login-btn"
|
||||
:class="{ 'is-loading': isProcessing }"
|
||||
:disabled="isProcessing || !regName || !regUsername || !regPassword"
|
||||
>
|
||||
<span class="btn-text">Зарегистрироваться</span>
|
||||
<span class="btn-loader">
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
<i data-lucide="arrow-right" class="btn-arrow"></i>
|
||||
</button>
|
||||
|
||||
<!-- Ссылка на вход -->
|
||||
<div class="form-switch">
|
||||
<span>Уже есть аккаунт?</span>
|
||||
<button type="button" class="switch-btn" @click="switchToLogin">Войти</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -113,7 +228,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick, watch } from 'vue'
|
||||
import { ref, reactive, onMounted, nextTick, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { authApi, cardsApi } from '../api'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
@@ -125,25 +240,124 @@ const { isMobile } = useMobile()
|
||||
const router = useRouter()
|
||||
const store = useProjectsStore()
|
||||
|
||||
// Режим: вход или регистрация
|
||||
const isRegisterMode = ref(false)
|
||||
|
||||
// Вход
|
||||
const login = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const isAuthenticating = ref(false)
|
||||
const showSuccess = ref(false)
|
||||
const loginFocused = ref(false)
|
||||
const passwordFocused = ref(false)
|
||||
|
||||
// Регистрация
|
||||
const regName = ref('')
|
||||
const regUsername = ref('')
|
||||
const regPassword = ref('')
|
||||
const regTelegram = ref('')
|
||||
const regNameFocused = ref(false)
|
||||
const regUsernameFocused = ref(false)
|
||||
const regPasswordFocused = ref(false)
|
||||
const regTelegramFocused = ref(false)
|
||||
|
||||
// Общие
|
||||
const error = ref('')
|
||||
const errors = reactive({})
|
||||
const isProcessing = ref(false)
|
||||
const showSuccess = ref(false)
|
||||
|
||||
// Состояния тряски полей
|
||||
const shakeUsername = ref(false)
|
||||
const shakeName = ref(false)
|
||||
const shakeTelegram = ref(false)
|
||||
|
||||
// Функция тряски поля
|
||||
const triggerShake = (shakeRef) => {
|
||||
shakeRef.value = true
|
||||
setTimeout(() => { shakeRef.value = false }, 400)
|
||||
}
|
||||
|
||||
// Сброс ошибки пароля при вводе
|
||||
const clearPasswordError = () => {
|
||||
if (errors.password) delete errors.password
|
||||
}
|
||||
|
||||
// Фильтрация логина — начинается с буквы, далее a-zA-Z, 0-9, _
|
||||
const filterUsername = () => {
|
||||
// Сбрасываем ошибку при вводе
|
||||
if (errors.username) delete errors.username
|
||||
|
||||
const before = regUsername.value
|
||||
let value = before.replace(/[^a-zA-Z0-9_]/g, '')
|
||||
// Первый символ должен быть буквой — убираем цифры/_ из начала
|
||||
value = value.replace(/^[0-9_]+/, '')
|
||||
regUsername.value = value
|
||||
// Если что-то отфильтровалось — тряска
|
||||
if (before !== value && before.length >= value.length) {
|
||||
triggerShake(shakeUsername)
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтрация имени — только буквы, пробелы, дефис
|
||||
const filterName = () => {
|
||||
// Сбрасываем ошибку при вводе
|
||||
if (errors.name) delete errors.name
|
||||
|
||||
const before = regName.value
|
||||
let value = before.replace(/[^a-zA-Zа-яА-ЯёЁ\s\-]/g, '')
|
||||
value = value.replace(/\s+/g, ' ')
|
||||
regName.value = value
|
||||
// Если что-то отфильтровалось — тряска
|
||||
if (before !== value && before.length > value.length) {
|
||||
triggerShake(shakeName)
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтрация telegram — только a-z, 0-9, _ и начинается с @
|
||||
const filterTelegram = () => {
|
||||
const before = regTelegram.value
|
||||
let value = before
|
||||
value = value.replace(/@/g, '')
|
||||
value = value.replace(/[^a-zA-Z0-9_]/g, '').toLowerCase()
|
||||
const after = value ? '@' + value : ''
|
||||
regTelegram.value = after
|
||||
// Если что-то отфильтровалось (кроме добавления @) — тряска
|
||||
const beforeClean = before.replace(/@/g, '').toLowerCase()
|
||||
if (beforeClean !== value && beforeClean.length > value.length) {
|
||||
triggerShake(shakeTelegram)
|
||||
}
|
||||
}
|
||||
|
||||
// Не даём удалить @ — очищаем поле полностью
|
||||
const preventDeleteAt = (e) => {
|
||||
if ((e.key === 'Backspace' || e.key === 'Delete') && regTelegram.value === '@') {
|
||||
e.preventDefault()
|
||||
regTelegram.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Переключение режимов
|
||||
const switchToRegister = () => {
|
||||
error.value = ''
|
||||
Object.keys(errors).forEach(k => delete errors[k])
|
||||
isRegisterMode.value = true
|
||||
nextTick(refreshIcons)
|
||||
}
|
||||
|
||||
const switchToLogin = () => {
|
||||
error.value = ''
|
||||
Object.keys(errors).forEach(k => delete errors[k])
|
||||
isRegisterMode.value = false
|
||||
regTelegram.value = ''
|
||||
nextTick(refreshIcons)
|
||||
}
|
||||
|
||||
// Предзагрузка данных пока показывается анимация успеха
|
||||
const prefetchData = async () => {
|
||||
try {
|
||||
// Загружаем проекты, колонки, отделы, лейблы, юзеров
|
||||
await store.init()
|
||||
|
||||
// Если есть текущий проект — загружаем карточки
|
||||
if (store.currentProjectId) {
|
||||
const result = await cardsApi.getAll(store.currentProjectId)
|
||||
if (result.success) {
|
||||
// Сохраняем в sessionStorage для MainApp
|
||||
sessionStorage.setItem('prefetchedCards', JSON.stringify(result.data))
|
||||
}
|
||||
}
|
||||
@@ -152,38 +366,93 @@ const prefetchData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Вход
|
||||
const handleLogin = async () => {
|
||||
error.value = ''
|
||||
isAuthenticating.value = true
|
||||
isProcessing.value = true
|
||||
|
||||
try {
|
||||
const data = await authApi.login(login.value, password.value)
|
||||
|
||||
if (data.success) {
|
||||
// Устанавливаем кэш авторизации (чтобы навигация между страницами была мгновенной)
|
||||
setAuthCache(true)
|
||||
|
||||
// Показываем анимацию успеха
|
||||
showSuccess.value = true
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
|
||||
// Параллельно: предзагрузка данных + ожидание анимации
|
||||
const prefetchPromise = prefetchData()
|
||||
const animationPromise = new Promise(resolve => setTimeout(resolve, 1800))
|
||||
|
||||
// Ждём обоих (минимум 1.8 сек анимации)
|
||||
await Promise.all([prefetchPromise, animationPromise])
|
||||
|
||||
router.push('/')
|
||||
} else {
|
||||
isAuthenticating.value = false
|
||||
isProcessing.value = false
|
||||
error.value = data.errors?.username || data.errors?.password || 'Неверный логин или пароль'
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
}
|
||||
} catch (e) {
|
||||
isAuthenticating.value = false
|
||||
isProcessing.value = false
|
||||
error.value = 'Ошибка подключения к серверу'
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
}
|
||||
}
|
||||
|
||||
// Регистрация
|
||||
const handleRegister = async () => {
|
||||
error.value = ''
|
||||
Object.keys(errors).forEach(k => delete errors[k])
|
||||
isProcessing.value = true
|
||||
|
||||
try {
|
||||
// Убираем @ из telegram при отправке, если только @ — отправляем null
|
||||
const telegramValue = regTelegram.value.replace('@', '').trim()
|
||||
|
||||
const data = await authApi.register({
|
||||
name: regName.value.trim(),
|
||||
username: regUsername.value.trim(),
|
||||
password: regPassword.value,
|
||||
telegram: telegramValue || null
|
||||
})
|
||||
|
||||
if (data.success) {
|
||||
// Автоматически входим после регистрации
|
||||
const loginResult = await authApi.login(regUsername.value.trim(), regPassword.value)
|
||||
|
||||
if (loginResult.success) {
|
||||
setAuthCache(true)
|
||||
showSuccess.value = true
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
|
||||
const prefetchPromise = prefetchData()
|
||||
const animationPromise = new Promise(resolve => setTimeout(resolve, 1800))
|
||||
await Promise.all([prefetchPromise, animationPromise])
|
||||
|
||||
router.push('/')
|
||||
} else {
|
||||
// Если автовход не удался — переключаем на форму входа
|
||||
isProcessing.value = false
|
||||
switchToLogin()
|
||||
error.value = 'Аккаунт создан. Войдите с вашими данными.'
|
||||
}
|
||||
} else {
|
||||
isProcessing.value = false
|
||||
// Показываем ошибки с бэка
|
||||
if (data.errors) {
|
||||
Object.assign(errors, data.errors)
|
||||
// Берём первую ошибку для отображения
|
||||
const firstError = Object.values(data.errors)[0]
|
||||
error.value = firstError || 'Ошибка регистрации'
|
||||
} else {
|
||||
error.value = 'Ошибка регистрации'
|
||||
}
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
}
|
||||
} catch (e) {
|
||||
isProcessing.value = false
|
||||
error.value = 'Ошибка подключения к серверу'
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
@@ -198,8 +467,7 @@ const refreshIcons = () => {
|
||||
|
||||
onMounted(refreshIcons)
|
||||
|
||||
// Обновляем иконки только когда меняется состояние успеха
|
||||
watch(showSuccess, () => {
|
||||
watch([showSuccess, isRegisterMode], () => {
|
||||
nextTick(refreshIcons)
|
||||
})
|
||||
</script>
|
||||
@@ -403,6 +671,10 @@ watch(showSuccess, () => {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.input-group.has-error .input-icon {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
@@ -455,6 +727,29 @@ watch(showSuccess, () => {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.input-group.has-error .input-line::after {
|
||||
background: #f87171;
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
/* Анимация тряски при неверном вводе */
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-4px); }
|
||||
40% { transform: translateX(4px); }
|
||||
60% { transform: translateX(-3px); }
|
||||
80% { transform: translateX(3px); }
|
||||
}
|
||||
|
||||
.input-group.shake {
|
||||
animation: shake 0.4s ease-out;
|
||||
}
|
||||
|
||||
.input-group.shake .input-line::after {
|
||||
background: #f87171 !important;
|
||||
transform: scaleX(1) !important;
|
||||
}
|
||||
|
||||
/* Ошибка */
|
||||
.error-message {
|
||||
display: flex;
|
||||
@@ -559,6 +854,31 @@ watch(showSuccess, () => {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* Переключение между входом и регистрацией */
|
||||
.form-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.switch-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.switch-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Состояние успеха */
|
||||
.success-state {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user