1
0

Большое обновление

1. Создание личных проектов
2. Управление командой
3. Приглашение участников
4. Уведомления

и многое другое...
This commit is contained in:
2026-01-18 20:17:02 +07:00
parent 250eac70a7
commit 190b4d0a5e
51 changed files with 6179 additions and 426 deletions

View File

@@ -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;