Большое обновление
1. Создание личных проектов 2. Управление командой 3. Приглашение участников 4. Уведомления и многое другое...
This commit is contained in:
@@ -2,10 +2,17 @@
|
||||
window.APP_CONFIG = {
|
||||
API_BASE: 'http://192.168.1.6',
|
||||
|
||||
// Интервал автообновления данных (в секундах)
|
||||
IDLE_REFRESH_SECONDS: 1,
|
||||
// Интервалы автообновления данных (в секундах)
|
||||
REFRESH_INTERVALS: {
|
||||
cards: 2, // Карточки на доске
|
||||
comments: 5, // Комментарии к задаче
|
||||
invites: 10 // Приглашения на странице без проектов
|
||||
},
|
||||
|
||||
// Брейкпоинт для мобильной версии (px)
|
||||
MOBILE_BREAKPOINT: 1400
|
||||
MOBILE_BREAKPOINT: 1400,
|
||||
|
||||
// Автообновление страницы (F5) — интервал в секундах, 0 = отключено
|
||||
AUTO_REFRESH_SECONDS: 500
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 8.0 KiB |
@@ -2,11 +2,14 @@
|
||||
<div class="pwa-safe-wrapper">
|
||||
<router-view />
|
||||
</div>
|
||||
<!-- Глобальные toast-уведомления -->
|
||||
<ToastContainer />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { initMobileClass } from './composables/useMobile'
|
||||
import ToastContainer from './components/ui/ToastContainer.vue'
|
||||
|
||||
let cleanup = null
|
||||
|
||||
@@ -27,6 +30,17 @@ onUnmounted(() => {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Отключаем 300ms задержку на тапах для всех кликабельных элементов */
|
||||
button,
|
||||
a,
|
||||
[role="button"],
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
label {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* CSS переменные (цветовая палитра) */
|
||||
:root {
|
||||
--bg-body: #111113;
|
||||
|
||||
@@ -11,12 +11,13 @@ export const getFullUrl = (url) => {
|
||||
}
|
||||
|
||||
// Базовая функция запроса с глобальной проверкой сессии
|
||||
const request = async (endpoint, options = {}) => {
|
||||
const request = async (endpoint, options = {}, skipSessionCheck = false) => {
|
||||
const res = await fetch(`${API_BASE}${endpoint}`, options)
|
||||
const data = await res.json()
|
||||
|
||||
// Глобальная проверка: если сессия истекла — редирект на логин
|
||||
if (data.success === false && data.errors?.session) {
|
||||
// Пропускаем для эндпоинтов авторизации/регистрации
|
||||
if (!skipSessionCheck && data.success === false && data.errors?.session) {
|
||||
// Очищаем кэш авторизации (через window чтобы избежать циклической зависимости)
|
||||
if (window.__clearAuthCache) window.__clearAuthCache()
|
||||
// Редирект на логин (если ещё не там)
|
||||
@@ -35,19 +36,25 @@ export const authApi = {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'auth_login', username, password })
|
||||
}),
|
||||
}, true), // skipSessionCheck — это авторизация
|
||||
register: ({ name, username, password, telegram }) => request('/api/user', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'create_user', name, username, password, telegram })
|
||||
}, true), // skipSessionCheck — это регистрация
|
||||
check: () => request('/api/user', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'check_session' })
|
||||
}),
|
||||
}, true), // skipSessionCheck — это проверка сессии
|
||||
logout: () => request('/api/user', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'logout' })
|
||||
})
|
||||
}, true) // skipSessionCheck — это выход
|
||||
}
|
||||
|
||||
// ==================== PROJECTS ====================
|
||||
@@ -215,7 +222,105 @@ export const commentImageApi = {
|
||||
|
||||
// ==================== USERS ====================
|
||||
export const usersApi = {
|
||||
getAll: () => request('/api/user', { credentials: 'include' })
|
||||
// Получить участников проекта (id_project обязателен)
|
||||
getAll: (id_project) => request(`/api/user?id_project=${id_project}`, { credentials: 'include' }),
|
||||
|
||||
// Поиск пользователя по логину
|
||||
search: (username) => request('/api/user', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'search', username })
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== PROJECT INVITES ====================
|
||||
export const projectInviteApi = {
|
||||
// Получить свои pending-приглашения
|
||||
getMyPending: () => request('/api/projectInvite', { credentials: 'include' }),
|
||||
|
||||
// Получить количество pending-приглашений
|
||||
getCount: () => request('/api/projectInvite', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'get_count' })
|
||||
}),
|
||||
|
||||
// Отправить приглашение
|
||||
send: (project_id, user_id, is_admin = false, permissions = null) => request('/api/projectInvite', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'send', project_id, user_id, is_admin, permissions })
|
||||
}),
|
||||
|
||||
// Проверить, есть ли pending-приглашение
|
||||
checkPending: (project_id, user_id) => request('/api/projectInvite', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'check_pending', project_id, user_id })
|
||||
}),
|
||||
|
||||
// Получить pending-приглашения проекта (для админа)
|
||||
getProjectPending: (project_id) => request('/api/projectInvite', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'get_project_pending', project_id })
|
||||
}),
|
||||
|
||||
// Принять приглашение
|
||||
accept: (invite_id) => request('/api/projectInvite', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'accept', invite_id })
|
||||
}),
|
||||
|
||||
// Отклонить приглашение
|
||||
decline: (invite_id) => request('/api/projectInvite', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'decline', invite_id })
|
||||
}),
|
||||
|
||||
// Отменить приглашение (для админа)
|
||||
cancel: (invite_id, project_id) => request('/api/projectInvite', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'cancel', invite_id, project_id })
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== PROJECT ACCESS ====================
|
||||
export const projectAccessApi = {
|
||||
// Добавление участника напрямую (используется после принятия инвайта — внутренний метод)
|
||||
addMember: (project_id, user_id, is_admin = false, permissions = null) => request('/api/projectAccess', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'add_member', project_id, user_id, is_admin, permissions })
|
||||
}),
|
||||
|
||||
// Обновление прав участника
|
||||
updateMember: (project_id, user_id, { is_admin, permissions } = {}) => request('/api/projectAccess', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'update_member', project_id, user_id, is_admin, permissions })
|
||||
}),
|
||||
|
||||
// Удаление участника из проекта
|
||||
removeMember: (project_id, user_id) => request('/api/projectAccess', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'remove_member', project_id, user_id })
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== SERVER ====================
|
||||
|
||||
@@ -126,7 +126,7 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['stats-updated', 'open-task', 'create-task'])
|
||||
const emit = defineEmits(['stats-updated', 'open-task', 'create-task', 'cards-moved'])
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
@@ -140,8 +140,18 @@ onUpdated(refreshIcons)
|
||||
// Локальная копия карточек для optimistic UI
|
||||
const localCards = ref([])
|
||||
|
||||
// Флаг для предотвращения race condition при drag-and-drop
|
||||
// Пока карточка перемещается, не перезаписываем localCards данными из props
|
||||
const isMovingCard = ref(false)
|
||||
|
||||
// Синхронизируем с props при загрузке/смене проекта
|
||||
watch(() => props.cards, (newCards) => {
|
||||
// Если идёт перемещение карточки, не перезаписываем локальное состояние
|
||||
// чтобы избежать "прыжка" из-за stale данных от polling
|
||||
if (isMovingCard.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// Копируем данные и добавляем order если нет
|
||||
localCards.value = JSON.parse(JSON.stringify(newCards)).map((card, idx) => ({
|
||||
...card,
|
||||
@@ -219,6 +229,9 @@ const handleDropCard = async ({ cardId, fromColumnId, toColumnId, toIndex }) =>
|
||||
const card = localCards.value.find(c => c.id === cardId)
|
||||
if (!card) return
|
||||
|
||||
// Устанавливаем флаг чтобы watcher не перезаписывал localCards
|
||||
isMovingCard.value = true
|
||||
|
||||
// Локально обновляем для мгновенного отклика
|
||||
card.column_id = toColumnId
|
||||
|
||||
@@ -240,8 +253,16 @@ const handleDropCard = async ({ cardId, fromColumnId, toColumnId, toIndex }) =>
|
||||
c.order = idx
|
||||
})
|
||||
|
||||
// Отправляем на сервер (сервер сам пересчитает order для всех)
|
||||
await cardsApi.updateOrder(cardId, toColumnId, toIndex)
|
||||
try {
|
||||
// Отправляем на сервер (сервер сам пересчитает order для всех)
|
||||
await cardsApi.updateOrder(cardId, toColumnId, toIndex)
|
||||
|
||||
// После успешного обновления, сообщаем родителю чтобы он обновил данные
|
||||
emit('cards-moved')
|
||||
} finally {
|
||||
// Сбрасываем флаг после завершения операции
|
||||
isMovingCard.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Генератор id для новых карточек
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="card"
|
||||
:draggable="!isMobile"
|
||||
:draggable="!isMobile && canMove"
|
||||
@dragstart="handleDragStart"
|
||||
@dragend="handleDragEnd"
|
||||
@touchstart="handleTouchStart"
|
||||
@@ -11,7 +11,7 @@
|
||||
@mouseup="handleMouseUp"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@contextmenu="handleContextMenu"
|
||||
:class="{ dragging: isDragging, 'has-label-color': cardLabelColor, 'long-pressing': isLongPressing }"
|
||||
:class="{ dragging: isDragging, 'has-label-color': cardLabelColor, 'long-pressing': isLongPressing, 'no-move': !canMove }"
|
||||
:style="cardLabelColor ? { '--label-bg': cardLabelColor } : {}"
|
||||
>
|
||||
<div class="card-header">
|
||||
@@ -24,6 +24,23 @@
|
||||
>
|
||||
{{ cardDepartment.name_departments }}
|
||||
</span>
|
||||
<!-- Индикаторы ограничений прав -->
|
||||
<div v-if="!canEdit || !canMove" class="permissions-indicators">
|
||||
<span
|
||||
v-if="!canEdit"
|
||||
class="perm-icon no-edit"
|
||||
title="Нет прав на редактирование"
|
||||
>
|
||||
<i data-lucide="pencil-off"></i>
|
||||
</span>
|
||||
<span
|
||||
v-if="!canMove"
|
||||
class="perm-icon no-move-icon"
|
||||
title="Нет прав на перемещение"
|
||||
>
|
||||
<i data-lucide="lock"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button
|
||||
@@ -91,7 +108,15 @@ useLucideIcons()
|
||||
const props = defineProps({
|
||||
card: Object,
|
||||
columnId: [String, Number],
|
||||
index: Number
|
||||
index: Number,
|
||||
canMove: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
canEdit: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['archive', 'move-request'])
|
||||
@@ -102,7 +127,7 @@ let longPressTimer = null
|
||||
let touchStartPos = { x: 0, y: 0 }
|
||||
|
||||
const handleTouchStart = (e) => {
|
||||
if (!isMobile.value) return
|
||||
if (!isMobile.value || !props.canMove) return
|
||||
|
||||
const touch = e.touches[0]
|
||||
touchStartPos = { x: touch.clientX, y: touch.clientY }
|
||||
@@ -154,7 +179,7 @@ const handleContextMenu = (e) => {
|
||||
|
||||
// Mouse long-press для ПК в мобильном режиме (когда touch события недоступны)
|
||||
const handleMouseDown = (e) => {
|
||||
if (!isMobile.value) return
|
||||
if (!isMobile.value || !props.canMove) return
|
||||
// Только левая кнопка мыши
|
||||
if (e.button !== 0) return
|
||||
|
||||
@@ -189,6 +214,10 @@ const handleMouseLeave = () => {
|
||||
const isDragging = ref(false)
|
||||
|
||||
const handleDragStart = (e) => {
|
||||
if (!props.canMove) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
isDragging.value = true
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('cardId', props.card.id.toString())
|
||||
@@ -248,6 +277,10 @@ const handleArchive = () => {
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.card.no-move {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
@@ -424,4 +457,34 @@ const handleArchive = () => {
|
||||
.btn-archive-card.always-visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ========== ИНДИКАТОРЫ ПРАВ ========== */
|
||||
.permissions-indicators {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.perm-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.perm-icon i,
|
||||
.perm-icon svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.perm-icon.no-edit {
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
.perm-icon.no-move-icon {
|
||||
color: var(--red);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
:card="card"
|
||||
:column-id="column.id"
|
||||
:index="index"
|
||||
:can-move="store.canMoveTask(card)"
|
||||
:can-edit="store.canEditTask(card)"
|
||||
@click="emit('open-task', card)"
|
||||
@archive="emit('archive-task', $event)"
|
||||
@move-request="emit('move-request', $event)"
|
||||
@@ -44,8 +46,10 @@
|
||||
import { ref, onMounted, onUpdated } from 'vue'
|
||||
import Card from './Card.vue'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
const store = useProjectsStore()
|
||||
|
||||
const props = defineProps({
|
||||
column: Object
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="datepicker" ref="datepickerRef">
|
||||
<div class="datepicker-trigger" :class="{ mobile: isMobile }" @click="toggleCalendar">
|
||||
<div class="datepicker" :class="{ disabled: disabled }" ref="datepickerRef">
|
||||
<div class="datepicker-trigger" :class="{ mobile: isMobile, disabled: disabled }" @click="!disabled && toggleCalendar()">
|
||||
<i data-lucide="calendar"></i>
|
||||
<span v-if="modelValue" class="date-text">{{ formatCompactDate(modelValue) }}</span>
|
||||
<span v-else class="placeholder">Выберите дату</span>
|
||||
<button v-if="modelValue" class="clear-btn" @click.stop="clearDate">
|
||||
<button v-if="modelValue && !disabled" class="clear-btn" @click.stop="clearDate">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -102,7 +102,11 @@ import { useMobile } from '../composables/useMobile'
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: String
|
||||
modelValue: String,
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
@@ -336,6 +340,15 @@ watch(isOpen, () => {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.datepicker-trigger.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.datepicker-trigger.disabled:hover {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.datepicker-trigger i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
|
||||
@@ -2,18 +2,17 @@
|
||||
<header class="header" :class="{ mobile: isMobile }">
|
||||
<!-- Десктоп версия -->
|
||||
<template v-if="!isMobile">
|
||||
<div class="header-left">
|
||||
<div v-if="title || $slots.filters" class="header-left">
|
||||
<div class="title-row">
|
||||
<h1 class="page-title">{{ title }}</h1>
|
||||
<h1 v-if="title" class="page-title">{{ title }}</h1>
|
||||
<slot name="filters"></slot>
|
||||
</div>
|
||||
<p v-if="subtitle" class="page-subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<slot name="actions"></slot>
|
||||
<slot name="stats"></slot>
|
||||
<button class="logout-btn" @click="logout" title="Выйти">
|
||||
<i data-lucide="log-out"></i>
|
||||
</button>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -21,11 +20,10 @@
|
||||
<template v-else>
|
||||
<!-- Компактная строка: заголовок слева + иконки-кнопки -->
|
||||
<div class="mobile-header-row">
|
||||
<h1 v-if="!$slots['mobile-filters']" class="mobile-title">{{ title }}</h1>
|
||||
<h1 v-if="title && !$slots['mobile-filters']" class="mobile-title">{{ title }}</h1>
|
||||
<slot name="mobile-filters"></slot>
|
||||
<button class="mobile-logout-btn" @click="logout" title="Выйти">
|
||||
<i data-lucide="log-out"></i>
|
||||
</button>
|
||||
<slot name="actions"></slot>
|
||||
<LogoutButton :mobile="true" />
|
||||
</div>
|
||||
</template>
|
||||
</header>
|
||||
@@ -33,10 +31,8 @@
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUpdated } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { authApi } from '../api'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
import { clearAuthCache } from '../router'
|
||||
import LogoutButton from './ui/LogoutButton.vue'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
@@ -51,14 +47,6 @@ defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const logout = async () => {
|
||||
clearAuthCache()
|
||||
await authApi.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
@@ -116,31 +104,6 @@ onUpdated(refreshIcons)
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.logout-btn i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* ========== MOBILE ========== */
|
||||
.header.mobile {
|
||||
padding: 10px 16px;
|
||||
@@ -162,31 +125,4 @@ onUpdated(refreshIcons)
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-logout-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mobile-logout-btn:hover,
|
||||
.mobile-logout-btn:active {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.mobile-logout-btn i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
</style>
|
||||
|
||||
955
front_vue/src/components/MemberPanel.vue
Normal file
955
front_vue/src/components/MemberPanel.vue
Normal file
@@ -0,0 +1,955 @@
|
||||
<template>
|
||||
<SlidePanel
|
||||
:show="show"
|
||||
@close="handleClose"
|
||||
>
|
||||
<template #header>
|
||||
<h2>{{ isEdit ? 'Редактирование участника' : 'Пригласить участника' }}</h2>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<!-- Поиск пользователя (только для нового) -->
|
||||
<div v-if="!isEdit" class="form-section">
|
||||
<label class="form-label">Логин пользователя</label>
|
||||
<TextInput
|
||||
v-model="form.username"
|
||||
placeholder="Введите логин для поиска"
|
||||
/>
|
||||
<p v-if="searching" class="form-hint">Поиск...</p>
|
||||
<p v-else-if="searchError" class="form-error">{{ searchError }}</p>
|
||||
<div v-else-if="foundUser" class="found-user" :class="{ 'is-me': isFoundUserMe, 'is-member': isFoundUserAlreadyMember, 'is-pending': isFoundUserPendingInvite }">
|
||||
<div class="found-user-avatar">
|
||||
<img v-if="foundUser.avatar_url" :src="getFullUrl(foundUser.avatar_url)" :alt="foundUser.name || foundUser.username">
|
||||
<span v-else class="avatar-placeholder">{{ (foundUser.name || foundUser.username || '?')[0] }}</span>
|
||||
</div>
|
||||
<div class="found-user-info">
|
||||
<span v-if="isFoundUserMe" class="found-user-me">Это же вы...</span>
|
||||
<span v-else-if="isFoundUserAlreadyMember" class="found-user-member">Участник уже в этом проекте</span>
|
||||
<span v-else-if="isFoundUserPendingInvite" class="found-user-pending">Пользователь уже приглашён, ожидается ответ</span>
|
||||
<template v-else>
|
||||
<span class="found-user-name">{{ foundUser.name || foundUser.username }}</span>
|
||||
<span v-if="foundUser.name" class="found-user-username">@{{ foundUser.username }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<i v-if="!isFoundUserMe && !isFoundUserAlreadyMember && !isFoundUserPendingInvite" data-lucide="check-circle" class="found-user-check"></i>
|
||||
<i v-else-if="isFoundUserPendingInvite" data-lucide="clock" class="found-user-pending-icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Инфо о пользователе (для редактирования) -->
|
||||
<div v-else class="member-info">
|
||||
<div class="member-avatar">
|
||||
<img v-if="member?.avatar_url" :src="getFullUrl(member.avatar_url)" :alt="member.name || member.username">
|
||||
<span v-else class="avatar-placeholder">{{ (member?.name || member?.username || '?')[0] }}</span>
|
||||
</div>
|
||||
<div class="member-details">
|
||||
<h3>{{ member?.name || member?.username || 'Без имени' }}</h3>
|
||||
<span v-if="member?.is_owner" class="member-badge owner">Создатель</span>
|
||||
<span v-else-if="member?.is_admin" class="member-badge admin">Администратор</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Админ чекбокс (не для владельцев) -->
|
||||
<div v-if="!member?.is_owner" class="form-section">
|
||||
<label class="admin-toggle" :class="{ active: form.is_admin }">
|
||||
<div class="admin-toggle-info">
|
||||
<i data-lucide="shield"></i>
|
||||
<div class="admin-toggle-text">
|
||||
<span class="admin-toggle-label">Администратор</span>
|
||||
<span class="admin-toggle-hint">Полный доступ ко всем функциям проекта</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toggle-switch large" :class="{ on: form.is_admin }">
|
||||
<input type="checkbox" v-model="form.is_admin" @click.stop />
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Права (скрыты для админов) -->
|
||||
<div v-if="!form.is_admin && !member?.is_owner" class="form-section permissions-section">
|
||||
<label class="form-label">Права доступа</label>
|
||||
|
||||
<div v-for="group in permissionsGroups" :key="group.title" class="permission-group">
|
||||
<div class="permission-group-title">{{ group.title }}</div>
|
||||
<div class="permission-group-list">
|
||||
<label
|
||||
v-for="perm in group.permissions"
|
||||
:key="perm.key"
|
||||
v-show="!isPermissionHidden(perm)"
|
||||
class="permission-row"
|
||||
:class="{
|
||||
active: form.permissions[perm.key],
|
||||
sub: perm.hiddenBy
|
||||
}"
|
||||
>
|
||||
<i v-if="perm.hiddenBy" data-lucide="corner-down-right" class="permission-branch"></i>
|
||||
<span class="permission-name">{{ perm.label }}</span>
|
||||
<div class="toggle-switch" :class="{ on: form.permissions[perm.key] }">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.permissions[perm.key]"
|
||||
@click.stop
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<!-- Кнопка удаления (только для редактирования, не для владельцев) -->
|
||||
<button
|
||||
v-if="isEdit && !member?.is_owner"
|
||||
class="btn-delete"
|
||||
@click="confirmRemove"
|
||||
:disabled="saving"
|
||||
>
|
||||
<i data-lucide="trash-2"></i>
|
||||
<span class="btn-delete-text">Удалить из проекта</span>
|
||||
</button>
|
||||
<span v-else></span>
|
||||
|
||||
<ActionButtons
|
||||
:cancelText="'Отмена'"
|
||||
:saveText="isEdit ? 'Сохранить' : 'Пригласить'"
|
||||
:saveIcon="isEdit ? '' : 'send'"
|
||||
:loading="saving"
|
||||
:disabled="!canSave"
|
||||
@cancel="handleClose"
|
||||
@save="handleSave"
|
||||
/>
|
||||
</template>
|
||||
</SlidePanel>
|
||||
|
||||
<!-- Диалог подтверждения удаления -->
|
||||
<ConfirmDialog
|
||||
:show="showRemoveDialog"
|
||||
type="removeMember"
|
||||
:message="removeDialogMessage"
|
||||
:action="doRemoveMember"
|
||||
@confirm="showRemoveDialog = false"
|
||||
@cancel="showRemoveDialog = false"
|
||||
/>
|
||||
|
||||
<!-- Диалог несохранённых изменений -->
|
||||
<ConfirmDialog
|
||||
:show="showUnsavedDialog"
|
||||
type="unsavedChanges"
|
||||
@confirm="saveAndClose"
|
||||
@cancel="showUnsavedDialog = false"
|
||||
@discard="discardAndClose"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUpdated } from 'vue'
|
||||
import SlidePanel from './ui/SlidePanel.vue'
|
||||
import TextInput from './ui/TextInput.vue'
|
||||
import ActionButtons from './ui/ActionButtons.vue'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
import { getFullUrl, usersApi, projectAccessApi, projectInviteApi } from '../api'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
member: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'saved'])
|
||||
|
||||
const store = useProjectsStore()
|
||||
|
||||
// Режим редактирования
|
||||
const isEdit = computed(() => !!props.member)
|
||||
|
||||
// Форма
|
||||
const form = ref({
|
||||
username: '',
|
||||
is_admin: false,
|
||||
permissions: {}
|
||||
})
|
||||
|
||||
// Состояния
|
||||
const saving = ref(false)
|
||||
const searching = ref(false)
|
||||
const searchError = ref('')
|
||||
const foundUser = ref(null)
|
||||
let searchTimeout = null
|
||||
|
||||
// Найденный пользователь — это я?
|
||||
const isFoundUserMe = computed(() =>
|
||||
foundUser.value && Number(foundUser.value.id) === store.currentUserId
|
||||
)
|
||||
|
||||
// Найденный пользователь уже участник проекта?
|
||||
const isFoundUserAlreadyMember = computed(() => {
|
||||
if (!foundUser.value) return false
|
||||
const userId = Number(foundUser.value.id)
|
||||
return store.users.some(u => Number(u.id_user) === userId)
|
||||
})
|
||||
|
||||
// Найденный пользователь уже приглашён (pending)?
|
||||
const isFoundUserPendingInvite = ref(false)
|
||||
|
||||
// Поиск пользователя с debounce
|
||||
const searchUser = async (username) => {
|
||||
if (!username || username.length < 2) {
|
||||
foundUser.value = null
|
||||
searchError.value = ''
|
||||
isFoundUserPendingInvite.value = false
|
||||
return
|
||||
}
|
||||
|
||||
searching.value = true
|
||||
searchError.value = ''
|
||||
isFoundUserPendingInvite.value = false
|
||||
|
||||
try {
|
||||
const result = await usersApi.search(username)
|
||||
if (result.success) {
|
||||
foundUser.value = result.data
|
||||
searchError.value = ''
|
||||
|
||||
// Проверяем, нет ли уже pending-приглашения
|
||||
const userId = Number(result.data.id)
|
||||
if (userId !== store.currentUserId && !store.users.some(u => Number(u.id_user) === userId)) {
|
||||
const pendingCheck = await projectInviteApi.checkPending(store.currentProjectId, userId)
|
||||
isFoundUserPendingInvite.value = pendingCheck.success && pendingCheck.has_pending
|
||||
}
|
||||
} else {
|
||||
foundUser.value = null
|
||||
searchError.value = result.errors?.username || 'Пользователь не найден'
|
||||
}
|
||||
} catch (error) {
|
||||
foundUser.value = null
|
||||
searchError.value = 'Ошибка поиска'
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced поиск при вводе
|
||||
watch(() => form.value.username, (newVal) => {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
foundUser.value = null
|
||||
searchError.value = ''
|
||||
isFoundUserPendingInvite.value = false
|
||||
|
||||
if (newVal && newVal.length >= 2) {
|
||||
searchTimeout = setTimeout(() => searchUser(newVal), 400)
|
||||
}
|
||||
})
|
||||
|
||||
// Группы прав с зависимостями
|
||||
// hiddenBy: если указанное право активно — это право скрывается и автоматически включается
|
||||
// Порядок: включённые по умолчанию первыми
|
||||
const permissionsGroups = [
|
||||
{
|
||||
title: 'Задачи',
|
||||
permissions: [
|
||||
{ key: 'create_task', label: 'Создание задач' },
|
||||
{ key: 'archive_task', label: 'Архивирование задач' },
|
||||
{ key: 'edit_task', label: 'Редактирование любых задач' },
|
||||
{ key: 'edit_own_task_only', label: 'Только назначенные на себя', hiddenBy: 'edit_task' },
|
||||
{ key: 'move_task', label: 'Перемещение любых задач' },
|
||||
{ key: 'move_own_task_only', label: 'Только назначенные на себя', hiddenBy: 'move_task' },
|
||||
{ key: 'delete_task', label: 'Удаление задач' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Комментарии',
|
||||
permissions: [
|
||||
{ key: 'create_comment', label: 'Создание в любых задачах' },
|
||||
{ key: 'create_comment_own_task_only', label: 'Только в назначенных на себя', hiddenBy: 'create_comment' },
|
||||
{ key: 'delete_all_comments', label: 'Удаление любых комментариев' },
|
||||
{ key: 'delete_own_comments', label: 'Только своих комментариев', hiddenBy: 'delete_all_comments' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Файлы',
|
||||
permissions: [
|
||||
{ key: 'upload_files', label: 'Загрузка файлов' },
|
||||
{ key: 'upload_images', label: 'Загрузка картинок' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Колонки',
|
||||
permissions: [
|
||||
{ key: 'create_column', label: 'Создание колонок' },
|
||||
{ key: 'edit_column', label: 'Редактирование колонок' },
|
||||
{ key: 'delete_column', label: 'Удаление колонок' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Управление',
|
||||
permissions: [
|
||||
{ key: 'manage_departments', label: 'Управление отделами' },
|
||||
{ key: 'remove_members', label: 'Удаление участников' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
// Проверка, должно ли право быть скрыто
|
||||
const isPermissionHidden = (perm) => {
|
||||
if (!perm.hiddenBy) return false
|
||||
return form.value.permissions[perm.hiddenBy] === true
|
||||
}
|
||||
|
||||
// Права по умолчанию
|
||||
const defaultPermissions = {
|
||||
create_task: true,
|
||||
edit_task: false,
|
||||
edit_own_task_only: true,
|
||||
delete_task: false,
|
||||
move_task: false,
|
||||
move_own_task_only: true,
|
||||
archive_task: true,
|
||||
create_column: false,
|
||||
edit_column: false,
|
||||
delete_column: false,
|
||||
manage_departments: false,
|
||||
remove_members: false,
|
||||
create_comment: false,
|
||||
create_comment_own_task_only: true,
|
||||
delete_own_comments: true,
|
||||
delete_all_comments: false,
|
||||
upload_files: true,
|
||||
upload_images: true
|
||||
}
|
||||
|
||||
// Можно сохранять
|
||||
const canSave = computed(() => {
|
||||
if (isEdit.value) {
|
||||
return !props.member?.is_owner
|
||||
}
|
||||
// Для приглашения нужен найденный пользователь (не я, не уже участник, не pending)
|
||||
return !!foundUser.value && !searching.value && !isFoundUserMe.value && !isFoundUserAlreadyMember.value && !isFoundUserPendingInvite.value
|
||||
})
|
||||
|
||||
// Инициализация формы
|
||||
const initForm = () => {
|
||||
if (props.member) {
|
||||
form.value = {
|
||||
username: '',
|
||||
is_admin: props.member.is_admin || false,
|
||||
permissions: { ...defaultPermissions, ...(props.member.permissions || {}) }
|
||||
}
|
||||
} else {
|
||||
form.value = {
|
||||
username: '',
|
||||
is_admin: false,
|
||||
permissions: { ...defaultPermissions }
|
||||
}
|
||||
}
|
||||
// Сохраняем начальное состояние для проверки изменений
|
||||
initialForm.value = JSON.parse(JSON.stringify(form.value))
|
||||
searchError.value = ''
|
||||
foundUser.value = null
|
||||
isFoundUserPendingInvite.value = false
|
||||
}
|
||||
|
||||
// Следим за открытием панели
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal) {
|
||||
initForm()
|
||||
}
|
||||
})
|
||||
|
||||
// Начальное состояние для проверки изменений
|
||||
const initialForm = ref(null)
|
||||
|
||||
// Проверка есть ли изменения
|
||||
const hasChanges = computed(() => {
|
||||
if (!initialForm.value) return false
|
||||
if (!isEdit.value) {
|
||||
// Для нового — есть изменения если найден пользователь
|
||||
return !!foundUser.value
|
||||
}
|
||||
// Для редактирования — сравниваем с начальным состоянием
|
||||
return JSON.stringify(form.value) !== JSON.stringify(initialForm.value)
|
||||
})
|
||||
|
||||
// Диалоги
|
||||
const showRemoveDialog = ref(false)
|
||||
const removeDialogMessage = ref('')
|
||||
const showUnsavedDialog = ref(false)
|
||||
|
||||
// Закрытие
|
||||
const handleClose = () => {
|
||||
if (hasChanges.value) {
|
||||
showUnsavedDialog.value = true
|
||||
return
|
||||
}
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Сохранить и закрыть (из диалога unsavedChanges)
|
||||
const saveAndClose = async () => {
|
||||
showUnsavedDialog.value = false
|
||||
await handleSave()
|
||||
}
|
||||
|
||||
// Отменить изменения и закрыть
|
||||
const discardAndClose = () => {
|
||||
showUnsavedDialog.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Сохранение
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const projectId = store.currentProjectId
|
||||
|
||||
if (isEdit.value) {
|
||||
// Обновление прав участника
|
||||
const result = await projectAccessApi.updateMember(
|
||||
projectId,
|
||||
props.member.id_user,
|
||||
{
|
||||
is_admin: form.value.is_admin,
|
||||
permissions: form.value.permissions
|
||||
}
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
console.error('Error updating member:', result.errors)
|
||||
toast.error('Ошибка сохранения прав')
|
||||
return
|
||||
}
|
||||
|
||||
toast.success('Права участника сохранены')
|
||||
} else {
|
||||
// Отправка приглашения новому участнику
|
||||
if (!foundUser.value) return
|
||||
|
||||
const result = await projectInviteApi.send(
|
||||
projectId,
|
||||
foundUser.value.id,
|
||||
form.value.is_admin,
|
||||
form.value.permissions
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
searchError.value = result.errors?.invite || 'Ошибка при отправке приглашения'
|
||||
toast.error(searchError.value)
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(`Приглашение отправлено ${foundUser.value.name || foundUser.value.username}`)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
emit('close') // Закрываем напрямую, без проверки hasChanges
|
||||
} catch (error) {
|
||||
console.error('Error saving member:', error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Открыть диалог удаления
|
||||
const confirmRemove = () => {
|
||||
removeDialogMessage.value = `<b>${props.member?.name || props.member?.username}</b> будет удалён из проекта.`
|
||||
showRemoveDialog.value = true
|
||||
}
|
||||
|
||||
// Выполнить удаление
|
||||
const doRemoveMember = async () => {
|
||||
const result = await projectAccessApi.removeMember(
|
||||
store.currentProjectId,
|
||||
props.member.id_user
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.errors?.member || 'Ошибка удаления')
|
||||
throw new Error(result.errors?.member || 'Ошибка удаления')
|
||||
}
|
||||
|
||||
toast.success('Участник удалён из проекта')
|
||||
emit('saved')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Обновление иконок
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshIcons)
|
||||
onUpdated(refreshIcons)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Уменьшаем отступ между первой секцией и админ-чекбоксом */
|
||||
.form-section:first-child {
|
||||
margin-bottom: -8px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: #ef4444;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Найденный пользователь */
|
||||
.found-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: rgba(0, 212, 170, 0.1);
|
||||
border: 1px solid rgba(0, 212, 170, 0.3);
|
||||
border-radius: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.found-user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.found-user-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.found-user-avatar .avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.found-user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.found-user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.found-user-username {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.found-user-check {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Найденный пользователь — это я */
|
||||
.found-user.is-me,
|
||||
.found-user.is-member {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.found-user-me,
|
||||
.found-user-member {
|
||||
font-size: 14px;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* Найденный пользователь уже приглашён */
|
||||
.found-user.is-pending {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.found-user-pending {
|
||||
font-size: 14px;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.found-user-pending-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #fbbf24;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Инфо участника */
|
||||
.member-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 12px;
|
||||
margin-bottom: -8px;
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.member-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.member-details h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.member-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
border-radius: 4px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.member-badge.owner {
|
||||
color: var(--accent);
|
||||
background: rgba(0, 212, 170, 0.15);
|
||||
}
|
||||
|
||||
.member-badge.admin {
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
|
||||
/* Тогл админа */
|
||||
.admin-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.admin-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.admin-toggle.active {
|
||||
background: rgba(0, 212, 170, 0.1);
|
||||
border-color: rgba(0, 212, 170, 0.3);
|
||||
}
|
||||
|
||||
.admin-toggle-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-toggle-info > i {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.admin-toggle.active .admin-toggle-info > i {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.admin-toggle-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-toggle-label {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.admin-toggle-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toggle-switch.large {
|
||||
width: 52px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 24px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toggle-slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.toggle-switch.large .toggle-slider::before {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.toggle-switch.on .toggle-slider {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.toggle-switch.on .toggle-slider::before {
|
||||
left: calc(100% - 21px);
|
||||
}
|
||||
|
||||
.toggle-switch.large.on .toggle-slider::before {
|
||||
left: calc(100% - 25px);
|
||||
}
|
||||
|
||||
/* Группы прав */
|
||||
.permissions-section {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.permission-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.permission-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.permission-group-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.permission-group-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.permission-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.permission-row:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.permission-row.active {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.permission-row.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -1px;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: rgba(0, 212, 170, 0.5);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.permission-row:not(.sub) + .permission-row:not(.sub) {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* Первый элемент без отрицательного top */
|
||||
.permission-row.active:first-child::before {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.permission-row.sub {
|
||||
padding-left: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.permission-branch {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.4;
|
||||
margin-right: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.permission-row.sub .permission-name {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.permission-name {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.permission-row.active .permission-name {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Кнопка удаления */
|
||||
.btn-delete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #ef4444;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.btn-delete:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-delete i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Мобильная версия: только иконка */
|
||||
@media (max-width: 480px) {
|
||||
.btn-delete {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.btn-delete-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -162,6 +162,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import SlidePanel from './ui/SlidePanel.vue'
|
||||
import TextInput from './ui/TextInput.vue'
|
||||
import ColorPicker from './ui/ColorPicker.vue'
|
||||
@@ -171,10 +172,13 @@ import ConfirmDialog from './ConfirmDialog.vue'
|
||||
import { useLucideIcons } from '../composables/useLucideIcons'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const { refreshIcons } = useLucideIcons()
|
||||
const toast = useToast()
|
||||
const { isMobile } = useMobile()
|
||||
const store = useProjectsStore()
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
@@ -491,8 +495,11 @@ const handleSave = async () => {
|
||||
// Обновляем колонки если есть изменения
|
||||
// Дефолтные колонки уже созданы сервером
|
||||
// Здесь можно добавить логику для кастомизации колонок при создании
|
||||
toast.success('Проект создан')
|
||||
emit('saved', result.id)
|
||||
emit('close')
|
||||
} else {
|
||||
toast.error('Ошибка создания проекта')
|
||||
}
|
||||
} else {
|
||||
// Редактирование проекта
|
||||
@@ -529,6 +536,7 @@ const handleSave = async () => {
|
||||
await store.reorderColumns(ids)
|
||||
}
|
||||
|
||||
toast.success('Изменения сохранены')
|
||||
emit('saved', props.project.id)
|
||||
emit('close')
|
||||
}
|
||||
@@ -568,10 +576,17 @@ const confirmDeleteProject = async () => {
|
||||
|
||||
const result = await store.deleteProject(props.project.id)
|
||||
if (!result.success) {
|
||||
toast.error(result.errors?.access || 'Ошибка удаления')
|
||||
throw new Error(result.errors?.access || 'Ошибка удаления')
|
||||
}
|
||||
|
||||
toast.success('Проект удалён')
|
||||
emit('close')
|
||||
|
||||
// Если после удаления нет проектов — переходим на страницу без проектов
|
||||
if (store.projects.length === 0) {
|
||||
router.push('/no-projects')
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== WATCH ====================
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
{{ project.name }}
|
||||
</button>
|
||||
<button
|
||||
v-if="project.id_admin"
|
||||
v-if="project.is_admin"
|
||||
class="project-edit-btn"
|
||||
title="Настройки проекта"
|
||||
@click.stop="handleEdit(project)"
|
||||
|
||||
@@ -16,15 +16,29 @@
|
||||
<router-link to="/team" class="nav-item" :class="{ active: $route.path === '/team' }" title="Команда">
|
||||
<i data-lucide="users"></i>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="pendingInvitesCount > 0"
|
||||
to="/invites"
|
||||
class="nav-item invites"
|
||||
:class="{ active: $route.path === '/invites' }"
|
||||
title="Приглашения"
|
||||
>
|
||||
<i data-lucide="mail"></i>
|
||||
<span class="invites-badge">{{ pendingInvitesCount }}</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUpdated } from 'vue'
|
||||
import { computed, onMounted, onUpdated } from 'vue'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
const store = useProjectsStore()
|
||||
|
||||
const pendingInvitesCount = computed(() => store.pendingInvitesCount)
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
@@ -102,6 +116,27 @@ onUpdated(refreshIcons)
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
/* Приглашения */
|
||||
.nav-item.invites {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.invites-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
/* ========== MOBILE: Нижняя навигация ========== */
|
||||
.sidebar.mobile {
|
||||
top: auto;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<template>
|
||||
<SlidePanel
|
||||
:show="show"
|
||||
:width="500"
|
||||
:min-width="400"
|
||||
:max-width="700"
|
||||
@close="handleClose"
|
||||
>
|
||||
<template #header>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Кнопка добавления комментария -->
|
||||
<div class="comment-action-bar">
|
||||
<div v-if="canComment" class="comment-action-bar">
|
||||
<button class="btn-new-comment" @click="openEditorForCreate">
|
||||
<i data-lucide="message-square-plus"></i>
|
||||
Написать комментарий
|
||||
@@ -93,6 +93,10 @@ const props = defineProps({
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
canComment: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -242,7 +246,7 @@ const loadComments = async (silent = false) => {
|
||||
// Автообновление
|
||||
const startRefresh = () => {
|
||||
stopRefresh()
|
||||
const interval = (window.APP_CONFIG?.IDLE_REFRESH_SECONDS || 30) * 1000
|
||||
const interval = (window.APP_CONFIG?.REFRESH_INTERVALS?.comments || 30) * 1000
|
||||
refreshInterval = setInterval(async () => {
|
||||
if (props.active && props.taskId) {
|
||||
await loadComments(true)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
v-model="form.title"
|
||||
placeholder="Введите название задачи"
|
||||
ref="titleInputRef"
|
||||
:readonly="!canEdit"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@@ -12,11 +13,12 @@
|
||||
<TextInput
|
||||
v-model="form.description"
|
||||
placeholder="Краткое описание в одну строку..."
|
||||
:readonly="!canEdit"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Подробное описание">
|
||||
<template #actions>
|
||||
<template v-if="canEdit" #actions>
|
||||
<div class="format-buttons">
|
||||
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('bold')" title="Жирный (Ctrl+B)">
|
||||
<i data-lucide="bold"></i>
|
||||
@@ -33,6 +35,7 @@
|
||||
v-model="form.details"
|
||||
placeholder="Подробное описание задачи, заметки, ссылки..."
|
||||
:show-toolbar="false"
|
||||
:disabled="!canEdit"
|
||||
ref="detailsEditorRef"
|
||||
/>
|
||||
</FormField>
|
||||
@@ -41,6 +44,7 @@
|
||||
<TagsSelect
|
||||
v-model="form.departmentId"
|
||||
:options="departmentOptions"
|
||||
:disabled="!canEdit"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@@ -48,12 +52,13 @@
|
||||
<TagsSelect
|
||||
v-model="form.labelId"
|
||||
:options="labelOptions"
|
||||
:disabled="!canEdit"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div class="field-row" :class="{ mobile: isMobile }">
|
||||
<FormField label="Срок выполнения">
|
||||
<DatePicker v-model="form.dueDate" />
|
||||
<DatePicker v-model="form.dueDate" :disabled="!canEdit" />
|
||||
</FormField>
|
||||
|
||||
<FormField label="Исполнитель">
|
||||
@@ -63,18 +68,21 @@
|
||||
searchable
|
||||
placeholder="Без исполнителя"
|
||||
empty-label="Без исполнителя"
|
||||
:disabled="!canEdit"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
v-if="canEdit || attachedFiles.length > 0"
|
||||
label="Прикреплённые файлы"
|
||||
hint="Разрешены: PNG, JPEG, JPG, ZIP, RAR (до 10 МБ)"
|
||||
:hint="canEdit ? 'Разрешены: PNG, JPEG, JPG, ZIP, RAR (до 10 МБ)' : ''"
|
||||
:error="fileError"
|
||||
>
|
||||
<FileUploader
|
||||
:files="attachedFiles"
|
||||
:get-full-url="getFullUrl"
|
||||
:read-only="!canEdit"
|
||||
@add="handleFileAdd"
|
||||
@remove="handleFileRemove"
|
||||
@preview="$emit('preview-image', $event)"
|
||||
@@ -126,6 +134,10 @@ const props = defineProps({
|
||||
users: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
canEdit: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -173,13 +185,32 @@ const labelOptions = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
// Данные исполнителя из карточки (может быть удалённый участник)
|
||||
const cardAssignee = ref(null)
|
||||
|
||||
const userOptions = computed(() => {
|
||||
return props.users.map(user => ({
|
||||
value: user.id,
|
||||
const options = props.users.map(user => ({
|
||||
value: user.id_user, // id_user - это id пользователя из accounts
|
||||
label: user.name,
|
||||
subtitle: user.telegram,
|
||||
avatar: getFullUrl(user.avatar_url)
|
||||
}))
|
||||
|
||||
// Если текущий исполнитель не в списке участников — добавляем как виртуальную опцию
|
||||
if (cardAssignee.value && form.userId) {
|
||||
const exists = options.some(opt => Number(opt.value) === Number(form.userId))
|
||||
if (!exists) {
|
||||
options.unshift({
|
||||
value: form.userId,
|
||||
label: cardAssignee.value.name || 'Удалённый участник',
|
||||
subtitle: '',
|
||||
avatar: cardAssignee.value.avatar ? getFullUrl(cardAssignee.value.avatar) : null,
|
||||
disabled: true // Нельзя выбрать повторно
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
// Change tracking
|
||||
@@ -216,6 +247,7 @@ const resetForm = () => {
|
||||
form.labelId = 2 // Нормально по умолчанию
|
||||
form.dueDate = new Date().toISOString().split('T')[0]
|
||||
form.userId = null
|
||||
cardAssignee.value = null
|
||||
clearFiles()
|
||||
}
|
||||
|
||||
@@ -234,6 +266,16 @@ const loadFromCard = (card) => {
|
||||
form.dueDate = card.dueDate || ''
|
||||
form.userId = card.accountId || null
|
||||
|
||||
// Сохраняем данные исполнителя для случая если он удалён из проекта
|
||||
if (card.accountId && card.assignee) {
|
||||
cardAssignee.value = {
|
||||
avatar: card.assignee,
|
||||
name: null // Имя неизвестно, будет показываться аватарка
|
||||
}
|
||||
} else {
|
||||
cardAssignee.value = null
|
||||
}
|
||||
|
||||
if (card.files && card.files.length > 0) {
|
||||
attachedFiles.value = card.files.map(f => ({
|
||||
name: f.name,
|
||||
@@ -256,8 +298,15 @@ const applyFormat = (command) => {
|
||||
|
||||
const getAvatarByUserId = (userId) => {
|
||||
if (!userId) return null
|
||||
const user = props.users.find(u => u.id === userId)
|
||||
return user ? user.avatar_url : null
|
||||
const user = props.users.find(u => Number(u.id_user) === Number(userId))
|
||||
if (user) return user.avatar_url
|
||||
|
||||
// Fallback: если это тот же удалённый участник (userId не изменился), используем сохранённый аватар
|
||||
if (cardAssignee.value && Number(userId) === Number(initialForm.value.userId)) {
|
||||
return cardAssignee.value.avatar
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// File handlers
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
:departments="store.departments"
|
||||
:labels="store.labels"
|
||||
:users="store.users"
|
||||
:can-edit="canEdit"
|
||||
@preview-image="openImagePreview"
|
||||
/>
|
||||
|
||||
@@ -42,13 +43,14 @@
|
||||
:current-user-avatar="store.currentUserAvatar"
|
||||
:is-project-admin="store.isProjectAdmin"
|
||||
:active="activeTab === 'comments'"
|
||||
:can-comment="canComment"
|
||||
@comments-loaded="commentsCount = $event"
|
||||
@preview-file="openImagePreview"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Footer: скрываем на вкладке комментариев -->
|
||||
<template #footer v-if="activeTab !== 'comments'">
|
||||
<!-- Footer: скрываем на вкладке комментариев или если нет прав редактировать -->
|
||||
<template #footer v-if="activeTab !== 'comments' && canEdit">
|
||||
<div class="footer-left">
|
||||
<IconButton
|
||||
v-if="!isNew"
|
||||
@@ -143,9 +145,11 @@ import { useMobile } from '../../composables/useMobile'
|
||||
import { useLucideIcons } from '../../composables/useLucideIcons'
|
||||
import { useDateFormat } from '../../composables/useDateFormat'
|
||||
import { useProjectsStore } from '../../stores/projects'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
const { refreshIcons } = useLucideIcons()
|
||||
const toast = useToast()
|
||||
const { formatFull } = useDateFormat()
|
||||
const store = useProjectsStore()
|
||||
|
||||
@@ -203,7 +207,7 @@ const previewImage = ref(null)
|
||||
const panelTitle = computed(() => {
|
||||
if (isNew.value) return 'Новая задача'
|
||||
if (activeTab.value === 'comments') return 'Комментарии'
|
||||
return 'Редактирование'
|
||||
return canEdit.value ? 'Редактирование' : 'Просмотр'
|
||||
})
|
||||
|
||||
// Tabs config
|
||||
@@ -223,6 +227,18 @@ const canArchive = computed(() => {
|
||||
return store.doneColumnId && Number(props.columnId) === store.doneColumnId
|
||||
})
|
||||
|
||||
// Право на редактирование (для новой — create_task, для существующей — canEditTask)
|
||||
const canEdit = computed(() => {
|
||||
if (isNew.value) return store.can('create_task')
|
||||
return store.canEditTask(props.card)
|
||||
})
|
||||
|
||||
// Право на создание комментариев
|
||||
const canComment = computed(() => {
|
||||
if (isNew.value) return false // В новой задаче нельзя комментировать
|
||||
return store.canCreateComment(props.card)
|
||||
})
|
||||
|
||||
// Close handling
|
||||
const tryClose = () => {
|
||||
if (editTabRef.value?.hasChanges) {
|
||||
@@ -295,6 +311,11 @@ const handleSave = async () => {
|
||||
} else {
|
||||
emit('save', taskData)
|
||||
}
|
||||
|
||||
toast.success(props.card?.id ? 'Задача сохранена' : 'Задача создана')
|
||||
} catch (error) {
|
||||
toast.error('Ошибка сохранения задачи')
|
||||
console.error('Error saving task:', error)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
@@ -315,6 +336,8 @@ const confirmDelete = async () => {
|
||||
} else {
|
||||
emit('delete', props.card.id)
|
||||
}
|
||||
|
||||
toast.success('Задача удалена')
|
||||
}
|
||||
|
||||
// Archive
|
||||
@@ -332,6 +355,8 @@ const confirmArchive = async () => {
|
||||
} else {
|
||||
emit('archive', props.card.id)
|
||||
}
|
||||
|
||||
toast.success('Задача в архиве')
|
||||
}
|
||||
|
||||
// Restore
|
||||
@@ -349,6 +374,8 @@ const confirmRestore = async () => {
|
||||
} else {
|
||||
emit('restore', props.card.id)
|
||||
}
|
||||
|
||||
toast.success('Задача восстановлена')
|
||||
}
|
||||
|
||||
// Image preview
|
||||
@@ -401,6 +428,9 @@ watch(() => props.show, async (newVal) => {
|
||||
previewImage.value = null
|
||||
isSaving.value = false // Сброс состояния кнопки сохранения
|
||||
|
||||
// Обновляем права пользователя (могли измениться администратором)
|
||||
store.fetchUsers()
|
||||
|
||||
// Reset comments tab
|
||||
commentsTabRef.value?.reset()
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
style="display: none"
|
||||
>
|
||||
|
||||
<!-- Пустая зона drag & drop -->
|
||||
<!-- Пустая зона drag & drop (скрываем в readOnly если нет файлов) -->
|
||||
<div
|
||||
v-if="files.length === 0"
|
||||
v-if="files.length === 0 && !readOnly"
|
||||
class="file-dropzone"
|
||||
:class="{ 'dragover': isDragging }"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@@ -28,10 +28,10 @@
|
||||
<div
|
||||
v-else
|
||||
class="files-container"
|
||||
:class="{ 'dragover': isDragging }"
|
||||
@dragover.prevent="isDragging = true"
|
||||
:class="{ 'dragover': isDragging && !readOnly }"
|
||||
@dragover.prevent="!readOnly && (isDragging = true)"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleFileDrop"
|
||||
@drop.prevent="!readOnly && handleFileDrop($event)"
|
||||
>
|
||||
<!-- Превью файлов -->
|
||||
<div
|
||||
@@ -39,7 +39,7 @@
|
||||
:key="file.name + '-' + file.size"
|
||||
class="file-preview-item"
|
||||
>
|
||||
<div class="file-actions">
|
||||
<div v-if="!readOnly" class="file-actions">
|
||||
<button class="btn-download-file" @click.stop="downloadFile(file)" title="Скачать">
|
||||
<i data-lucide="download"></i>
|
||||
</button>
|
||||
@@ -58,8 +58,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Кнопка добавить ещё -->
|
||||
<div class="file-add-btn" @click="triggerFileInput">
|
||||
<!-- Кнопка добавить ещё (скрываем в readOnly) -->
|
||||
<div v-if="!readOnly" class="file-add-btn" @click="triggerFileInput">
|
||||
<i data-lucide="plus"></i>
|
||||
<span>Добавить</span>
|
||||
</div>
|
||||
@@ -101,6 +101,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: 'или нажмите для выбора'
|
||||
},
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
getFullUrl: {
|
||||
type: Function,
|
||||
default: (url) => url
|
||||
|
||||
121
front_vue/src/components/ui/LogoutButton.vue
Normal file
121
front_vue/src/components/ui/LogoutButton.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<button
|
||||
:class="['logout-btn', { mobile }]"
|
||||
@click="showDialog = true"
|
||||
title="Выйти"
|
||||
>
|
||||
<i data-lucide="log-out"></i>
|
||||
<span v-if="showText" class="logout-text">Выйти из аккаунта</span>
|
||||
</button>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:show="showDialog"
|
||||
type="logout"
|
||||
:action="logout"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUpdated } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { authApi } from '../../api'
|
||||
import { clearAuthCache } from '../../router'
|
||||
import { useProjectsStore } from '../../stores/projects'
|
||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||
|
||||
defineProps({
|
||||
showText: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
mobile: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const store = useProjectsStore()
|
||||
const showDialog = ref(false)
|
||||
|
||||
const logout = async () => {
|
||||
clearAuthCache()
|
||||
await authApi.logout()
|
||||
store.reset()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) window.lucide.createIcons()
|
||||
}
|
||||
|
||||
onMounted(refreshIcons)
|
||||
onUpdated(refreshIcons)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logout-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
gap: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.logout-btn i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* С текстом */
|
||||
.logout-btn:has(.logout-text) {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.logout-btn:has(.logout-text):hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.logout-btn:has(.logout-text) i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Мобильная версия (иконка) */
|
||||
.logout-btn.mobile {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.logout-btn.mobile:hover,
|
||||
.logout-btn.mobile:active {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
}
|
||||
</style>
|
||||
271
front_vue/src/components/ui/NotificationCard.vue
Normal file
271
front_vue/src/components/ui/NotificationCard.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div
|
||||
class="notification-card"
|
||||
:class="{ processing, [type]: true }"
|
||||
>
|
||||
<div class="notification-info">
|
||||
<!-- Аватар -->
|
||||
<div class="notification-avatar" :style="avatarStyle">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" :alt="title">
|
||||
<span v-else class="avatar-initials">{{ avatarInitial }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Детали -->
|
||||
<div class="notification-details">
|
||||
<span class="notification-title">{{ title }}</span>
|
||||
<span v-if="subtitle" class="notification-subtitle">{{ subtitle }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Бейдж -->
|
||||
<span v-if="badge" class="notification-badge">{{ badge }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Действия -->
|
||||
<div v-if="showActions" class="notification-actions">
|
||||
<button
|
||||
class="btn-action btn-decline"
|
||||
@click="$emit('decline')"
|
||||
:disabled="processing"
|
||||
title="Отклонить"
|
||||
>
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
<button
|
||||
class="btn-action btn-accept"
|
||||
@click="$emit('accept')"
|
||||
:disabled="processing"
|
||||
title="Принять"
|
||||
>
|
||||
<i data-lucide="check"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useLucideIcons } from '../../composables/useLucideIcons'
|
||||
|
||||
useLucideIcons()
|
||||
|
||||
const props = defineProps({
|
||||
// Основные данные
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
badge: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// Аватар
|
||||
avatarUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
avatarName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
avatarColor: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// Тип уведомления (для стилизации)
|
||||
type: {
|
||||
type: String,
|
||||
default: 'default', // default, invite, warning, info
|
||||
validator: (v) => ['default', 'invite', 'warning', 'info'].includes(v)
|
||||
},
|
||||
|
||||
// Состояние
|
||||
processing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showActions: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['accept', 'decline'])
|
||||
|
||||
// Первая буква для аватара
|
||||
const avatarInitial = computed(() => {
|
||||
return (props.avatarName || props.title || '?')[0].toUpperCase()
|
||||
})
|
||||
|
||||
// Стиль аватара
|
||||
const avatarStyle = computed(() => {
|
||||
if (props.avatarColor) {
|
||||
return { background: props.avatarColor }
|
||||
}
|
||||
return {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 10px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.notification-card:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.notification-card.processing {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Инфо */
|
||||
.notification-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Аватар */
|
||||
.notification-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--blue, #3b82f6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-initials {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Детали */
|
||||
.notification-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.notification-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Бейдж */
|
||||
.notification-badge {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
padding: 3px 6px;
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #f59e0b;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Действия */
|
||||
.notification-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-action i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn-action:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-decline {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-decline:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.btn-accept {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-accept:hover:not(:disabled) {
|
||||
background: #00e6b8;
|
||||
}
|
||||
|
||||
/* ========== ТИПЫ ========== */
|
||||
/* Invite (зелёный акцент) */
|
||||
.notification-card.invite .btn-accept {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* Warning (оранжевый) */
|
||||
.notification-card.warning .notification-badge {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
/* Info (синий) */
|
||||
.notification-card.info .notification-badge {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
}
|
||||
</style>
|
||||
@@ -31,8 +31,8 @@
|
||||
<!-- Редактируемое поле -->
|
||||
<div
|
||||
class="rich-editor"
|
||||
:class="{ 'is-empty': !modelValue }"
|
||||
contenteditable="true"
|
||||
:class="{ 'is-empty': !modelValue, 'disabled': disabled }"
|
||||
:contenteditable="!disabled"
|
||||
ref="editorRef"
|
||||
@input="onInput"
|
||||
@paste="onPaste"
|
||||
@@ -65,6 +65,10 @@ const props = defineProps({
|
||||
autoLinkify: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -309,6 +313,12 @@ defineExpose({
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.rich-editor.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.rich-editor:focus {
|
||||
border-color: var(--accent);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.value"
|
||||
class="dropdown-item"
|
||||
:class="{ active: modelValue === option.value }"
|
||||
:class="{ active: isActive(option.value) }"
|
||||
@click="selectOption(option.value)"
|
||||
>
|
||||
<slot name="option" :option="option">
|
||||
@@ -56,7 +56,16 @@
|
||||
>
|
||||
<div class="option-content">
|
||||
<span class="option-label">{{ option.label }}</span>
|
||||
<span v-if="option.subtitle" class="option-subtitle">{{ option.subtitle }}</span>
|
||||
<a
|
||||
v-if="option.subtitle"
|
||||
class="option-subtitle"
|
||||
:href="'https://t.me/' + option.subtitle.replace('@', '')"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
>
|
||||
<i data-lucide="send"></i>
|
||||
{{ option.subtitle.startsWith('@') ? option.subtitle : '@' + option.subtitle }}
|
||||
</a>
|
||||
</div>
|
||||
</slot>
|
||||
</button>
|
||||
@@ -115,7 +124,7 @@
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.value"
|
||||
class="mobile-select-item"
|
||||
:class="{ active: modelValue === option.value }"
|
||||
:class="{ active: isActive(option.value) }"
|
||||
@click="selectOption(option.value)"
|
||||
>
|
||||
<img
|
||||
@@ -126,9 +135,18 @@
|
||||
>
|
||||
<div class="option-content">
|
||||
<span class="option-label">{{ option.label }}</span>
|
||||
<span v-if="option.subtitle" class="option-subtitle">{{ option.subtitle }}</span>
|
||||
<a
|
||||
v-if="option.subtitle"
|
||||
class="option-subtitle"
|
||||
:href="'https://t.me/' + option.subtitle.replace('@', '')"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
>
|
||||
<i data-lucide="send"></i>
|
||||
{{ option.subtitle.startsWith('@') ? option.subtitle : '@' + option.subtitle }}
|
||||
</a>
|
||||
</div>
|
||||
<i v-if="modelValue === option.value" data-lucide="check" class="check-icon"></i>
|
||||
<i v-if="isActive(option.value)" data-lucide="check" class="check-icon"></i>
|
||||
</button>
|
||||
|
||||
<!-- Пустой результат поиска -->
|
||||
@@ -247,17 +265,24 @@ const closeDropdown = () => {
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
// Сравнение значений (приводим к числу для корректного сравнения)
|
||||
const isActive = (optionValue) => {
|
||||
if (props.modelValue === null || props.modelValue === undefined) return false
|
||||
return Number(optionValue) === Number(props.modelValue)
|
||||
}
|
||||
|
||||
// Выбранная опция
|
||||
const selectedOption = computed(() => {
|
||||
if (!props.modelValue) return null
|
||||
return props.options.find(opt => opt.value === props.modelValue)
|
||||
if (props.modelValue === null || props.modelValue === undefined) return null
|
||||
return props.options.find(opt => isActive(opt.value))
|
||||
})
|
||||
|
||||
// Отфильтрованные опции
|
||||
// Отфильтрованные опции (исключаем disabled, они только для отображения выбранного)
|
||||
const filteredOptions = computed(() => {
|
||||
if (!searchQuery.value.trim()) return props.options
|
||||
let opts = props.options.filter(opt => !opt.disabled)
|
||||
if (!searchQuery.value.trim()) return opts
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return props.options.filter(opt =>
|
||||
return opts.filter(opt =>
|
||||
opt.label?.toLowerCase().includes(query) ||
|
||||
opt.subtitle?.toLowerCase().includes(query)
|
||||
)
|
||||
@@ -500,12 +525,30 @@ onUpdated(refreshIcons)
|
||||
.option-subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.option-subtitle:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.option-subtitle :deep(svg) {
|
||||
width: 10px !important;
|
||||
height: 10px !important;
|
||||
}
|
||||
|
||||
.dropdown-item.active .option-subtitle {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.dropdown-item.active .option-subtitle:hover {
|
||||
color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.no-selection-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
@@ -678,6 +721,15 @@ onUpdated(refreshIcons)
|
||||
.mobile-select-item .option-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mobile-select-item .option-subtitle :deep(svg) {
|
||||
width: 12px !important;
|
||||
height: 12px !important;
|
||||
}
|
||||
|
||||
.mobile-select-item.active .option-subtitle {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<template>
|
||||
<div class="tags-select">
|
||||
<div class="tags-select" :class="{ disabled: disabled }">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="tag-option"
|
||||
:class="{ active: isSelected(option.value) }"
|
||||
:style="{ '--tag-color': option.color || defaultColor }"
|
||||
@click="toggleOption(option.value)"
|
||||
:disabled="disabled"
|
||||
@click="!disabled && toggleOption(option.value)"
|
||||
>
|
||||
<span v-if="option.icon" class="tag-icon">{{ option.icon }}</span>
|
||||
{{ option.label }}
|
||||
@@ -38,6 +39,10 @@ const props = defineProps({
|
||||
defaultColor: {
|
||||
type: String,
|
||||
default: 'var(--accent)'
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -114,6 +119,22 @@ const toggleOption = (value) => {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.tags-select.disabled .tag-option {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tags-select.disabled .tag-option:hover {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tags-select.disabled .tag-option.active:hover {
|
||||
background: var(--tag-color);
|
||||
border-color: var(--tag-color);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.tag-icon {
|
||||
font-size: 14px;
|
||||
margin-right: 4px;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<input
|
||||
type="text"
|
||||
class="text-input"
|
||||
:class="{ readonly: readonly }"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@@ -65,4 +66,14 @@ defineEmits(['update:modelValue', 'focus', 'blur', 'keydown'])
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.text-input.readonly {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.text-input.readonly:focus {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
</style>
|
||||
|
||||
431
front_vue/src/components/ui/ToastContainer.vue
Normal file
431
front_vue/src/components/ui/ToastContainer.vue
Normal file
@@ -0,0 +1,431 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="toast-container" :class="{ mobile: isMobile }">
|
||||
<TransitionGroup :name="isMobile ? 'toast-mobile' : 'toast'">
|
||||
<div
|
||||
v-for="(toast, index) in toasts"
|
||||
:key="toast.id"
|
||||
class="toast"
|
||||
:class="toast.type"
|
||||
:style="getStackStyle(index)"
|
||||
@click="!isMobile && remove(toast.id)"
|
||||
@touchstart="handleTouchStart($event, toast.id)"
|
||||
@touchmove="handleTouchMove($event, toast.id)"
|
||||
@touchend="handleTouchEnd($event, toast.id)"
|
||||
>
|
||||
<!-- Progress bar (время до автозакрытия) -->
|
||||
<div class="toast-progress" :style="{ '--duration': toast.duration + 'ms' }"></div>
|
||||
|
||||
<!-- Цветной акцент слева -->
|
||||
<div class="toast-accent"></div>
|
||||
|
||||
<div class="toast-content">
|
||||
<div class="toast-icon">
|
||||
<i :data-lucide="iconName(toast.type)"></i>
|
||||
</div>
|
||||
<span class="toast-message">{{ toast.message }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Кнопка закрытия (только десктоп) -->
|
||||
<button v-if="!isMobile" class="toast-close" @click.stop="remove(toast.id)">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
|
||||
<!-- Swipe hint для мобильных (на верхнем тосте) -->
|
||||
<div v-if="isMobile && index === toasts.length - 1" class="swipe-hint">
|
||||
<i data-lucide="chevrons-left"></i>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { useLucideIcons } from '../../composables/useLucideIcons'
|
||||
import { useMobile } from '../../composables/useMobile'
|
||||
|
||||
const { toasts, remove } = useToast()
|
||||
const { refreshIcons } = useLucideIcons()
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
// Swipe tracking
|
||||
const swipeState = ref({})
|
||||
|
||||
const handleTouchStart = (e, id) => {
|
||||
const touch = e.touches[0]
|
||||
swipeState.value[id] = {
|
||||
startX: touch.clientX,
|
||||
startY: touch.clientY,
|
||||
currentX: 0,
|
||||
swiping: false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchMove = (e, id) => {
|
||||
const state = swipeState.value[id]
|
||||
if (!state) return
|
||||
|
||||
const touch = e.touches[0]
|
||||
const deltaX = touch.clientX - state.startX
|
||||
const deltaY = Math.abs(touch.clientY - state.startY)
|
||||
|
||||
// Определяем направление: горизонтальный свайп влево
|
||||
if (Math.abs(deltaX) > 10 && deltaX < 0 && deltaY < 30) {
|
||||
state.swiping = true
|
||||
state.currentX = Math.max(deltaX, -150) // Ограничиваем свайп
|
||||
|
||||
// Применяем трансформацию (сохраняем центрирование -50%)
|
||||
const el = e.currentTarget
|
||||
el.style.transform = `translateX(calc(-50% + ${state.currentX}px))`
|
||||
el.style.opacity = 1 - Math.abs(state.currentX) / 150
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchEnd = (e, id) => {
|
||||
const state = swipeState.value[id]
|
||||
if (!state) return
|
||||
|
||||
const el = e.currentTarget
|
||||
|
||||
// Если свайпнули достаточно далеко — удаляем
|
||||
if (state.currentX < -80) {
|
||||
// Помечаем как swiped чтобы отключить CSS-анимацию TransitionGroup
|
||||
el.classList.add('swiped')
|
||||
el.style.transform = 'translateX(-150%)'
|
||||
el.style.opacity = '0'
|
||||
// Удаляем сразу, анимация уже произошла через JS
|
||||
setTimeout(() => remove(id), 100)
|
||||
} else {
|
||||
// Возвращаем на место
|
||||
el.style.transition = 'transform 0.2s, opacity 0.2s'
|
||||
el.style.transform = 'translateX(-50%)'
|
||||
el.style.opacity = '1'
|
||||
setTimeout(() => {
|
||||
el.style.transition = ''
|
||||
}, 200)
|
||||
}
|
||||
|
||||
delete swipeState.value[id]
|
||||
}
|
||||
|
||||
// Stack effect для нескольких тостов (мобильная версия)
|
||||
// Новые тосты полностью перекрывают старые, без уменьшения
|
||||
const getStackStyle = (index) => {
|
||||
if (!isMobile.value) return {}
|
||||
|
||||
// Новые тосты (с большим индексом) должны быть сверху
|
||||
return {
|
||||
zIndex: 100 + index
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем иконки при добавлении новых toast
|
||||
watch(() => toasts.value.length, () => {
|
||||
setTimeout(refreshIcons, 10)
|
||||
})
|
||||
|
||||
const iconName = (type) => {
|
||||
switch (type) {
|
||||
case 'success': return 'circle-check'
|
||||
case 'error': return 'circle-x'
|
||||
case 'warning': return 'triangle-alert'
|
||||
case 'info':
|
||||
default: return 'info'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ==================== DESKTOP ==================== */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 100000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
/* Safe area для iPhone */
|
||||
@supports (padding-top: env(safe-area-inset-top)) {
|
||||
.toast-container:not(.mobile) {
|
||||
top: calc(20px + env(safe-area-inset-top));
|
||||
right: calc(20px + env(safe-area-inset-right));
|
||||
}
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
padding-left: 20px;
|
||||
background: var(--bg-secondary, #1a1a1f);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(12px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Цветной акцент слева */
|
||||
.toast-accent {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
.toast-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-icon i,
|
||||
.toast-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #f4f4f5);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted, #6b6b70);
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast-close i,
|
||||
.toast-close svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.toast-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
opacity: 0.3;
|
||||
animation: progress var(--duration) linear forwards;
|
||||
border-radius: 0 0 0 12px;
|
||||
}
|
||||
|
||||
@keyframes progress {
|
||||
from { width: 100%; }
|
||||
to { width: 0%; }
|
||||
}
|
||||
|
||||
/* Swipe hint (скрыт по умолчанию) */
|
||||
.swipe-hint {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ==================== ТИПЫ УВЕДОМЛЕНИЙ ==================== */
|
||||
.toast.success .toast-accent { background: var(--accent, #00d4aa); }
|
||||
.toast.success .toast-icon { color: var(--accent, #00d4aa); }
|
||||
.toast.success .toast-progress { color: var(--accent, #00d4aa); }
|
||||
|
||||
.toast.error .toast-accent { background: var(--red, #f87171); }
|
||||
.toast.error .toast-icon { color: var(--red, #f87171); }
|
||||
.toast.error .toast-progress { color: var(--red, #f87171); }
|
||||
|
||||
.toast.warning .toast-accent { background: var(--orange, #fbbf24); }
|
||||
.toast.warning .toast-icon { color: var(--orange, #fbbf24); }
|
||||
.toast.warning .toast-progress { color: var(--orange, #fbbf24); }
|
||||
|
||||
.toast.info .toast-accent { background: var(--blue, #60a5fa); }
|
||||
.toast.info .toast-icon { color: var(--blue, #60a5fa); }
|
||||
.toast.info .toast-progress { color: var(--blue, #60a5fa); }
|
||||
|
||||
/* ==================== DESKTOP ANIMATIONS ==================== */
|
||||
.toast-enter-active {
|
||||
animation: toast-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
.toast-leave-active {
|
||||
animation: toast-out 0.2s ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== MOBILE ==================== */
|
||||
.toast-container.mobile {
|
||||
top: auto;
|
||||
bottom: 90px; /* Над навигацией */
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-width: none;
|
||||
gap: 0;
|
||||
min-height: 60px; /* Место для абсолютно спозиционированных тостов */
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@supports (padding-bottom: env(safe-area-inset-bottom)) {
|
||||
.toast-container.mobile {
|
||||
bottom: calc(90px + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
.toast-container.mobile .toast {
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
padding: 12px 16px;
|
||||
padding-left: 18px;
|
||||
border-radius: 16px;
|
||||
cursor: default;
|
||||
/* Pill-style с цветным градиентом на фоне */
|
||||
background: linear-gradient(135deg,
|
||||
rgba(30, 30, 35, 0.95) 0%,
|
||||
rgba(25, 25, 30, 0.98) 100%
|
||||
);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow:
|
||||
0 4px 20px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||
}
|
||||
|
||||
/* Акцент на мобильных — градиентная полоска */
|
||||
.toast-container.mobile .toast-accent {
|
||||
width: 3px;
|
||||
background: linear-gradient(180deg, currentColor 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.toast-container.mobile .toast.success .toast-accent { color: var(--accent, #00d4aa); }
|
||||
.toast-container.mobile .toast.error .toast-accent { color: var(--red, #f87171); }
|
||||
.toast-container.mobile .toast.warning .toast-accent { color: var(--orange, #fbbf24); }
|
||||
.toast-container.mobile .toast.info .toast-accent { color: var(--blue, #60a5fa); }
|
||||
|
||||
/* Swipe hint на мобильных */
|
||||
.toast-container.mobile .swipe-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.4;
|
||||
animation: swipe-hint 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.toast-container.mobile .swipe-hint i,
|
||||
.toast-container.mobile .swipe-hint svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@keyframes swipe-hint {
|
||||
0%, 100% { transform: translateX(0); opacity: 0.4; }
|
||||
50% { transform: translateX(-4px); opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* ==================== MOBILE ANIMATIONS ==================== */
|
||||
.toast-mobile-enter-active {
|
||||
animation: toast-mobile-in 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.toast-mobile-leave-active {
|
||||
animation: toast-mobile-out 0.25s ease-in forwards;
|
||||
}
|
||||
|
||||
/* Отключаем анимацию для элементов удалённых свайпом (уже анимированы через JS) */
|
||||
.toast-mobile-leave-active.swiped {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes toast-mobile-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-mobile-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-150%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Все тосты на мобильных в одном месте, новые поверх старых */
|
||||
.toast-container.mobile .toast {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* Только верхний (последний) тост интерактивен */
|
||||
.toast-container.mobile .toast:not(:last-child) {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,33 @@
|
||||
import { onMounted, onUpdated } from 'vue'
|
||||
|
||||
// Глобальный debounce таймер для всех компонентов
|
||||
let debounceTimer = null
|
||||
const DEBOUNCE_MS = 50
|
||||
|
||||
/**
|
||||
* Проверяет, есть ли необработанные иконки (<i data-lucide> без svg)
|
||||
*/
|
||||
const hasUnprocessedIcons = () => {
|
||||
const icons = document.querySelectorAll('i[data-lucide]')
|
||||
return icons.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Безопасное обновление иконок с debounce
|
||||
*/
|
||||
const debouncedRefresh = () => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
}
|
||||
debounceTimer = setTimeout(() => {
|
||||
// Обновляем только если есть необработанные иконки
|
||||
if (hasUnprocessedIcons() && window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
debounceTimer = null
|
||||
}, DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable для автоматического обновления Lucide иконок
|
||||
* при монтировании и обновлении компонента.
|
||||
@@ -10,12 +38,17 @@ import { onMounted, onUpdated } from 'vue'
|
||||
*/
|
||||
export function useLucideIcons() {
|
||||
const refresh = () => {
|
||||
debouncedRefresh()
|
||||
}
|
||||
|
||||
// Немедленное обновление при монтировании
|
||||
onMounted(() => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh)
|
||||
})
|
||||
|
||||
// Debounced обновление при изменениях
|
||||
onUpdated(refresh)
|
||||
|
||||
return { refreshIcons: refresh }
|
||||
|
||||
79
front_vue/src/composables/useToast.js
Normal file
79
front_vue/src/composables/useToast.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Глобальное состояние уведомлений (singleton)
|
||||
const toasts = ref([])
|
||||
let toastId = 0
|
||||
|
||||
/**
|
||||
* Composable для управления toast-уведомлениями
|
||||
*
|
||||
* Использование:
|
||||
* import { useToast } from '@/composables/useToast'
|
||||
* const toast = useToast()
|
||||
* toast.success('Сохранено!')
|
||||
* toast.error('Ошибка!')
|
||||
* toast.info('Информация')
|
||||
* toast.warning('Внимание')
|
||||
*/
|
||||
export function useToast() {
|
||||
/**
|
||||
* Добавить уведомление
|
||||
* @param {string} message - Текст уведомления
|
||||
* @param {'success'|'error'|'info'|'warning'} type - Тип уведомления
|
||||
* @param {number} duration - Время показа в мс (0 = не скрывать автоматически)
|
||||
*/
|
||||
const add = (message, type = 'info', duration = 3000) => {
|
||||
const id = ++toastId
|
||||
|
||||
toasts.value.push({
|
||||
id,
|
||||
message,
|
||||
type,
|
||||
duration, // Для progress bar в UI
|
||||
visible: true
|
||||
})
|
||||
|
||||
// Автоматическое скрытие
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
remove(id)
|
||||
}, duration)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить уведомление
|
||||
*/
|
||||
const remove = (id) => {
|
||||
const index = toasts.value.findIndex(t => t.id === id)
|
||||
if (index !== -1) {
|
||||
toasts.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистить все уведомления
|
||||
*/
|
||||
const clear = () => {
|
||||
toasts.value = []
|
||||
}
|
||||
|
||||
// Хелперы для разных типов
|
||||
const success = (message, duration = 3000) => add(message, 'success', duration)
|
||||
const error = (message, duration = 4000) => add(message, 'error', duration)
|
||||
const info = (message, duration = 3000) => add(message, 'info', duration)
|
||||
const warning = (message, duration = 3500) => add(message, 'warning', duration)
|
||||
|
||||
return {
|
||||
toasts,
|
||||
add,
|
||||
remove,
|
||||
clear,
|
||||
success,
|
||||
error,
|
||||
info,
|
||||
warning
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,17 @@ serverSettings.init()
|
||||
// Регистрация Service Worker для PWA
|
||||
registerSW({ immediate: true })
|
||||
|
||||
// Автообновление страницы (F5) по таймеру
|
||||
const initAutoRefresh = () => {
|
||||
const seconds = window.APP_CONFIG?.AUTO_REFRESH_SECONDS || 0
|
||||
if (seconds > 0) {
|
||||
setInterval(() => {
|
||||
window.location.reload()
|
||||
}, seconds * 1000)
|
||||
}
|
||||
}
|
||||
initAutoRefresh()
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
@@ -3,7 +3,10 @@ import MainApp from './views/MainApp.vue'
|
||||
import LoginPage from './views/LoginPage.vue'
|
||||
import TeamPage from './views/TeamPage.vue'
|
||||
import ArchivePage from './views/ArchivePage.vue'
|
||||
import NoProjectsPage from './views/NoProjectsPage.vue'
|
||||
import InvitesPage from './views/InvitesPage.vue'
|
||||
import { authApi } from './api'
|
||||
import { useProjectsStore } from './stores/projects'
|
||||
|
||||
// Кэш авторизации (чтобы не делать запрос при каждой навигации)
|
||||
let authCache = {
|
||||
@@ -59,18 +62,30 @@ const routes = [
|
||||
path: '/',
|
||||
name: 'main',
|
||||
component: MainApp,
|
||||
meta: { requiresAuth: true }
|
||||
meta: { requiresAuth: true, requiresProject: true }
|
||||
},
|
||||
{
|
||||
path: '/team',
|
||||
name: 'team',
|
||||
component: TeamPage,
|
||||
meta: { requiresAuth: true }
|
||||
meta: { requiresAuth: true, requiresProject: true }
|
||||
},
|
||||
{
|
||||
path: '/archive',
|
||||
name: 'archive',
|
||||
component: ArchivePage,
|
||||
meta: { requiresAuth: true, requiresProject: true }
|
||||
},
|
||||
{
|
||||
path: '/no-projects',
|
||||
name: 'no-projects',
|
||||
component: NoProjectsPage,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/invites',
|
||||
name: 'invites',
|
||||
component: InvitesPage,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
@@ -85,10 +100,11 @@ const router = createRouter({
|
||||
routes
|
||||
})
|
||||
|
||||
// Navigation guard — проверка авторизации
|
||||
// Navigation guard — проверка авторизации и наличия проектов
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// Если переходим между защищёнными страницами и кэш валиден — не проверяем сеть
|
||||
const needsAuth = to.meta.requiresAuth
|
||||
const needsProject = to.meta.requiresProject
|
||||
const fromProtected = from.meta?.requiresAuth
|
||||
|
||||
// Форсируем проверку только при переходе на защищённую страницу извне
|
||||
@@ -100,12 +116,44 @@ router.beforeEach(async (to, from, next) => {
|
||||
if (needsAuth && !isAuth) {
|
||||
// Не авторизован — на логин
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && isAuth) {
|
||||
return
|
||||
}
|
||||
|
||||
if (to.path === '/login' && isAuth) {
|
||||
// Уже авторизован — на главную
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// Проверка наличия проектов для страниц, которые их требуют
|
||||
if (needsProject && isAuth) {
|
||||
const store = useProjectsStore()
|
||||
|
||||
// Инициализируем store если ещё не инициализирован
|
||||
if (!store.initialized) {
|
||||
await store.init()
|
||||
}
|
||||
|
||||
// Нет проектов — на страницу /no-projects
|
||||
if (store.projects.length === 0) {
|
||||
next('/no-projects')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Если на /no-projects но проекты есть — на главную
|
||||
if (to.path === '/no-projects' && isAuth) {
|
||||
const store = useProjectsStore()
|
||||
if (!store.initialized) {
|
||||
await store.init()
|
||||
}
|
||||
if (store.projects.length > 0) {
|
||||
next('/')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -74,5 +74,29 @@ export const DIALOGS = {
|
||||
message: '', // Будет задан динамически
|
||||
confirmText: 'Удалить',
|
||||
variant: 'danger'
|
||||
},
|
||||
|
||||
// Удаление участника из проекта
|
||||
removeMember: {
|
||||
title: 'Удалить участника?',
|
||||
message: '', // Будет задан динамически
|
||||
confirmText: 'Удалить',
|
||||
variant: 'danger'
|
||||
},
|
||||
|
||||
// Выход из проекта
|
||||
leaveProject: {
|
||||
title: 'Выйти из проекта?',
|
||||
message: 'Вы потеряете доступ к задачам<br>и данным этого проекта.',
|
||||
confirmText: 'Выйти',
|
||||
variant: 'danger'
|
||||
},
|
||||
|
||||
// Выход из системы
|
||||
logout: {
|
||||
title: 'Выйти из аккаунта?',
|
||||
message: 'Вы будете перенаправлены<br>на страницу входа.',
|
||||
confirmText: 'Выйти',
|
||||
variant: 'warning'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,51 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { projectsApi, usersApi, cardsApi } from '../api'
|
||||
import { projectsApi, usersApi, cardsApi, projectInviteApi } from '../api'
|
||||
import { getCachedUser } from '../router'
|
||||
|
||||
// ==================== ЛОКАЛЬНЫЙ ПОРЯДОК ПРОЕКТОВ ====================
|
||||
const PROJECTS_ORDER_KEY = 'projectsOrder'
|
||||
|
||||
// Получить сохранённый порядок из localStorage
|
||||
const getLocalProjectsOrder = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(PROJECTS_ORDER_KEY)
|
||||
return saved ? JSON.parse(saved) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Сохранить порядок в localStorage
|
||||
const saveLocalProjectsOrder = (ids) => {
|
||||
localStorage.setItem(PROJECTS_ORDER_KEY, JSON.stringify(ids))
|
||||
}
|
||||
|
||||
// Применить локальный порядок к массиву проектов
|
||||
const applyLocalOrder = (projectsArray) => {
|
||||
const savedOrder = getLocalProjectsOrder()
|
||||
if (!savedOrder.length) return projectsArray
|
||||
|
||||
// Создаём Map для быстрого доступа
|
||||
const projectsMap = new Map(projectsArray.map(p => [p.id, p]))
|
||||
const result = []
|
||||
|
||||
// Сначала добавляем проекты в сохранённом порядке
|
||||
for (const id of savedOrder) {
|
||||
if (projectsMap.has(id)) {
|
||||
result.push(projectsMap.get(id))
|
||||
projectsMap.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Затем добавляем новые проекты (которых нет в сохранённом порядке)
|
||||
for (const project of projectsMap.values()) {
|
||||
result.push(project)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const useProjectsStore = defineStore('projects', () => {
|
||||
// ==================== СОСТОЯНИЕ ====================
|
||||
const projects = ref([])
|
||||
@@ -16,6 +59,7 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
const loading = ref(false)
|
||||
const initialized = ref(false)
|
||||
const currentUser = ref(null) // Текущий авторизованный пользователь
|
||||
const pendingInvitesCount = ref(0) // Количество pending-приглашений
|
||||
|
||||
// Текущий проект (из localStorage)
|
||||
const savedProjectId = localStorage.getItem('currentProjectId')
|
||||
@@ -35,8 +79,8 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
return project ? Number(project.id_ready) : null
|
||||
})
|
||||
|
||||
// ID текущего пользователя
|
||||
const currentUserId = computed(() => currentUser.value?.id || null)
|
||||
// ID текущего пользователя (приводим к числу для корректного сравнения)
|
||||
const currentUserId = computed(() => currentUser.value?.id ? Number(currentUser.value.id) : null)
|
||||
|
||||
// Имя текущего пользователя
|
||||
const currentUserName = computed(() => currentUser.value?.name || '')
|
||||
@@ -45,12 +89,81 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
const currentUserAvatar = computed(() => currentUser.value?.avatar_url || '')
|
||||
|
||||
// Является ли текущий пользователь админом проекта
|
||||
// Сервер возвращает id_admin: true только если текущий пользователь — админ
|
||||
const isProjectAdmin = computed(() => {
|
||||
const project = projects.value.find(p => p.id === currentProjectId.value)
|
||||
return project?.id_admin === true
|
||||
return project?.is_admin === true
|
||||
})
|
||||
|
||||
// Права текущего пользователя в проекте (загружаются с users)
|
||||
const currentUserPermissions = computed(() => {
|
||||
if (!currentUserId.value) return {}
|
||||
const member = users.value.find(u => u.id_user === currentUserId.value)
|
||||
return member?.permissions || {}
|
||||
})
|
||||
|
||||
// Проверка конкретного права (админ имеет все права)
|
||||
const can = (permission) => {
|
||||
if (isProjectAdmin.value) return true
|
||||
return currentUserPermissions.value[permission] === true
|
||||
}
|
||||
|
||||
// Проверка права на редактирование задачи
|
||||
const canEditTask = (task) => {
|
||||
if (!currentUserId.value) return false
|
||||
if (isProjectAdmin.value) return true
|
||||
if (can('edit_task')) return true
|
||||
|
||||
const creatorId = task?.create_id_account ? Number(task.create_id_account) : null
|
||||
// accountId (mapped) или id_account (raw)
|
||||
const assigneeId = task?.accountId ? Number(task.accountId) : (task?.id_account ? Number(task.id_account) : null)
|
||||
|
||||
// Создатель + право создания → может редактировать
|
||||
if (creatorId === currentUserId.value && can('create_task')) return true
|
||||
|
||||
// Назначена на себя + право edit_own_task_only
|
||||
if (assigneeId === currentUserId.value && can('edit_own_task_only')) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Проверка права на перемещение задачи
|
||||
const canMoveTask = (task) => {
|
||||
if (!currentUserId.value) return false
|
||||
if (isProjectAdmin.value) return true
|
||||
if (can('move_task')) return true
|
||||
|
||||
const creatorId = task?.create_id_account ? Number(task.create_id_account) : null
|
||||
// accountId (mapped) или id_account (raw)
|
||||
const assigneeId = task?.accountId ? Number(task.accountId) : (task?.id_account ? Number(task.id_account) : null)
|
||||
|
||||
// Создатель + право создания → может перемещать
|
||||
if (creatorId === currentUserId.value && can('create_task')) return true
|
||||
|
||||
// Назначена на себя + право move_own_task_only
|
||||
if (assigneeId === currentUserId.value && can('move_own_task_only')) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Проверка права на создание комментария в задаче
|
||||
const canCreateComment = (task) => {
|
||||
if (!currentUserId.value) return false
|
||||
if (isProjectAdmin.value) return true
|
||||
if (can('create_comment')) return true
|
||||
|
||||
const creatorId = task?.create_id_account ? Number(task.create_id_account) : null
|
||||
// accountId (mapped) или id_account (raw)
|
||||
const assigneeId = task?.accountId ? Number(task.accountId) : (task?.id_account ? Number(task.id_account) : null)
|
||||
|
||||
// Создатель + право создания → может комментировать
|
||||
if (creatorId === currentUserId.value && can('create_task')) return true
|
||||
|
||||
// Назначена на себя + право create_comment_own_task_only
|
||||
if (assigneeId === currentUserId.value && can('create_comment_own_task_only')) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ==================== ДЕЙСТВИЯ ====================
|
||||
// Инициализация (загрузка проектов + данных активного)
|
||||
const init = async () => {
|
||||
@@ -64,7 +177,8 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
|
||||
if (result.success) {
|
||||
if (result.data.projects) {
|
||||
projects.value = result.data.projects
|
||||
// Применяем локальный порядок сортировки
|
||||
projects.value = applyLocalOrder(result.data.projects)
|
||||
|
||||
// Применяем данные активного проекта
|
||||
if (result.data.active) {
|
||||
@@ -73,11 +187,18 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
labels.value = result.data.active.labels
|
||||
}
|
||||
} else {
|
||||
projects.value = result.data
|
||||
// Применяем локальный порядок сортировки
|
||||
projects.value = applyLocalOrder(result.data)
|
||||
}
|
||||
|
||||
// Если нет проектов — очищаем currentProjectId
|
||||
if (projects.value.length === 0) {
|
||||
currentProjectId.value = null
|
||||
localStorage.removeItem('currentProjectId')
|
||||
localStorage.removeItem('currentProjectName')
|
||||
}
|
||||
// Если нет выбранного проекта — выбираем первый
|
||||
if (!currentProjectId.value || !projects.value.find(p => p.id === currentProjectId.value)) {
|
||||
else if (!currentProjectId.value || !projects.value.find(p => p.id === currentProjectId.value)) {
|
||||
if (projects.value.length > 0) {
|
||||
await selectProject(projects.value[0].id, true) // Загружаем данные проекта
|
||||
}
|
||||
@@ -91,20 +212,22 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Загружаем пользователей
|
||||
const usersData = await usersApi.getAll()
|
||||
if (usersData.success) users.value = usersData.data
|
||||
|
||||
// Получаем текущего пользователя из кэша роутера (без повторного запроса)
|
||||
// Получаем текущего пользователя из кэша роутера
|
||||
if (!currentUser.value) {
|
||||
const cachedUser = getCachedUser()
|
||||
if (cachedUser) {
|
||||
// Находим полные данные пользователя (с id) из списка users
|
||||
const fullUser = users.value.find(u => u.username === cachedUser.username)
|
||||
currentUser.value = fullUser || cachedUser
|
||||
currentUser.value = cachedUser
|
||||
}
|
||||
}
|
||||
|
||||
// Загружаем участников проекта (users теперь привязаны к проекту)
|
||||
if (currentProjectId.value) {
|
||||
await fetchUsers()
|
||||
// Загружаем количество pending-приглашений (только если есть проекты)
|
||||
// Если проектов нет — NoProjectsPage сама загрузит полные данные с count
|
||||
await fetchPendingInvitesCount()
|
||||
}
|
||||
|
||||
initialized.value = true
|
||||
} catch (error) {
|
||||
console.error('Ошибка инициализации:', error)
|
||||
@@ -113,6 +236,60 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Принудительная перезагрузка проектов (для обновления после принятия приглашения/выхода из проекта)
|
||||
const refreshProjects = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const result = await projectsApi.getAll()
|
||||
|
||||
if (result.success) {
|
||||
if (result.data.projects) {
|
||||
// Применяем локальный порядок сортировки
|
||||
projects.value = applyLocalOrder(result.data.projects)
|
||||
} else {
|
||||
// Применяем локальный порядок сортировки
|
||||
projects.value = applyLocalOrder(result.data)
|
||||
}
|
||||
|
||||
// Если нет проектов — очищаем currentProjectId
|
||||
if (projects.value.length === 0) {
|
||||
currentProjectId.value = null
|
||||
columns.value = []
|
||||
departments.value = []
|
||||
labels.value = []
|
||||
users.value = []
|
||||
cards.value = []
|
||||
archivedCards.value = []
|
||||
localStorage.removeItem('currentProjectId')
|
||||
localStorage.removeItem('currentProjectName')
|
||||
}
|
||||
// Проверяем, есть ли текущий проект в списке
|
||||
else if (currentProjectId.value && !projects.value.find(p => p.id === currentProjectId.value)) {
|
||||
// Текущий проект больше недоступен — переключаемся на первый
|
||||
await selectProject(projects.value[0].id, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Устанавливаем текущего пользователя из кэша (если ещё не установлен)
|
||||
if (!currentUser.value) {
|
||||
const cachedUser = getCachedUser()
|
||||
if (cachedUser) {
|
||||
currentUser.value = cachedUser
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем количество pending-приглашений
|
||||
await fetchPendingInvitesCount()
|
||||
|
||||
initialized.value = true
|
||||
} catch (error) {
|
||||
console.error('Ошибка перезагрузки проектов:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Выбор проекта
|
||||
const selectProject = async (projectId, fetchData = true) => {
|
||||
currentProjectId.value = projectId
|
||||
@@ -140,17 +317,46 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
departments.value = projectData.data.departments
|
||||
labels.value = projectData.data.labels
|
||||
|
||||
// Обновляем id_admin в списке проектов (сервер возвращает true если текущий пользователь админ)
|
||||
// Обновляем is_admin в списке проектов
|
||||
const project = projects.value.find(p => p.id === currentProjectId.value)
|
||||
if (project && projectData.data.project?.id_admin === true) {
|
||||
project.id_admin = true
|
||||
if (project && projectData.data.project?.is_admin === true) {
|
||||
project.is_admin = true
|
||||
}
|
||||
}
|
||||
|
||||
// Загружаем участников проекта
|
||||
await fetchUsers()
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки данных проекта:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка участников проекта
|
||||
const fetchUsers = async () => {
|
||||
if (!currentProjectId.value) return
|
||||
|
||||
try {
|
||||
const usersData = await usersApi.getAll(currentProjectId.value)
|
||||
if (usersData.success) {
|
||||
users.value = usersData.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки участников:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка количества pending-приглашений
|
||||
const fetchPendingInvitesCount = async () => {
|
||||
try {
|
||||
const result = await projectInviteApi.getCount()
|
||||
if (result.success) {
|
||||
pendingInvitesCount.value = result.count
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки приглашений:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== КАРТОЧКИ ====================
|
||||
// Загрузка активных карточек (silent = тихое обновление без loading)
|
||||
const fetchCards = async (silent = false) => {
|
||||
@@ -221,9 +427,11 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
archivedCards.value = []
|
||||
currentProjectId.value = null
|
||||
currentUser.value = null
|
||||
pendingInvitesCount.value = 0
|
||||
initialized.value = false
|
||||
localStorage.removeItem('currentProjectId')
|
||||
localStorage.removeItem('currentProjectName')
|
||||
localStorage.removeItem(PROJECTS_ORDER_KEY)
|
||||
}
|
||||
|
||||
// ==================== CRUD ПРОЕКТОВ ====================
|
||||
@@ -233,13 +441,19 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
const result = await projectsApi.create(name)
|
||||
if (result.success) {
|
||||
// Добавляем проект в список
|
||||
projects.value.push({
|
||||
const newProject = {
|
||||
id: result.id,
|
||||
name,
|
||||
id_order: projects.value.length + 1,
|
||||
id_ready: result.id_ready,
|
||||
id_admin: true // Создатель = админ
|
||||
})
|
||||
is_admin: true // Создатель = админ
|
||||
}
|
||||
projects.value.push(newProject)
|
||||
|
||||
// Добавляем в локальный порядок
|
||||
const currentOrder = getLocalProjectsOrder()
|
||||
currentOrder.push(result.id)
|
||||
saveLocalProjectsOrder(currentOrder)
|
||||
|
||||
// Переключаемся на новый проект
|
||||
await selectProject(result.id)
|
||||
}
|
||||
@@ -270,6 +484,12 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
if (index !== -1) {
|
||||
projects.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// Удаляем из локального порядка
|
||||
const currentOrder = getLocalProjectsOrder()
|
||||
const filteredOrder = currentOrder.filter(pid => pid !== id)
|
||||
saveLocalProjectsOrder(filteredOrder)
|
||||
|
||||
// Если удалили текущий проект — переключаемся на первый
|
||||
if (id === currentProjectId.value) {
|
||||
if (projects.value.length > 0) {
|
||||
@@ -287,17 +507,17 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
return result
|
||||
}
|
||||
|
||||
// Обновление порядка проектов
|
||||
const reorderProjects = async (ids) => {
|
||||
// Оптимистичное обновление
|
||||
// Обновление порядка проектов (локально, без отправки на сервер)
|
||||
const reorderProjects = (ids) => {
|
||||
// Применяем новый порядок
|
||||
const reordered = ids.map((id, index) => {
|
||||
const project = projects.value.find(p => p.id === id)
|
||||
return { ...project, id_order: index + 1 }
|
||||
})
|
||||
return { ...project }
|
||||
}).filter(Boolean)
|
||||
projects.value = reordered
|
||||
|
||||
// Отправляем на сервер
|
||||
await projectsApi.updateOrder(ids)
|
||||
// Сохраняем порядок локально
|
||||
saveLocalProjectsOrder(ids)
|
||||
}
|
||||
|
||||
// ==================== CRUD КОЛОНОК ====================
|
||||
@@ -390,6 +610,7 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
initialized,
|
||||
currentProjectId,
|
||||
currentUser,
|
||||
pendingInvitesCount,
|
||||
// Геттеры
|
||||
currentProject,
|
||||
doneColumnId,
|
||||
@@ -397,10 +618,19 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
currentUserName,
|
||||
currentUserAvatar,
|
||||
isProjectAdmin,
|
||||
currentUserPermissions,
|
||||
// Проверки прав
|
||||
can,
|
||||
canEditTask,
|
||||
canMoveTask,
|
||||
canCreateComment,
|
||||
// Действия
|
||||
init,
|
||||
refreshProjects,
|
||||
selectProject,
|
||||
fetchProjectData,
|
||||
fetchUsers,
|
||||
fetchPendingInvitesCount,
|
||||
fetchCards,
|
||||
fetchArchivedCards,
|
||||
clearCards,
|
||||
|
||||
@@ -292,6 +292,7 @@ const handleRestoreFromPanel = async (cardId) => {
|
||||
|
||||
// ==================== ИНИЦИАЛИЗАЦИЯ ====================
|
||||
onMounted(async () => {
|
||||
// Store уже мог быть инициализирован в роутере
|
||||
await store.init()
|
||||
await fetchCards()
|
||||
|
||||
|
||||
548
front_vue/src/views/InvitesPage.vue
Normal file
548
front_vue/src/views/InvitesPage.vue
Normal file
@@ -0,0 +1,548 @@
|
||||
<template>
|
||||
<PageLayout>
|
||||
<Header title="Приглашения">
|
||||
<template #stats>
|
||||
<div v-if="invites.length > 0" class="header-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">{{ invites.length }}</span>
|
||||
<span class="stat-label">{{ invitesLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Header>
|
||||
|
||||
<main class="main" :class="{ mobile: isMobile }">
|
||||
<Loader v-if="loading" />
|
||||
|
||||
<div v-else-if="invites.length === 0" class="empty-state" :class="{ mobile: isMobile }">
|
||||
<i data-lucide="inbox"></i>
|
||||
<p>Нет приглашений</p>
|
||||
<span>Когда вас пригласят в проект, приглашение появится здесь</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="invites-list" :class="{ mobile: isMobile }">
|
||||
<div
|
||||
v-for="invite in invites"
|
||||
:key="invite.id"
|
||||
class="invite-card"
|
||||
:class="{ processing: processingId === invite.id, mobile: isMobile }"
|
||||
>
|
||||
<!-- Desktop версия -->
|
||||
<template v-if="!isMobile">
|
||||
<!-- Аватар пользователя -->
|
||||
<div class="user-avatar">
|
||||
<img v-if="invite.from_user.avatar_url" :src="getFullUrl(invite.from_user.avatar_url)" :alt="invite.from_user.name || invite.from_user.username">
|
||||
<span v-else class="avatar-initials">{{ (invite.from_user.name || invite.from_user.username || '?')[0] }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Основной контент -->
|
||||
<div class="card-main">
|
||||
<span class="user-name">{{ invite.from_user.name || invite.from_user.username }}</span>
|
||||
<span class="invite-separator">→</span>
|
||||
<div class="project-info">
|
||||
<i data-lucide="folder"></i>
|
||||
<span class="project-label">Проект:</span>
|
||||
<span class="project-name">{{ invite.project_name }}</span>
|
||||
</div>
|
||||
<span v-if="invite.is_admin" class="admin-badge">Админ</span>
|
||||
</div>
|
||||
|
||||
<!-- Дата -->
|
||||
<div class="card-date">{{ formatDate(invite.created_at) }}</div>
|
||||
|
||||
<!-- Кнопки -->
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="btn-action btn-decline"
|
||||
:disabled="processingId === invite.id"
|
||||
@click.stop="declineInvite(invite)"
|
||||
title="Отклонить"
|
||||
>
|
||||
<i data-lucide="x"></i>
|
||||
Отклонить
|
||||
</button>
|
||||
<button
|
||||
class="btn-action btn-accept"
|
||||
:disabled="processingId === invite.id"
|
||||
@click.stop="acceptInvite(invite)"
|
||||
title="Принять"
|
||||
>
|
||||
<i data-lucide="check"></i>
|
||||
Принять
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Mobile версия -->
|
||||
<template v-else>
|
||||
<div class="card-row">
|
||||
<div class="user-avatar">
|
||||
<img v-if="invite.from_user.avatar_url" :src="getFullUrl(invite.from_user.avatar_url)" :alt="invite.from_user.name || invite.from_user.username">
|
||||
<span v-else class="avatar-initials">{{ (invite.from_user.name || invite.from_user.username || '?')[0] }}</span>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<div class="card-top">
|
||||
<span class="user-name">{{ invite.from_user.name || invite.from_user.username }}</span>
|
||||
<span v-if="invite.is_admin" class="admin-badge">Админ</span>
|
||||
</div>
|
||||
<div class="card-bottom">
|
||||
<div class="project-info">
|
||||
<i data-lucide="folder"></i>
|
||||
<span class="project-label">Проект:</span>
|
||||
<span class="project-name">{{ invite.project_name }}</span>
|
||||
</div>
|
||||
<span class="card-date">{{ formatDate(invite.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="btn-action btn-decline"
|
||||
:disabled="processingId === invite.id"
|
||||
@click.stop="declineInvite(invite)"
|
||||
>
|
||||
<i data-lucide="x"></i>
|
||||
Отклонить
|
||||
</button>
|
||||
<button
|
||||
class="btn-action btn-accept"
|
||||
:disabled="processingId === invite.id"
|
||||
@click.stop="acceptInvite(invite)"
|
||||
>
|
||||
<i data-lucide="check"></i>
|
||||
Принять
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUpdated, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import PageLayout from '../components/PageLayout.vue'
|
||||
import Header from '../components/Header.vue'
|
||||
import Loader from '../components/ui/Loader.vue'
|
||||
import { getFullUrl, projectInviteApi } from '../api'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
import { useDateFormat } from '../composables/useDateFormat'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useProjectsStore()
|
||||
const toast = useToast()
|
||||
const { isMobile } = useMobile()
|
||||
const { formatRelative } = useDateFormat()
|
||||
|
||||
const loading = ref(true)
|
||||
const invites = ref([])
|
||||
const processingId = ref(null)
|
||||
|
||||
// Склонение слова "приглашение"
|
||||
const invitesLabel = computed(() => {
|
||||
const count = invites.value.length
|
||||
if (count === 1) return 'приглашение'
|
||||
if (count >= 2 && count <= 4) return 'приглашения'
|
||||
return 'приглашений'
|
||||
})
|
||||
|
||||
// Форматирование даты
|
||||
const formatDate = (dateStr) => {
|
||||
return formatRelative(dateStr)
|
||||
}
|
||||
|
||||
// Загрузка приглашений
|
||||
const loadInvites = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await projectInviteApi.getMyPending()
|
||||
if (result.success) {
|
||||
invites.value = result.data
|
||||
// Обновляем счётчик в store
|
||||
store.pendingInvitesCount = result.count
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки приглашений:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Принять приглашение
|
||||
const acceptInvite = async (invite) => {
|
||||
processingId.value = invite.id
|
||||
try {
|
||||
const result = await projectInviteApi.accept(invite.id)
|
||||
if (result.success) {
|
||||
toast.success(`Вы присоединились к проекту «${invite.project_name}»`)
|
||||
// Удаляем из списка
|
||||
invites.value = invites.value.filter(i => i.id !== invite.id)
|
||||
// Обновляем счётчик
|
||||
store.pendingInvitesCount = invites.value.length
|
||||
// Принудительно обновляем список проектов
|
||||
await store.refreshProjects()
|
||||
// Переходим к проекту
|
||||
await store.selectProject(result.project_id)
|
||||
router.push('/')
|
||||
} else {
|
||||
toast.error('Ошибка принятия приглашения')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка принятия приглашения:', error)
|
||||
toast.error('Ошибка принятия приглашения')
|
||||
} finally {
|
||||
processingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Отклонить приглашение
|
||||
const declineInvite = async (invite) => {
|
||||
processingId.value = invite.id
|
||||
try {
|
||||
const result = await projectInviteApi.decline(invite.id)
|
||||
if (result.success) {
|
||||
toast.info('Приглашение отклонено')
|
||||
invites.value = invites.value.filter(i => i.id !== invite.id)
|
||||
// Обновляем счётчик
|
||||
store.pendingInvitesCount = invites.value.length
|
||||
} else {
|
||||
toast.error('Ошибка отклонения приглашения')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка отклонения приглашения:', error)
|
||||
toast.error('Ошибка отклонения приглашения')
|
||||
} finally {
|
||||
processingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Обновление иконок
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
// Следим за сменой режима mobile/desktop
|
||||
watch(isMobile, async () => {
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
})
|
||||
|
||||
// Обновляем иконки когда данные загрузились
|
||||
watch(loading, async (newVal, oldVal) => {
|
||||
if (oldVal === true && newVal === false) {
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
loadInvites()
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
})
|
||||
|
||||
onUpdated(refreshIcons)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ========== MAIN ========== */
|
||||
.main {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.main.mobile {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
/* ========== ПУСТОЕ СОСТОЯНИЕ ========== */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
opacity: 0.4;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.empty-state span {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty-state.mobile {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.empty-state.mobile i {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
/* ========== СПИСОК ========== */
|
||||
.invites-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-width: 1200px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.invites-list.mobile {
|
||||
max-width: none;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ========== DESKTOP: КАРТОЧКА ========== */
|
||||
.invite-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.invite-card:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.invite-card.processing {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Аватар пользователя */
|
||||
.user-avatar {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--blue, #3b82f6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.user-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-initials {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Основной контент */
|
||||
.card-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.invite-separator {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.4;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.project-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.project-info i {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.project-label,
|
||||
.project-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
padding: 3px 6px;
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #f59e0b;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Дата */
|
||||
.card-date {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Кнопки */
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-action i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.btn-decline:hover {
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.btn-accept {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-accept:hover {
|
||||
background: #00e6b8;
|
||||
border-color: #00e6b8;
|
||||
}
|
||||
|
||||
.btn-action:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ========== MOBILE: КАРТОЧКА ========== */
|
||||
.invite-card.mobile {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .card-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .avatar-initials {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.invite-card.mobile .card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .user-name {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .card-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .project-label,
|
||||
.invite-card.mobile .project-name {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .project-info i {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .card-date {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .card-actions {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.invite-card.mobile .btn-action {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
@stats-updated="stats = $event"
|
||||
@open-task="openTaskPanel"
|
||||
@create-task="openNewTaskPanel"
|
||||
@cards-moved="onCardsMoved"
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -158,6 +159,11 @@ const onProjectChange = async () => {
|
||||
await fetchCards()
|
||||
}
|
||||
|
||||
// После перемещения карточки — тихо обновляем данные с сервера
|
||||
const onCardsMoved = async () => {
|
||||
await fetchCards(true)
|
||||
}
|
||||
|
||||
// ==================== СТАТИСТИКА ====================
|
||||
const stats = ref({ total: 0, inProgress: 0, done: 0 })
|
||||
|
||||
@@ -231,15 +237,26 @@ const onProjectSaved = async (projectId) => {
|
||||
}
|
||||
|
||||
// ==================== АВТООБНОВЛЕНИЕ (POLLING) ====================
|
||||
const REFRESH_INTERVAL = (window.APP_CONFIG?.IDLE_REFRESH_SECONDS ?? 30) * 1000
|
||||
const CARDS_REFRESH_INTERVAL = (window.APP_CONFIG?.REFRESH_INTERVALS?.cards ?? 30) * 1000
|
||||
const INVITES_REFRESH_INTERVAL = (window.APP_CONFIG?.REFRESH_INTERVALS?.invites ?? 30) * 1000
|
||||
let pollTimer = null
|
||||
let invitesPollTimer = null
|
||||
|
||||
const startPolling = () => {
|
||||
// Polling карточек
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
pollTimer = setInterval(async () => {
|
||||
// Не обновляем когда открыта модалка — это может прерывать клики
|
||||
if (panelOpen.value || projectPanelOpen.value) return
|
||||
console.log('[AutoRefresh] Обновление данных...')
|
||||
await fetchCards(true) // silent = true, без Loader
|
||||
}, REFRESH_INTERVAL)
|
||||
}, CARDS_REFRESH_INTERVAL)
|
||||
|
||||
// Polling приглашений (для бейджа)
|
||||
if (invitesPollTimer) clearInterval(invitesPollTimer)
|
||||
invitesPollTimer = setInterval(async () => {
|
||||
await store.fetchPendingInvitesCount()
|
||||
}, INVITES_REFRESH_INTERVAL)
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
@@ -247,11 +264,15 @@ const stopPolling = () => {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
if (invitesPollTimer) {
|
||||
clearInterval(invitesPollTimer)
|
||||
invitesPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== ИНИЦИАЛИЗАЦИЯ ====================
|
||||
onMounted(async () => {
|
||||
// Store уже мог быть инициализирован при логине (prefetch)
|
||||
// Store уже мог быть инициализирован при логине (prefetch) или в роутере
|
||||
await store.init()
|
||||
|
||||
// Проверяем предзагруженные карточки
|
||||
|
||||
621
front_vue/src/views/NoProjectsPage.vue
Normal file
621
front_vue/src/views/NoProjectsPage.vue
Normal file
@@ -0,0 +1,621 @@
|
||||
<template>
|
||||
<div class="no-projects-page" :class="{ 'is-mobile': isMobile }">
|
||||
<!-- Левитирующие иконки на фоне -->
|
||||
<div class="floating-icons">
|
||||
<div class="float-icon icon-1"><i data-lucide="folder"></i></div>
|
||||
<div class="float-icon icon-2"><i data-lucide="clipboard-list"></i></div>
|
||||
<div class="float-icon icon-3"><i data-lucide="calendar"></i></div>
|
||||
<div class="float-icon icon-4"><i data-lucide="users"></i></div>
|
||||
<div class="float-icon icon-5"><i data-lucide="check-square"></i></div>
|
||||
<div class="float-icon icon-6"><i data-lucide="target"></i></div>
|
||||
<div class="float-icon icon-7"><i data-lucide="star"></i></div>
|
||||
<div class="float-icon icon-8"><i data-lucide="zap"></i></div>
|
||||
<div class="float-icon icon-9"><i data-lucide="flag"></i></div>
|
||||
<div class="float-icon icon-10"><i data-lucide="layers"></i></div>
|
||||
<div class="float-icon icon-11"><i data-lucide="clock"></i></div>
|
||||
<div class="float-icon icon-12"><i data-lucide="inbox"></i></div>
|
||||
</div>
|
||||
|
||||
<!-- Контент -->
|
||||
<div class="content" :class="{ 'is-success': showSuccess }">
|
||||
|
||||
<!-- Состояние успеха -->
|
||||
<Transition name="success-fade">
|
||||
<div v-if="showSuccess" class="success-state">
|
||||
<div class="success-icon">
|
||||
<i data-lucide="check"></i>
|
||||
</div>
|
||||
<h1 class="success-title">{{ successType === 'create' ? 'Ура!' : 'Поздравляем!' }}</h1>
|
||||
<p class="success-text">{{ successType === 'create' ? 'Вы создали первый проект' : 'Ваш первый проект ждёт вас' }}</p>
|
||||
<div class="success-loader">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Основной контент -->
|
||||
<Transition name="form-fade">
|
||||
<div v-if="!showSuccess" class="main-content">
|
||||
<div class="icon-wrapper">
|
||||
<i data-lucide="folder-plus"></i>
|
||||
</div>
|
||||
|
||||
<h1>Нет доступных проектов</h1>
|
||||
|
||||
<p class="description">
|
||||
<template v-if="invites.length > 0">
|
||||
Примите приглашение или<br>создайте свой проект
|
||||
</template>
|
||||
<template v-else>
|
||||
Создайте новый проект или попросите<br>пригласить вас в существующий
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<!-- Приглашения -->
|
||||
<div v-if="invites.length > 0" class="invites-section">
|
||||
<div class="invites-header">
|
||||
<i data-lucide="mail"></i>
|
||||
<span>{{ invites.length }} {{ invitesLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="invites-list">
|
||||
<NotificationCard
|
||||
v-for="invite in invites"
|
||||
:key="invite.id"
|
||||
type="invite"
|
||||
:title="invite.project_name"
|
||||
:subtitle="'от ' + (invite.from_user.name || invite.from_user.username)"
|
||||
:avatar-url="invite.from_user.avatar_url ? getFullUrl(invite.from_user.avatar_url) : ''"
|
||||
:avatar-name="invite.from_user.name || invite.from_user.username"
|
||||
:badge="invite.is_admin ? 'Админ' : ''"
|
||||
:processing="processingId === invite.id"
|
||||
@accept="acceptInvite(invite)"
|
||||
@decline="declineInvite(invite)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-create" @click="openCreateProject">
|
||||
<i data-lucide="plus"></i>
|
||||
<span>Создать проект</span>
|
||||
</button>
|
||||
|
||||
<LogoutButton :show-text="true" />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Панель создания проекта -->
|
||||
<ProjectPanel
|
||||
:show="showProjectPanel"
|
||||
:project="null"
|
||||
@close="showProjectPanel = false"
|
||||
@saved="onProjectCreated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, onUpdated, nextTick, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ProjectPanel from '../components/ProjectPanel.vue'
|
||||
import NotificationCard from '../components/ui/NotificationCard.vue'
|
||||
import LogoutButton from '../components/ui/LogoutButton.vue'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
import { projectInviteApi, getFullUrl, cardsApi } from '../api'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
const router = useRouter()
|
||||
const store = useProjectsStore()
|
||||
|
||||
const showProjectPanel = ref(false)
|
||||
const invites = ref([])
|
||||
const processingId = ref(null)
|
||||
const showSuccess = ref(false)
|
||||
const successType = ref('invite') // 'invite' | 'create'
|
||||
|
||||
// Склонение слова "приглашение"
|
||||
const invitesLabel = computed(() => {
|
||||
const count = invites.value.length
|
||||
if (count === 1) return 'приглашение'
|
||||
if (count >= 2 && count <= 4) return 'приглашения'
|
||||
return 'приглашений'
|
||||
})
|
||||
|
||||
// Загрузка приглашений
|
||||
const loadInvites = async () => {
|
||||
try {
|
||||
const result = await projectInviteApi.getMyPending()
|
||||
if (result.success) {
|
||||
invites.value = result.data
|
||||
store.pendingInvitesCount = result.count
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки приглашений:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Предзагрузка данных пока показывается анимация успеха
|
||||
const prefetchData = async (projectId) => {
|
||||
try {
|
||||
// Перезагружаем проекты принудительно
|
||||
await store.refreshProjects()
|
||||
await store.selectProject(projectId)
|
||||
|
||||
// Загружаем карточки
|
||||
if (store.currentProjectId) {
|
||||
const result = await cardsApi.getAll(store.currentProjectId)
|
||||
if (result.success) {
|
||||
sessionStorage.setItem('prefetchedCards', JSON.stringify(result.data))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Prefetch error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Принять приглашение
|
||||
const acceptInvite = async (invite) => {
|
||||
processingId.value = invite.id
|
||||
try {
|
||||
const result = await projectInviteApi.accept(invite.id)
|
||||
if (result.success) {
|
||||
// Показываем анимацию успеха
|
||||
successType.value = 'invite'
|
||||
showSuccess.value = true
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
|
||||
// Параллельно: предзагрузка данных + ожидание анимации
|
||||
const prefetchPromise = prefetchData(result.project_id)
|
||||
const animationPromise = new Promise(resolve => setTimeout(resolve, 1800))
|
||||
|
||||
// Ждём обоих (минимум 1.8 сек анимации)
|
||||
await Promise.all([prefetchPromise, animationPromise])
|
||||
|
||||
router.push('/')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка принятия приглашения:', error)
|
||||
} finally {
|
||||
processingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Отклонить приглашение
|
||||
const declineInvite = async (invite) => {
|
||||
processingId.value = invite.id
|
||||
try {
|
||||
const result = await projectInviteApi.decline(invite.id)
|
||||
if (result.success) {
|
||||
invites.value = invites.value.filter(i => i.id !== invite.id)
|
||||
store.pendingInvitesCount = invites.value.length
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка отклонения приглашения:', error)
|
||||
} finally {
|
||||
processingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const openCreateProject = () => {
|
||||
showProjectPanel.value = true
|
||||
}
|
||||
|
||||
const onProjectCreated = async (projectId) => {
|
||||
showProjectPanel.value = false
|
||||
|
||||
// Показываем анимацию успеха
|
||||
successType.value = 'create'
|
||||
showSuccess.value = true
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
|
||||
// createProject() в store уже добавил проект и выбрал его
|
||||
// Нужно только загрузить карточки для предзагрузки
|
||||
const prefetchPromise = (async () => {
|
||||
try {
|
||||
if (store.currentProjectId) {
|
||||
const result = await cardsApi.getAll(store.currentProjectId)
|
||||
if (result.success) {
|
||||
sessionStorage.setItem('prefetchedCards', JSON.stringify(result.data))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Prefetch error:', e)
|
||||
}
|
||||
})()
|
||||
|
||||
const animationPromise = new Promise(resolve => setTimeout(resolve, 1800))
|
||||
|
||||
// Ждём обоих (минимум 1.8 сек анимации)
|
||||
await Promise.all([prefetchPromise, animationPromise])
|
||||
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
// Обновление иконок
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
// Периодическое обновление приглашений
|
||||
let refreshInterval = null
|
||||
const REFRESH_MS = (window.APP_CONFIG?.REFRESH_INTERVALS?.invites || 30) * 1000
|
||||
|
||||
onMounted(() => {
|
||||
loadInvites()
|
||||
refreshIcons()
|
||||
|
||||
// Запускаем периодическое обновление
|
||||
refreshInterval = setInterval(() => {
|
||||
// Не обновляем если показывается анимация успеха
|
||||
if (!showSuccess.value) {
|
||||
loadInvites()
|
||||
}
|
||||
}, REFRESH_MS)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval)
|
||||
refreshInterval = null
|
||||
}
|
||||
})
|
||||
|
||||
onUpdated(refreshIcons)
|
||||
|
||||
// Обновляем иконки при смене состояния успеха
|
||||
watch(showSuccess, () => {
|
||||
nextTick(refreshIcons)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.no-projects-page {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #0a0a0c 0%, #111113 50%, #0d1117 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Левитирующие иконки */
|
||||
.floating-icons {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.float-icon {
|
||||
position: absolute;
|
||||
color: rgba(255, 255, 255, 0.04);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.float-icon i,
|
||||
.float-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.icon-1 { width: 48px; height: 48px; top: 8%; left: 12%; animation: float1 6s ease-in-out infinite; }
|
||||
.icon-2 { width: 36px; height: 36px; top: 15%; right: 18%; animation: float2 7s ease-in-out infinite; }
|
||||
.icon-3 { width: 42px; height: 42px; top: 25%; left: 8%; animation: float3 8s ease-in-out infinite; }
|
||||
.icon-4 { width: 32px; height: 32px; top: 35%; right: 10%; animation: float1 5s ease-in-out infinite; }
|
||||
.icon-5 { width: 40px; height: 40px; top: 55%; left: 15%; animation: float2 9s ease-in-out infinite; }
|
||||
.icon-6 { width: 44px; height: 44px; top: 65%; right: 20%; animation: float3 6s ease-in-out infinite; }
|
||||
.icon-7 { width: 28px; height: 28px; top: 75%; left: 10%; animation: float1 7s ease-in-out infinite; }
|
||||
.icon-8 { width: 38px; height: 38px; top: 85%; right: 15%; animation: float2 8s ease-in-out infinite; }
|
||||
.icon-9 { width: 34px; height: 34px; bottom: 20%; left: 25%; animation: float3 5s ease-in-out infinite; }
|
||||
.icon-10 { width: 46px; height: 46px; top: 45%; left: 5%; animation: float1 10s ease-in-out infinite; }
|
||||
.icon-11 { width: 30px; height: 30px; top: 20%; left: 25%; animation: float2 6s ease-in-out infinite; }
|
||||
.icon-12 { width: 36px; height: 36px; bottom: 30%; right: 8%; animation: float3 7s ease-in-out infinite; }
|
||||
|
||||
@keyframes float1 {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
25% { transform: translateY(-15px) rotate(5deg); }
|
||||
75% { transform: translateY(10px) rotate(-3deg); }
|
||||
}
|
||||
|
||||
@keyframes float2 {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
33% { transform: translateY(-20px) rotate(-5deg); }
|
||||
66% { transform: translateY(15px) rotate(3deg); }
|
||||
}
|
||||
|
||||
@keyframes float3 {
|
||||
0%, 100% { transform: translateY(0) translateX(0); }
|
||||
50% { transform: translateY(-12px) translateX(8px); }
|
||||
}
|
||||
|
||||
/* Контент */
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
animation: contentAppear 0.6s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ========== ПРИГЛАШЕНИЯ ========== */
|
||||
.invites-section {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.invites-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: var(--accent);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.invites-header i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.invites-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@keyframes contentAppear {
|
||||
0% { opacity: 0; transform: translateY(20px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
margin: 0 auto 28px;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, #00e6b8 100%);
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 20px 60px rgba(0, 212, 170, 0.3);
|
||||
animation: iconFloat 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.icon-wrapper :deep(svg) {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
@keyframes iconFloat {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 12px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 15px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 36px;
|
||||
}
|
||||
|
||||
.btn-create {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, #00e6b8 100%);
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
color: #000;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: 14px;
|
||||
box-shadow: 0 10px 40px rgba(0, 212, 170, 0.3);
|
||||
}
|
||||
|
||||
.btn-create:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 15px 50px rgba(0, 212, 170, 0.4);
|
||||
}
|
||||
|
||||
.btn-create:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-create :deep(svg) {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
/* Мобильная адаптация */
|
||||
.no-projects-page.is-mobile h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.no-projects-page.is-mobile .description {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.no-projects-page.is-mobile .icon-wrapper {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
}
|
||||
|
||||
.no-projects-page.is-mobile .icon-wrapper :deep(svg) {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.no-projects-page.is-mobile .float-icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Мобильные стили для приглашений */
|
||||
.no-projects-page.is-mobile .invites-section {
|
||||
padding: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ========== СОСТОЯНИЕ УСПЕХА ========== */
|
||||
.success-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
animation: successAppear 0.6s ease;
|
||||
}
|
||||
|
||||
@keyframes successAppear {
|
||||
0% { opacity: 0; transform: scale(0.9); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, #00e6b8 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 28px;
|
||||
box-shadow: 0 25px 80px rgba(0, 212, 170, 0.4);
|
||||
animation: successPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.success-icon :deep(svg) {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: #000;
|
||||
animation: successCheck 0.5s ease 0.2s both;
|
||||
}
|
||||
|
||||
@keyframes successPulse {
|
||||
0%, 100% { transform: scale(1); box-shadow: 0 25px 80px rgba(0, 212, 170, 0.4); }
|
||||
50% { transform: scale(1.05); box-shadow: 0 30px 100px rgba(0, 212, 170, 0.5); }
|
||||
}
|
||||
|
||||
@keyframes successCheck {
|
||||
0% { transform: scale(0) rotate(-45deg); opacity: 0; }
|
||||
50% { transform: scale(1.2) rotate(10deg); }
|
||||
100% { transform: scale(1) rotate(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.success-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.success-text {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 32px 0;
|
||||
}
|
||||
|
||||
.success-loader {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.success-loader span {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: successLoaderBounce 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.success-loader span:nth-child(2) { animation-delay: 0.16s; }
|
||||
.success-loader span:nth-child(3) { animation-delay: 0.32s; }
|
||||
|
||||
@keyframes successLoaderBounce {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.form-fade-enter-active,
|
||||
.form-fade-leave-active {
|
||||
transition: opacity 0.4s ease, transform 0.4s ease;
|
||||
}
|
||||
|
||||
.form-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.form-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.success-fade-enter-active {
|
||||
transition: opacity 0.5s ease 0.2s, transform 0.5s ease 0.2s;
|
||||
}
|
||||
|
||||
.success-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
/* Мобильная адаптация успеха */
|
||||
.no-projects-page.is-mobile .success-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.no-projects-page.is-mobile .success-icon :deep(svg) {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.no-projects-page.is-mobile .success-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.no-projects-page.is-mobile .success-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,38 +1,100 @@
|
||||
<template>
|
||||
<div class="app" :class="{ mobile: isMobile }">
|
||||
<Sidebar />
|
||||
|
||||
<div class="main-wrapper">
|
||||
<Header title="Команда" subtitle="Наша команда специалистов" />
|
||||
<PageLayout>
|
||||
<Header title="Команда">
|
||||
<template #filters>
|
||||
<ProjectSelector />
|
||||
<div v-if="canManageMembers" class="team-actions desktop">
|
||||
<button
|
||||
class="team-action-btn"
|
||||
title="Пригласить участника"
|
||||
@click="openMemberPanel(null)"
|
||||
>
|
||||
<i data-lucide="plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<main class="main">
|
||||
<Loader v-if="loading" />
|
||||
<template #mobile-filters>
|
||||
<ProjectSelector />
|
||||
<div v-if="canManageMembers" class="team-actions">
|
||||
<button
|
||||
class="team-action-btn"
|
||||
title="Пригласить участника"
|
||||
@click="openMemberPanel(null)"
|
||||
>
|
||||
<i data-lucide="plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #stats>
|
||||
<div v-if="users.length > 0" class="header-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">{{ users.length }}</span>
|
||||
<span class="stat-label">в команде</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Header>
|
||||
|
||||
<main class="main" :class="{ mobile: isMobile }">
|
||||
<Loader v-if="loading" />
|
||||
|
||||
<!-- Desktop: grid -->
|
||||
<div v-else-if="!isMobile" class="team-grid">
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
:key="user.id_user"
|
||||
class="team-card"
|
||||
>
|
||||
<div class="card-avatar">
|
||||
<img v-if="user.avatar_url" :src="getFullUrl(user.avatar_url)" :alt="user.name || ''">
|
||||
<span v-else class="avatar-placeholder">{{ (user.name || '?')[0] }}</span>
|
||||
<div
|
||||
class="card-avatar"
|
||||
:class="{ clickable: canEditUser(user) }"
|
||||
@click="canEditUser(user) && openMemberPanel(user)"
|
||||
>
|
||||
<img v-if="user.avatar_url" :src="getFullUrl(user.avatar_url)" :alt="user.name || user.username || ''">
|
||||
<span v-else class="avatar-placeholder">{{ (user.name || user.username || '?')[0] }}</span>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<h3 class="card-name">{{ user.name || 'Без имени' }}</h3>
|
||||
<h3 class="card-name">{{ user.name || user.username || 'Без имени' }}</h3>
|
||||
<div class="card-meta">
|
||||
<span v-if="user.department" class="card-department">{{ user.department }}</span>
|
||||
<span v-if="user.username" class="card-username">@{{ user.username }}</span>
|
||||
<button
|
||||
v-if="canEditUser(user)"
|
||||
class="card-edit-btn"
|
||||
title="Редактировать права"
|
||||
@click="openMemberPanel(user)"
|
||||
>
|
||||
<i data-lucide="pencil"></i>
|
||||
</button>
|
||||
<span v-if="user.is_owner" class="card-role owner">Создатель</span>
|
||||
<span v-else-if="user.is_admin" class="card-role admin">Админ</span>
|
||||
<span v-else class="card-role member">Участник</span>
|
||||
<button
|
||||
v-if="canRemoveUser(user)"
|
||||
class="card-remove-btn"
|
||||
title="Удалить из проекта"
|
||||
@click="confirmRemoveUser(user)"
|
||||
>
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
<!-- Кнопка выхода - только для себя -->
|
||||
<button
|
||||
v-if="isCurrentUser(user)"
|
||||
class="card-leave-btn"
|
||||
title="Выйти из проекта"
|
||||
@click="confirmLeaveProject"
|
||||
>
|
||||
<i data-lucide="log-out"></i>
|
||||
</button>
|
||||
</div>
|
||||
<a
|
||||
v-if="user.telegram"
|
||||
:href="'https://t.me/' + user.telegram.replace('@', '')"
|
||||
v-if="user.telegram"
|
||||
:href="`https://t.me/${user.telegram}`"
|
||||
target="_blank"
|
||||
class="card-telegram"
|
||||
>
|
||||
<i data-lucide="send"></i>
|
||||
{{ user.telegram }}
|
||||
@{{ user.telegram }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,84 +105,246 @@
|
||||
<div class="mobile-cards" ref="mobileCardsRef" @scroll="onCardsScroll">
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
:key="user.id_user"
|
||||
class="mobile-card"
|
||||
>
|
||||
<div class="mobile-avatar">
|
||||
<img v-if="user.avatar_url" :src="getFullUrl(user.avatar_url)" :alt="user.name || ''">
|
||||
<span v-else class="avatar-placeholder">{{ (user.name || '?')[0] }}</span>
|
||||
<div
|
||||
class="mobile-avatar"
|
||||
:class="{ clickable: canEditUser(user) }"
|
||||
@click="canEditUser(user) && openMemberPanel(user)"
|
||||
>
|
||||
<img v-if="user.avatar_url" :src="getFullUrl(user.avatar_url)" :alt="user.name || user.username || ''">
|
||||
<span v-else class="avatar-placeholder">{{ (user.name || user.username || '?')[0] }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mobile-info">
|
||||
<h2 class="mobile-name">{{ user.name || 'Без имени' }}</h2>
|
||||
<span v-if="user.username" class="mobile-username">@{{ user.username }}</span>
|
||||
<span v-if="user.department" class="mobile-department">{{ user.department }}</span>
|
||||
<h2 class="mobile-name">{{ user.name || user.username || 'Без имени' }}</h2>
|
||||
<div class="mobile-role-row">
|
||||
<button
|
||||
v-if="canEditUser(user)"
|
||||
class="mobile-edit-btn"
|
||||
title="Редактировать права"
|
||||
@click="openMemberPanel(user)"
|
||||
>
|
||||
<i data-lucide="pencil"></i>
|
||||
</button>
|
||||
<span v-if="user.is_owner" class="mobile-role owner">Создатель</span>
|
||||
<span v-else-if="user.is_admin" class="mobile-role admin">Администратор</span>
|
||||
<span v-else class="mobile-role member">Участник</span>
|
||||
<button
|
||||
v-if="canRemoveUser(user)"
|
||||
class="mobile-remove-btn"
|
||||
title="Удалить из проекта"
|
||||
@click="confirmRemoveUser(user)"
|
||||
>
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
<!-- Кнопка выхода - только для себя -->
|
||||
<button
|
||||
v-if="isCurrentUser(user)"
|
||||
class="mobile-leave-btn"
|
||||
title="Выйти из проекта"
|
||||
@click="confirmLeaveProject"
|
||||
>
|
||||
<i data-lucide="log-out"></i>
|
||||
</button>
|
||||
</div>
|
||||
<a
|
||||
v-if="user.telegram"
|
||||
:href="`https://t.me/${user.telegram}`"
|
||||
target="_blank"
|
||||
class="mobile-telegram"
|
||||
>
|
||||
<i data-lucide="send"></i>
|
||||
Написать в Telegram
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a
|
||||
v-if="user.telegram"
|
||||
:href="'https://t.me/' + user.telegram.replace('@', '')"
|
||||
target="_blank"
|
||||
class="mobile-telegram"
|
||||
>
|
||||
<i data-lucide="send"></i>
|
||||
Написать в Telegram
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Фиксированные индикаторы над навигацией -->
|
||||
<div v-if="isMobile && !loading && users.length > 0" class="mobile-team-footer">
|
||||
<div class="team-indicators">
|
||||
<button
|
||||
v-for="(user, index) in users"
|
||||
:key="user.id"
|
||||
class="indicator-dot"
|
||||
:class="{ active: currentUserIndex === index }"
|
||||
@click="scrollToUser(index)"
|
||||
>
|
||||
<img v-if="user.avatar_url" :src="getFullUrl(user.avatar_url)" :alt="user.name || ''">
|
||||
<span v-else class="avatar-placeholder-small">{{ (user.name || '?')[0] }}</span>
|
||||
</button>
|
||||
<!-- Фиксированные индикаторы над навигацией -->
|
||||
<div v-if="isMobile && !loading && users.length > 0" class="mobile-team-footer">
|
||||
<div class="team-indicators">
|
||||
<button
|
||||
v-for="(user, index) in users"
|
||||
:key="user.id_user"
|
||||
class="indicator-dot"
|
||||
:class="{ active: currentUserIndex === index }"
|
||||
@click="scrollToUser(index)"
|
||||
>
|
||||
<img v-if="user.avatar_url" :src="getFullUrl(user.avatar_url)" :alt="user.name || user.username || ''">
|
||||
<span v-else class="avatar-placeholder-small">{{ (user.name || user.username || '?')[0] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Панель участника -->
|
||||
<MemberPanel
|
||||
:show="showMemberPanel"
|
||||
:member="selectedMember"
|
||||
@close="closeMemberPanel"
|
||||
@saved="store.fetchUsers()"
|
||||
/>
|
||||
|
||||
<!-- Диалог подтверждения удаления -->
|
||||
<ConfirmDialog
|
||||
:show="showRemoveDialog"
|
||||
type="removeMember"
|
||||
:message="removeDialogMessage"
|
||||
:action="doRemoveMember"
|
||||
@confirm="showRemoveDialog = false"
|
||||
@cancel="showRemoveDialog = false"
|
||||
/>
|
||||
|
||||
<!-- Диалог подтверждения выхода из проекта -->
|
||||
<ConfirmDialog
|
||||
:show="showLeaveDialog"
|
||||
type="leaveProject"
|
||||
:action="doLeaveProject"
|
||||
@confirm="showLeaveDialog = false"
|
||||
@cancel="showLeaveDialog = false"
|
||||
/>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onUpdated } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import Sidebar from '../components/Sidebar.vue'
|
||||
import { ref, computed, watch, onMounted, onUpdated, onUnmounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import PageLayout from '../components/PageLayout.vue'
|
||||
import Header from '../components/Header.vue'
|
||||
import Loader from '../components/ui/Loader.vue'
|
||||
import { usersApi, getFullUrl } from '../api'
|
||||
import ProjectSelector from '../components/ProjectSelector.vue'
|
||||
import MemberPanel from '../components/MemberPanel.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import { getFullUrl, projectAccessApi } from '../api'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { isMobile } = useMobile()
|
||||
const store = useProjectsStore()
|
||||
|
||||
const users = ref([])
|
||||
const loading = ref(true)
|
||||
const mobileCardsRef = ref(null)
|
||||
const currentUserIndex = ref(0)
|
||||
const { users, loading, currentProjectId } = storeToRefs(store)
|
||||
|
||||
const fetchUsers = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await usersApi.getAll()
|
||||
if (data.success) {
|
||||
users.value = data.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки команды:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
// Права на управление участниками (приглашать/редактировать - только админ)
|
||||
const canManageMembers = computed(() => store.isProjectAdmin)
|
||||
|
||||
// Права на удаление участников (remove_members или админ)
|
||||
const canRemoveMembers = computed(() => store.isProjectAdmin || store.can('remove_members'))
|
||||
|
||||
// Можно ли редактировать пользователя (не себя и не владельца) - только админ
|
||||
const canEditUser = (user) => {
|
||||
return canManageMembers.value && Number(user.id_user) !== store.currentUserId && !user.is_owner
|
||||
}
|
||||
|
||||
// Можно ли удалить пользователя (только для не-админов с правом remove_members)
|
||||
// Админы удаляют через панель редактирования
|
||||
const canRemoveUser = (user) => {
|
||||
return !store.isProjectAdmin && store.can('remove_members') && Number(user.id_user) !== store.currentUserId && !user.is_owner
|
||||
}
|
||||
|
||||
// Это текущий пользователь? (для кнопки выхода)
|
||||
const isCurrentUser = (user) => {
|
||||
return Number(user.id_user) === store.currentUserId && !user.is_owner
|
||||
}
|
||||
|
||||
// Диалог удаления участника
|
||||
const showRemoveDialog = ref(false)
|
||||
const removeDialogMessage = ref('')
|
||||
const userToRemove = ref(null)
|
||||
|
||||
// Открыть диалог удаления
|
||||
const confirmRemoveUser = (user) => {
|
||||
userToRemove.value = user
|
||||
removeDialogMessage.value = `<b>${user.name || user.username}</b> будет удалён из проекта.`
|
||||
showRemoveDialog.value = true
|
||||
}
|
||||
|
||||
// Выполнить удаление
|
||||
const doRemoveMember = async () => {
|
||||
if (!userToRemove.value) return
|
||||
|
||||
const result = await projectAccessApi.removeMember(store.currentProjectId, userToRemove.value.id_user)
|
||||
if (result.success) {
|
||||
await store.fetchUsers()
|
||||
} else {
|
||||
throw new Error(result.errors?.member || 'Ошибка удаления')
|
||||
}
|
||||
}
|
||||
|
||||
// Диалог выхода из проекта
|
||||
const showLeaveDialog = ref(false)
|
||||
|
||||
const confirmLeaveProject = () => {
|
||||
showLeaveDialog.value = true
|
||||
}
|
||||
|
||||
// Выполнить выход из проекта
|
||||
const doLeaveProject = async () => {
|
||||
const projectIdToLeave = store.currentProjectId
|
||||
const result = await projectAccessApi.removeMember(projectIdToLeave, store.currentUserId)
|
||||
if (result.success) {
|
||||
toast.success('Вы вышли из проекта')
|
||||
|
||||
// Очищаем текущий проект из localStorage чтобы избежать ошибок доступа
|
||||
localStorage.removeItem('currentProjectId')
|
||||
localStorage.removeItem('currentProjectName')
|
||||
store.currentProjectId = null
|
||||
|
||||
// Принудительно перезагружаем список проектов
|
||||
await store.refreshProjects()
|
||||
|
||||
// Проверяем есть ли другие проекты
|
||||
if (store.projects.length > 0) {
|
||||
// Переключаемся на первый доступный проект
|
||||
await store.selectProject(store.projects[0].id)
|
||||
router.push('/')
|
||||
} else {
|
||||
router.push('/no-projects')
|
||||
}
|
||||
} else {
|
||||
toast.error(result.errors?.member || 'Ошибка выхода из проекта')
|
||||
throw new Error(result.errors?.member || 'Ошибка выхода из проекта')
|
||||
}
|
||||
}
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
// Инициализация store при прямом открытии страницы
|
||||
onMounted(async () => {
|
||||
// Store уже мог быть инициализирован в роутере
|
||||
if (!store.initialized) {
|
||||
await store.init()
|
||||
}
|
||||
refreshIcons()
|
||||
})
|
||||
|
||||
// Панель участника
|
||||
const showMemberPanel = ref(false)
|
||||
const selectedMember = ref(null)
|
||||
|
||||
const openMemberPanel = (member = null) => {
|
||||
selectedMember.value = member
|
||||
showMemberPanel.value = true
|
||||
}
|
||||
|
||||
const closeMemberPanel = () => {
|
||||
showMemberPanel.value = false
|
||||
selectedMember.value = null
|
||||
}
|
||||
|
||||
const mobileCardsRef = ref(null)
|
||||
const currentUserIndex = ref(0)
|
||||
|
||||
const onCardsScroll = () => {
|
||||
if (!mobileCardsRef.value) return
|
||||
const container = mobileCardsRef.value
|
||||
@@ -129,46 +353,89 @@ const onCardsScroll = () => {
|
||||
currentUserIndex.value = Math.round(scrollLeft / cardWidth)
|
||||
}
|
||||
|
||||
const scrollToUser = (index) => {
|
||||
const scrollToUser = (index, smooth = true) => {
|
||||
if (!mobileCardsRef.value) return
|
||||
const container = mobileCardsRef.value
|
||||
const cardWidth = container.offsetWidth
|
||||
container.scrollTo({
|
||||
left: index * cardWidth,
|
||||
behavior: 'smooth'
|
||||
behavior: smooth ? 'smooth' : 'instant'
|
||||
})
|
||||
}
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
// При ресайзе — пересчитываем scroll позицию чтобы карточка не съезжала
|
||||
const onResize = () => {
|
||||
if (mobileCardsRef.value && currentUserIndex.value > 0) {
|
||||
scrollToUser(currentUserIndex.value, false)
|
||||
}
|
||||
}
|
||||
|
||||
// Загружаем при входе на страницу
|
||||
watch(() => route.path, async (path) => {
|
||||
if (path === '/team') {
|
||||
await fetchUsers()
|
||||
// При смене режима mobile/desktop - сбрасываем и перерисовываем иконки
|
||||
watch(isMobile, async (newVal) => {
|
||||
if (!newVal) {
|
||||
// Переключились на desktop — сбрасываем индекс
|
||||
currentUserIndex.value = 0
|
||||
}
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
})
|
||||
|
||||
// Слушаем ресайз окна
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
})
|
||||
|
||||
// Загружаем участников при смене проекта
|
||||
watch(currentProjectId, async (newId, oldId) => {
|
||||
if (newId && oldId && newId !== oldId) {
|
||||
await store.fetchUsers()
|
||||
refreshIcons()
|
||||
}
|
||||
}, { immediate: true })
|
||||
})
|
||||
|
||||
onUpdated(refreshIcons)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
/* Кнопки действий (как на главной) */
|
||||
.team-actions {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.main-wrapper {
|
||||
flex: 1;
|
||||
margin-left: 64px;
|
||||
.team-actions.desktop {
|
||||
margin-left: -16px;
|
||||
}
|
||||
|
||||
.team-action-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
max-height: 100vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.team-action-btn:hover,
|
||||
.team-action-btn:active {
|
||||
color: var(--accent);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.team-action-btn i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.main {
|
||||
@@ -214,7 +481,7 @@ onUpdated(refreshIcons)
|
||||
}
|
||||
|
||||
.team-card:hover {
|
||||
border-color: var(--accent);
|
||||
border-color: var(--border-hover, rgba(255, 255, 255, 0.15));
|
||||
}
|
||||
|
||||
.card-avatar {
|
||||
@@ -224,6 +491,15 @@ onUpdated(refreshIcons)
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.card-avatar.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-avatar.clickable:hover {
|
||||
box-shadow: 0 0 0 3px var(--accent);
|
||||
}
|
||||
|
||||
.card-avatar img {
|
||||
@@ -274,17 +550,89 @@ onUpdated(refreshIcons)
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card-department {
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
.card-role {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.card-role.owner {
|
||||
color: var(--accent);
|
||||
background: rgba(0, 212, 170, 0.15);
|
||||
}
|
||||
|
||||
.card-role.admin {
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
|
||||
.card-role.member {
|
||||
color: var(--text-muted);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.card-edit-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.card-edit-btn:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.card-edit-btn i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.card-remove-btn,
|
||||
.card-leave-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.card-remove-btn {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.card-remove-btn:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.card-leave-btn {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card-leave-btn:hover {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.card-remove-btn i,
|
||||
.card-leave-btn i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.card-telegram {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -311,31 +659,7 @@ onUpdated(refreshIcons)
|
||||
}
|
||||
|
||||
/* Mobile styles */
|
||||
.app.mobile {
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.app.mobile .main-wrapper {
|
||||
margin-left: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app.mobile .main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
.main.mobile {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -382,6 +706,15 @@ onUpdated(refreshIcons)
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(0, 212, 170, 0.3),
|
||||
0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mobile-avatar.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-avatar.clickable:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.mobile-avatar img {
|
||||
@@ -414,16 +747,94 @@ onUpdated(refreshIcons)
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mobile-department {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
.mobile-role-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mobile-role {
|
||||
padding: 8px 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
border-radius: 20px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mobile-role.owner {
|
||||
color: var(--accent);
|
||||
background: rgba(0, 212, 170, 0.15);
|
||||
}
|
||||
|
||||
.mobile-role.admin {
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
|
||||
.mobile-role.member {
|
||||
color: var(--text-muted);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.mobile-edit-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.mobile-edit-btn:active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mobile-edit-btn i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.mobile-remove-btn,
|
||||
.mobile-leave-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.mobile-remove-btn {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.mobile-remove-btn:active {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.mobile-leave-btn {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mobile-leave-btn:active {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.mobile-remove-btn i,
|
||||
.mobile-leave-btn i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.mobile-telegram {
|
||||
|
||||
Reference in New Issue
Block a user