1
0
Files
TaskBoard/front_vue/src/views/MainApp.vue
Falknat cb075e56be Правка фронта
1. Улучшения мобильной версии
2. Улучшения комментариев фронта
3. Единый лоадер UI
2026-01-16 05:41:30 +07:00

435 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="app" :class="{ mobile: isMobile }">
<!-- Боковая панель навигации -->
<Sidebar />
<!-- Основной контент -->
<div class="main-wrapper">
<!-- Шапка с заголовком, фильтрами и статистикой -->
<Header title="Доска задач">
<!-- Десктоп: фильтры в одну строку -->
<template #filters>
<div class="filters">
<ProjectSelector @change="onProjectChange" />
<div class="filter-divider"></div>
<button
class="filter-tag"
:class="{ active: activeDepartment === null }"
@click="activeDepartment = null"
>
Все
</button>
<button
v-for="dept in store.departments"
:key="dept.id"
class="filter-tag"
:class="{ active: activeDepartment === dept.id }"
@click="activeDepartment = activeDepartment === dept.id ? null : dept.id"
>
{{ dept.name_departments }}
</button>
</div>
</template>
<!-- Мобильный: Проект + Отделы -->
<template #mobile-filters>
<ProjectSelector @change="onProjectChange" />
<MobileSelect
v-model="activeDepartment"
:options="departmentOptions"
title="Фильтр по отделам"
placeholder="Все отделы"
icon="filter"
compact
/>
</template>
<!-- Десктоп: статистика -->
<template #stats>
<div class="header-stats">
<div class="stat">
<span class="stat-value">{{ stats.total }}</span>
<span class="stat-label">задач</span>
</div>
<div class="stat-divider"></div>
<div class="stat">
<span class="stat-value">{{ stats.inProgress }}</span>
<span class="stat-label">в работе</span>
</div>
<div class="stat-divider"></div>
<div class="stat">
<span class="stat-value">{{ stats.done }}</span>
<span class="stat-label">готово</span>
</div>
</div>
</template>
</Header>
<!-- Доска с колонками и карточками -->
<main class="main">
<Loader v-if="loading" />
<Board
v-else
ref="boardRef"
:active-department="activeDepartment"
:departments="store.departments"
:labels="store.labels"
:columns="store.columns"
:cards="cards"
:done-column-id="store.doneColumnId"
@stats-updated="stats = $event"
@open-task="openTaskPanel"
@create-task="openNewTaskPanel"
/>
</main>
</div>
<!-- Панель редактирования/создания задачи -->
<TaskPanel
:show="panelOpen"
:card="editingCard"
:column-id="editingColumnId"
:done-column-id="store.doneColumnId"
:departments="store.departments"
:labels="store.labels"
:users="store.users"
:current-user-id="store.currentUserId"
:is-project-admin="store.isProjectAdmin"
:on-save="handleSaveTask"
@close="closePanel"
@delete="handleDeleteTask"
@archive="handleArchiveTask"
/>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import Sidebar from '../components/Sidebar.vue'
import Header from '../components/Header.vue'
import Board from '../components/Board.vue'
import TaskPanel from '../components/TaskPanel'
import ProjectSelector from '../components/ProjectSelector.vue'
import MobileSelect from '../components/ui/MobileSelect.vue'
import Loader from '../components/ui/Loader.vue'
import { useProjectsStore } from '../stores/projects'
import { cardsApi } from '../api'
import { useMobile } from '../composables/useMobile'
const { isMobile } = useMobile()
// ==================== STORE ====================
const store = useProjectsStore()
// ==================== МОБИЛЬНЫЕ СЕЛЕКТОРЫ ====================
const departmentOptions = computed(() => [
{ id: null, label: 'Все отделы' },
...store.departments.map(d => ({ id: d.id, label: d.name_departments }))
])
// ==================== КАРТОЧКИ ====================
const cards = ref([])
const loading = ref(true)
// Загрузка карточек текущего проекта (silent = тихое обновление без Loader)
const fetchCards = async (silent = false) => {
if (!store.currentProjectId) {
loading.value = false
return
}
if (!silent) {
loading.value = true
}
try {
const result = await cardsApi.getAll(store.currentProjectId)
if (result.success) cards.value = result.data
} finally {
if (!silent) {
loading.value = false
}
}
}
// При смене проекта — перезагружаем карточки
const onProjectChange = async () => {
activeDepartment.value = null
loading.value = true
await fetchCards()
}
// ==================== ФИЛЬТР ПО ОТДЕЛАМ ====================
const savedDepartment = localStorage.getItem('activeDepartment')
const activeDepartment = ref(savedDepartment ? parseInt(savedDepartment) : null)
watch(activeDepartment, (newVal) => {
if (newVal === null) {
localStorage.removeItem('activeDepartment')
} else {
localStorage.setItem('activeDepartment', newVal.toString())
}
})
// ==================== СТАТИСТИКА ====================
const stats = ref({ total: 0, inProgress: 0, done: 0 })
// ==================== ПАНЕЛЬ РЕДАКТИРОВАНИЯ ====================
const boardRef = ref(null)
const panelOpen = ref(false)
const editingCard = ref(null)
const editingColumnId = ref(null)
const openTaskPanel = ({ card, columnId }) => {
editingCard.value = card
editingColumnId.value = columnId
panelOpen.value = true
}
const openNewTaskPanel = (columnId) => {
editingCard.value = null
editingColumnId.value = columnId
panelOpen.value = true
}
const closePanel = () => {
panelOpen.value = false
editingCard.value = null
editingColumnId.value = null
}
const handleSaveTask = async (taskData) => {
if (!taskData.id && store.currentProjectId) {
taskData.id_project = store.currentProjectId
}
await boardRef.value?.saveTask(taskData, editingColumnId.value)
closePanel()
}
const handleDeleteTask = async (cardId) => {
await boardRef.value?.deleteTask(cardId, editingColumnId.value)
closePanel()
}
const handleArchiveTask = async (cardId) => {
await boardRef.value?.archiveTask(cardId)
closePanel()
}
// ==================== АВТООБНОВЛЕНИЕ (POLLING) ====================
const REFRESH_INTERVAL = (window.APP_CONFIG?.IDLE_REFRESH_SECONDS ?? 30) * 1000
let pollTimer = null
const startPolling = () => {
if (pollTimer) clearInterval(pollTimer)
pollTimer = setInterval(async () => {
console.log('[AutoRefresh] Обновление данных...')
await fetchCards(true) // silent = true, без Loader
}, REFRESH_INTERVAL)
}
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
// ==================== ИНИЦИАЛИЗАЦИЯ ====================
onMounted(async () => {
// Store уже мог быть инициализирован при логине (prefetch)
await store.init()
// Проверяем предзагруженные карточки
const prefetchedCards = sessionStorage.getItem('prefetchedCards')
if (prefetchedCards) {
try {
cards.value = JSON.parse(prefetchedCards)
sessionStorage.removeItem('prefetchedCards')
loading.value = false
} catch (e) {
await fetchCards()
}
} else {
await fetchCards()
}
startPolling()
if (window.lucide) window.lucide.createIcons()
})
onUnmounted(() => {
stopPolling()
})
</script>
<style scoped>
/* Контейнер приложения */
.app {
display: flex;
min-height: 100vh;
}
/* Основная область контента */
.main-wrapper {
flex: 1;
margin-left: 64px;
display: flex;
flex-direction: column;
background: var(--bg-main);
}
/* ========== MOBILE ========== */
.app.mobile {
height: 100vh;
height: 100dvh; /* Динамическая высота для iOS */
overflow: hidden;
}
.app.mobile .main-wrapper {
margin-left: 0;
padding-bottom: calc(64px + var(--safe-area-bottom, 0px)); /* место для нижней навигации + safe area */
height: 100vh;
height: 100dvh; /* Динамическая высота для iOS */
overflow: hidden;
}
/* Контейнер фильтров */
.filters {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
/* На мобильных — горизонтальный скролл */
.app.mobile .filters {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
}
.app.mobile .filters::-webkit-scrollbar {
display: none;
}
/* Разделитель между проектом и отделами */
.filter-divider {
width: 1px;
height: 24px;
background: rgba(255, 255, 255, 0.1);
margin: 0 8px;
flex-shrink: 0;
}
/* Кнопка фильтра */
.filter-tag {
padding: 6px 12px;
background: var(--bg-card);
border: none;
border-radius: 6px;
color: var(--text-secondary);
font-family: inherit;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.3px;
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
white-space: nowrap;
}
.filter-tag:hover {
background: var(--bg-card-hover);
color: var(--text-primary);
}
/* Активный фильтр */
.filter-tag.active {
background: var(--accent);
color: #000;
}
/* Блок статистики */
.header-stats {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 20px;
background: var(--bg-card);
border-radius: 10px;
}
.stat {
display: flex;
align-items: baseline;
gap: 5px;
}
.stat-value {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
}
/* Разделитель между статами */
.stat-divider {
width: 1px;
height: 16px;
background: rgba(255, 255, 255, 0.1);
}
/* ========== MOBILE: статистика ========== */
.app.mobile .header-stats {
display: none;
}
/* Основная область с доской */
.main {
flex: 1;
padding: 0 36px 36px;
overflow-x: auto;
scroll-behavior: smooth;
}
/* Стилизация горизонтального скроллбара */
.main::-webkit-scrollbar {
height: 10px;
}
.main::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.08);
border-radius: 5px;
margin: 0 36px;
}
.main::-webkit-scrollbar-thumb {
background: rgba(0, 212, 170, 0.4);
border-radius: 5px;
border: 2px solid transparent;
background-clip: padding-box;
}
.main::-webkit-scrollbar-thumb:hover {
background: rgba(0, 212, 170, 0.6);
}
/* ========== MOBILE: доска ========== */
.app.mobile .main {
flex: 1;
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0; /* Важно для flex children с overflow */
}
</style>