Мобильная версия
1. Адаптация и разработка мобильного варианта.
This commit is contained in:
@@ -2,6 +2,21 @@
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { initMobileClass } from './composables/useMobile'
|
||||
|
||||
let cleanup = null
|
||||
|
||||
onMounted(() => {
|
||||
cleanup = initMobileClass()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (cleanup) cleanup()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Сброс стилей */
|
||||
* {
|
||||
@@ -38,4 +53,34 @@ body {
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Мобильный режим — фиксируем body */
|
||||
body.is-mobile {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: 100dvh; /* Динамическая высота для iOS */
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
body.is-mobile #app {
|
||||
height: 100%;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Safe area для iPhone (notch и home indicator) */
|
||||
:root {
|
||||
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
/* Блокировка скролла когда панель открыта */
|
||||
body.panel-open {
|
||||
overflow: hidden !important;
|
||||
position: fixed !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
touch-action: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,75 +1,140 @@
|
||||
<template>
|
||||
<div
|
||||
class="archive-card"
|
||||
:class="{ 'has-label-color': cardLabelColor }"
|
||||
:class="{ 'has-label-color': cardLabelColor, mobile: isMobile }"
|
||||
:style="cardLabelColor ? { '--label-bg': cardLabelColor } : {}"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<!-- Аватарка -->
|
||||
<div class="card-assignee" v-if="card.assignee">
|
||||
<img
|
||||
v-if="isAvatarUrl(card.assignee)"
|
||||
:src="getFullUrl(card.assignee)"
|
||||
alt="avatar"
|
||||
class="assignee-img"
|
||||
/>
|
||||
<span v-else class="assignee-initials">{{ card.assignee }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Контент -->
|
||||
<div class="card-main">
|
||||
<div class="card-content">
|
||||
<h3 class="card-title">
|
||||
<span v-if="cardLabel" class="label-icon" :title="cardLabel.name_labels">{{ cardLabel.icon }}</span>
|
||||
{{ card.title }}
|
||||
</h3>
|
||||
<p v-if="card.description" class="card-description">{{ card.description }}</p>
|
||||
<!-- Desktop версия -->
|
||||
<template v-if="!isMobile">
|
||||
<!-- Аватарка -->
|
||||
<div class="card-assignee" v-if="card.assignee">
|
||||
<img
|
||||
v-if="isAvatarUrl(card.assignee)"
|
||||
:src="getFullUrl(card.assignee)"
|
||||
alt="avatar"
|
||||
class="assignee-img"
|
||||
/>
|
||||
<span v-else class="assignee-initials">{{ card.assignee }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Центральная часть: мета-информация -->
|
||||
<div class="card-meta">
|
||||
<span
|
||||
v-if="cardDepartment"
|
||||
class="department-tag"
|
||||
:style="{ color: cardDepartment.color }"
|
||||
>
|
||||
{{ cardDepartment.name_departments }}
|
||||
</span>
|
||||
<span v-if="card.files && card.files.length" class="files-badge" :title="card.files.length + ' файлов'">
|
||||
<i data-lucide="image"></i>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Контент -->
|
||||
<div class="card-main">
|
||||
<div class="card-content">
|
||||
<h3 class="card-title">
|
||||
<span v-if="cardLabel" class="label-icon" :title="cardLabel.name_labels">{{ cardLabel.icon }}</span>
|
||||
{{ card.title }}
|
||||
</h3>
|
||||
<p v-if="card.description" class="card-description">{{ card.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Даты: создано и выполнено -->
|
||||
<div class="card-dates">
|
||||
<span class="date-created">{{ formatDateFull(card.dateCreate) }}</span>
|
||||
<span class="date-closed">{{ formatDateFull(card.dateClosed) }}</span>
|
||||
</div>
|
||||
<!-- Центральная часть: мета-информация -->
|
||||
<div class="card-meta">
|
||||
<span
|
||||
v-if="cardDepartment"
|
||||
class="department-tag"
|
||||
:style="{ color: cardDepartment.color }"
|
||||
>
|
||||
{{ cardDepartment.name_departments }}
|
||||
</span>
|
||||
<span v-if="card.files && card.files.length" class="files-badge" :title="card.files.length + ' файлов'">
|
||||
<i data-lucide="image"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Кнопки действий (всегда видны) -->
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="btn-action btn-restore"
|
||||
@click.stop="$emit('restore', card.id)"
|
||||
title="Вернуть из архива"
|
||||
>
|
||||
<i data-lucide="archive-restore"></i>
|
||||
</button>
|
||||
<button
|
||||
class="btn-action btn-delete"
|
||||
@click.stop="$emit('delete', card.id)"
|
||||
title="Удалить навсегда"
|
||||
>
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Даты: создано и выполнено -->
|
||||
<div class="card-dates">
|
||||
<span class="date-created">{{ formatDateFull(card.dateCreate) }}</span>
|
||||
<span class="date-closed">{{ formatDateFull(card.dateClosed) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Кнопки действий (всегда видны) -->
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="btn-action btn-restore"
|
||||
@click.stop="$emit('restore', card.id)"
|
||||
title="Вернуть из архива"
|
||||
>
|
||||
<i data-lucide="archive-restore"></i>
|
||||
</button>
|
||||
<button
|
||||
class="btn-action btn-delete"
|
||||
@click.stop="$emit('delete', card.id)"
|
||||
title="Удалить навсегда"
|
||||
>
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Mobile версия (как Card.vue) -->
|
||||
<template v-else>
|
||||
<div class="card-header">
|
||||
<div class="tag-group">
|
||||
<span v-if="cardLabel" class="label-icon">{{ cardLabel.icon }}</span>
|
||||
<span
|
||||
v-if="cardDepartment"
|
||||
class="tag"
|
||||
:style="{ color: cardDepartment.color }"
|
||||
>
|
||||
{{ cardDepartment.name_departments }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button
|
||||
class="btn-action btn-restore"
|
||||
@click.stop="$emit('restore', card.id)"
|
||||
title="Вернуть из архива"
|
||||
>
|
||||
<i data-lucide="archive-restore"></i>
|
||||
</button>
|
||||
<button
|
||||
class="btn-action btn-delete"
|
||||
@click.stop="$emit('delete', card.id)"
|
||||
title="Удалить навсегда"
|
||||
>
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
<span v-if="card.files && card.files.length" class="files-indicator">
|
||||
<i data-lucide="image-plus"></i>
|
||||
</span>
|
||||
<div v-if="card.assignee" class="assignee">
|
||||
<img
|
||||
v-if="isAvatarUrl(card.assignee)"
|
||||
:src="getFullUrl(card.assignee)"
|
||||
alt="avatar"
|
||||
class="assignee-img"
|
||||
/>
|
||||
<span v-else>{{ card.assignee }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mobile-title">{{ card.title }}</h3>
|
||||
|
||||
<p v-if="card.description" class="mobile-description">
|
||||
{{ card.description }}
|
||||
</p>
|
||||
|
||||
<div class="card-footer">
|
||||
<span class="date-create">
|
||||
Создано: {{ formatDateWithYear(card.dateCreate) }}
|
||||
</span>
|
||||
<span v-if="card.dateClosed" class="date-closed">
|
||||
Закрыто: {{ closedDateText }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUpdated } from 'vue'
|
||||
import { getFullUrl } from '../api'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
const props = defineProps({
|
||||
card: Object,
|
||||
@@ -127,9 +192,34 @@ const formatDateFull = (dateStr) => {
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0')
|
||||
return `${day} ${months[date.getMonth()]} ${year}, ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
const formatDateWithYear = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
const day = date.getDate()
|
||||
const months = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
|
||||
return `${day} ${months[date.getMonth()]} ${date.getFullYear()}`
|
||||
}
|
||||
|
||||
// Форматирование даты закрытия (относительный формат)
|
||||
const closedDateText = computed(() => {
|
||||
if (!props.card.dateClosed) return ''
|
||||
const closed = new Date(props.card.dateClosed)
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
closed.setHours(0, 0, 0, 0)
|
||||
const daysAgo = Math.round((today - closed) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (daysAgo === 0) return 'Сегодня'
|
||||
if (daysAgo === 1) return 'Вчера'
|
||||
if (daysAgo >= 2 && daysAgo <= 4) return `${daysAgo} дня назад`
|
||||
if (daysAgo >= 5 && daysAgo <= 14) return `${daysAgo} дней назад`
|
||||
return formatDateWithYear(props.card.dateClosed)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ========== DESKTOP STYLES ========== */
|
||||
.archive-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -307,4 +397,111 @@ const formatDateFull = (dateStr) => {
|
||||
background: var(--red, #ff4757);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ========== MOBILE STYLES ========== */
|
||||
.archive-card.mobile {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.archive-card.mobile.has-label-color {
|
||||
background: color-mix(in srgb, var(--label-bg) 15%, var(--bg-card));
|
||||
border-left: 3px solid var(--label-bg);
|
||||
}
|
||||
|
||||
.archive-card.mobile .card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.archive-card.mobile .tag-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.archive-card.mobile .tag {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.archive-card.mobile .header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.archive-card.mobile .btn-action {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.archive-card.mobile .btn-action i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.archive-card.mobile .files-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.archive-card.mobile .files-indicator i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.archive-card.mobile .assignee {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--blue);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.archive-card.mobile .mobile-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.archive-card.mobile .mobile-description {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.archive-card.mobile .card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.archive-card.mobile .date-create {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="board">
|
||||
<div class="columns">
|
||||
<div class="board" :class="{ mobile: isMobile }">
|
||||
<div class="columns" ref="columnsRef" @scroll="onColumnsScroll">
|
||||
<Column
|
||||
v-for="column in filteredColumns"
|
||||
:key="column.id"
|
||||
@@ -14,6 +14,21 @@
|
||||
@archive-task="archiveTask"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Мобильный индикатор снизу (над навигацией) -->
|
||||
<div v-if="isMobile" class="mobile-column-footer">
|
||||
<div class="current-column-title">{{ currentColumnTitle }}</div>
|
||||
<div class="column-indicators">
|
||||
<button
|
||||
v-for="(column, index) in filteredColumns"
|
||||
:key="column.id"
|
||||
class="indicator-dot"
|
||||
:class="{ active: currentColumnIndex === index }"
|
||||
:style="{ '--dot-color': column.color }"
|
||||
@click="scrollToColumn(index)"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -21,6 +36,35 @@
|
||||
import { ref, computed, onMounted, onUpdated, watch } from 'vue'
|
||||
import Column from './Column.vue'
|
||||
import { cardsApi } from '../api'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
// Мобильный свайп
|
||||
const columnsRef = ref(null)
|
||||
const currentColumnIndex = ref(0)
|
||||
|
||||
const onColumnsScroll = () => {
|
||||
if (!columnsRef.value || !isMobile.value) return
|
||||
const scrollLeft = columnsRef.value.scrollLeft
|
||||
const columnWidth = columnsRef.value.scrollWidth / filteredColumns.value.length
|
||||
currentColumnIndex.value = Math.round(scrollLeft / columnWidth)
|
||||
}
|
||||
|
||||
const scrollToColumn = (index) => {
|
||||
if (!columnsRef.value) return
|
||||
const columnWidth = columnsRef.value.scrollWidth / filteredColumns.value.length
|
||||
columnsRef.value.scrollTo({
|
||||
left: index * columnWidth,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
|
||||
// Название текущей колонки для мобильного индикатора
|
||||
const currentColumnTitle = computed(() => {
|
||||
const col = filteredColumns.value[currentColumnIndex.value]
|
||||
return col ? col.title : ''
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
activeDepartment: Number,
|
||||
@@ -277,4 +321,76 @@ defineExpose({ saveTask, deleteTask, archiveTask })
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* ========== MOBILE: колонки со свайпом ========== */
|
||||
.board.mobile {
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.board.mobile .columns {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-behavior: smooth;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
/* Отключаем вертикальный скролл на уровне этого элемента */
|
||||
overscroll-behavior: contain;
|
||||
touch-action: pan-x;
|
||||
}
|
||||
|
||||
.board.mobile .columns::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Мобильный футер с индикатором колонок */
|
||||
.mobile-column-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-body);
|
||||
flex-shrink: 0;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.current-column-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.column-indicators {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.indicator-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.indicator-dot.active {
|
||||
width: 28px;
|
||||
border-radius: 5px;
|
||||
background: var(--dot-color, var(--accent));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<button
|
||||
v-if="canArchive"
|
||||
class="btn-archive-card"
|
||||
:class="{ 'always-visible': isMobile }"
|
||||
@click.stop="handleArchive"
|
||||
title="В архив"
|
||||
>
|
||||
@@ -65,6 +66,9 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUpdated } from 'vue'
|
||||
import { getFullUrl } from '../api'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
const props = defineProps({
|
||||
card: Object,
|
||||
@@ -350,4 +354,9 @@ const handleArchive = () => {
|
||||
background: var(--orange);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* Mobile: всегда показываем кнопку архивирования */
|
||||
.btn-archive-card.always-visible {
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
@dragenter.prevent="handleDragEnter"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop"
|
||||
:class="{ 'drag-over': isDragOver }"
|
||||
:class="{ 'drag-over': isDragOver, mobile: isMobile }"
|
||||
ref="columnRef"
|
||||
>
|
||||
<div class="column-header">
|
||||
@@ -45,6 +45,9 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUpdated } from 'vue'
|
||||
import Card from './Card.vue'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
const props = defineProps({
|
||||
column: Object,
|
||||
@@ -142,6 +145,23 @@ const handleDrop = (e) => {
|
||||
max-height: calc(100vh - 140px);
|
||||
}
|
||||
|
||||
/* ========== MOBILE ========== */
|
||||
.column.mobile {
|
||||
width: 100vw;
|
||||
min-width: 100vw;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
padding: 0 16px;
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.column.mobile .cards {
|
||||
max-height: calc(100vh - 320px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.column.drag-over .cards {
|
||||
background: var(--accent-soft);
|
||||
border-radius: 12px;
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<template>
|
||||
<div class="datepicker" ref="datepickerRef">
|
||||
<div class="datepicker-trigger" @click="toggleCalendar">
|
||||
<div class="datepicker-trigger" :class="{ mobile: isMobile }" @click="toggleCalendar">
|
||||
<i data-lucide="calendar"></i>
|
||||
<span v-if="modelValue">{{ formatDisplayDate(modelValue) }}</span>
|
||||
<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">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Transition name="dropdown">
|
||||
<!-- Desktop: dropdown calendar -->
|
||||
<Transition v-if="!isMobile" name="dropdown">
|
||||
<div v-if="isOpen" class="calendar" :class="{ 'open-up': openUp }" ref="calendarRef">
|
||||
<div class="calendar-header">
|
||||
<button class="nav-btn" @click="prevMonth">
|
||||
@@ -40,14 +41,65 @@
|
||||
{{ day.day }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Mobile: fullscreen overlay -->
|
||||
<Teleport to="body">
|
||||
<Transition name="mobile-calendar">
|
||||
<div v-if="isMobile && isOpen" class="mobile-calendar-overlay">
|
||||
<div class="mobile-calendar-panel">
|
||||
<div class="mobile-calendar-body">
|
||||
<div class="calendar-nav">
|
||||
<button class="nav-btn" @click="prevMonth">
|
||||
<i data-lucide="chevron-left"></i>
|
||||
</button>
|
||||
<span class="current-month">{{ monthNames[currentMonth] }} {{ currentYear }}</span>
|
||||
<button class="nav-btn" @click="nextMonth">
|
||||
<i data-lucide="chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="calendar-weekdays">
|
||||
<span v-for="day in weekdays" :key="day">{{ day }}</span>
|
||||
</div>
|
||||
|
||||
<div class="calendar-days">
|
||||
<button
|
||||
v-for="day in calendarDays"
|
||||
:key="day.key"
|
||||
class="day"
|
||||
:class="{
|
||||
'other-month': !day.currentMonth,
|
||||
'today': day.isToday,
|
||||
'selected': day.isSelected
|
||||
}"
|
||||
@click="selectDate(day)"
|
||||
>
|
||||
{{ day.day }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mobile-calendar-footer">
|
||||
<button class="btn-today" @click="selectToday">Сегодня</button>
|
||||
<button v-if="modelValue" class="btn-clear" @click="clearDate">Очистить</button>
|
||||
<button class="btn-close-footer" @click="closeCalendar">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: String
|
||||
@@ -68,6 +120,19 @@ const monthNames = [
|
||||
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
||||
]
|
||||
|
||||
const monthNamesShort = [
|
||||
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
||||
]
|
||||
|
||||
const formatCompactDate = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
const day = date.getDate()
|
||||
const monthShort = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
|
||||
return `${day} ${monthShort[date.getMonth()]} ${date.getFullYear()}`
|
||||
}
|
||||
|
||||
const formatDisplayDate = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
@@ -77,6 +142,10 @@ const formatDisplayDate = (dateStr) => {
|
||||
return `${day} ${month} ${year}`
|
||||
}
|
||||
|
||||
const closeCalendar = () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const calendarDays = computed(() => {
|
||||
const days = []
|
||||
const firstDay = new Date(currentYear.value, currentMonth.value, 1)
|
||||
@@ -434,4 +503,205 @@ watch(isOpen, () => {
|
||||
.calendar.open-up.dropdown-leave-to {
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
/* ========== MOBILE: Компактный триггер ========== */
|
||||
.datepicker-trigger.mobile {
|
||||
padding: 0 12px;
|
||||
height: 48px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.datepicker-trigger.mobile i:first-child {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.date-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ========== MOBILE: Fullscreen Calendar ========== */
|
||||
.mobile-calendar-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #18181b;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.mobile-calendar-panel {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #18181b;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mobile-calendar-body {
|
||||
flex: 1;
|
||||
padding: 20px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.calendar-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.calendar-nav .current-month {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.calendar-nav .nav-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.calendar-nav .nav-btn i {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.mobile-calendar-body .calendar-weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mobile-calendar-body .calendar-weekdays span {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.mobile-calendar-body .calendar-days {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mobile-calendar-body .day {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.mobile-calendar-body .day:active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.mobile-calendar-body .day.other-month {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.mobile-calendar-body .day.today {
|
||||
border: 2px solid var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mobile-calendar-body .day.selected {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-calendar-footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
padding-bottom: calc(16px + var(--safe-area-bottom, 0px));
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.btn-today {
|
||||
flex: 1;
|
||||
padding: 14px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: #000;
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-today:active {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.btn-clear {
|
||||
padding: 14px 20px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-clear:active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.btn-close-footer {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-close-footer i {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.btn-close-footer:active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Mobile calendar transition */
|
||||
.mobile-calendar-enter-active,
|
||||
.mobile-calendar-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mobile-calendar-enter-from,
|
||||
.mobile-calendar-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
169
front_vue/src/components/DepartmentSelector.vue
Normal file
169
front_vue/src/components/DepartmentSelector.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div class="department-select" :class="{ mobile: isMobile }" @click.stop>
|
||||
<button class="department-btn" @click="dropdownOpen = !dropdownOpen">
|
||||
<i data-lucide="filter" class="filter-icon"></i>
|
||||
{{ currentLabel }}
|
||||
<i data-lucide="chevron-down" class="chevron" :class="{ open: dropdownOpen }"></i>
|
||||
</button>
|
||||
<div class="department-dropdown" v-if="dropdownOpen">
|
||||
<button
|
||||
class="department-option"
|
||||
:class="{ active: modelValue === null }"
|
||||
@click="handleSelect(null)"
|
||||
>
|
||||
Все отделы
|
||||
</button>
|
||||
<button
|
||||
v-for="dept in departments"
|
||||
:key="dept.id"
|
||||
class="department-option"
|
||||
:class="{ active: modelValue === dept.id }"
|
||||
@click="handleSelect(dept.id)"
|
||||
>
|
||||
{{ dept.name_departments }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
departments: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
const dropdownOpen = ref(false)
|
||||
|
||||
const currentLabel = computed(() => {
|
||||
if (props.modelValue === null) return 'Все отделы'
|
||||
const dept = props.departments.find(d => d.id === props.modelValue)
|
||||
return dept?.name_departments || 'Все отделы'
|
||||
})
|
||||
|
||||
const handleSelect = (id) => {
|
||||
dropdownOpen.value = false
|
||||
emit('update:modelValue', id)
|
||||
}
|
||||
|
||||
// Закрытие при клике вне
|
||||
const closeDropdown = (e) => {
|
||||
if (!e.target.closest('.department-select')) {
|
||||
dropdownOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', closeDropdown)
|
||||
if (window.lucide) window.lucide.createIcons()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeDropdown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.department-select {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.department-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.department-btn:hover {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.department-btn .filter-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.department-btn .chevron {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
opacity: 0.5;
|
||||
transition: transform 0.2s;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.department-btn .chevron.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.department-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
min-width: 180px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 6px;
|
||||
z-index: 200;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.department-option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.department-option:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.department-option.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ========== MOBILE ========== */
|
||||
.department-select.mobile .department-dropdown {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: 80px;
|
||||
min-width: auto;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,20 +1,33 @@
|
||||
<template>
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<div class="title-row">
|
||||
<h1 class="page-title">{{ title }}</h1>
|
||||
<!-- Слот для фильтров (на одной строке с заголовком) -->
|
||||
<slot name="filters"></slot>
|
||||
<header class="header" :class="{ mobile: isMobile }">
|
||||
<!-- Десктоп версия -->
|
||||
<template v-if="!isMobile">
|
||||
<div class="header-left">
|
||||
<div class="title-row">
|
||||
<h1 class="page-title">{{ title }}</h1>
|
||||
<slot name="filters"></slot>
|
||||
</div>
|
||||
<p v-if="subtitle" class="page-subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<p v-if="subtitle" class="page-subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- Слот для статистики и прочего -->
|
||||
<slot name="stats"></slot>
|
||||
<button class="logout-btn" @click="logout" title="Выйти">
|
||||
<i data-lucide="log-out"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<slot name="stats"></slot>
|
||||
<button class="logout-btn" @click="logout" title="Выйти">
|
||||
<i data-lucide="log-out"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Мобильная версия -->
|
||||
<template v-else>
|
||||
<!-- Компактная строка: заголовок слева + иконки-кнопки -->
|
||||
<div class="mobile-header-row">
|
||||
<h1 v-if="!$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>
|
||||
</div>
|
||||
</template>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -22,6 +35,9 @@
|
||||
import { onMounted, onUpdated } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { authApi } from '../api'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
@@ -66,12 +82,15 @@ onUpdated(refreshIcons)
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@@ -92,6 +111,7 @@ onUpdated(refreshIcons)
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
@@ -118,4 +138,53 @@ onUpdated(refreshIcons)
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* ========== MOBILE ========== */
|
||||
.header.mobile {
|
||||
padding: 10px 16px;
|
||||
flex-direction: row;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mobile-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="project-select" v-if="store.currentProject" @click.stop>
|
||||
<div class="project-select" :class="{ mobile: isMobile }" v-if="store.currentProject" @click.stop>
|
||||
<button class="project-btn" @click="dropdownOpen = !dropdownOpen">
|
||||
<i data-lucide="folder" class="folder-icon"></i>
|
||||
{{ store.currentProject?.name || 'Выберите проект' }}
|
||||
@@ -22,8 +22,10 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
|
||||
const store = useProjectsStore()
|
||||
const { isMobile } = useMobile()
|
||||
const dropdownOpen = ref(false)
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
@@ -134,4 +136,16 @@ onUnmounted(() => {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ========== MOBILE ========== */
|
||||
.project-select.mobile .project-dropdown {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: 80px;
|
||||
min-width: auto;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<aside class="sidebar" :class="{ mobile: isMobile }">
|
||||
<!-- Логотип -->
|
||||
<div class="sidebar-logo">
|
||||
<i data-lucide="layout-grid"></i>
|
||||
@@ -22,6 +22,9 @@
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUpdated } from 'vue'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
@@ -98,4 +101,42 @@ onUpdated(refreshIcons)
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
/* ========== MOBILE: Нижняя навигация ========== */
|
||||
.sidebar.mobile {
|
||||
top: auto;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: calc(64px + var(--safe-area-bottom, 0px));
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 8px 0;
|
||||
padding-bottom: calc(8px + var(--safe-area-bottom, 0px));
|
||||
border-right: none;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: var(--bg-body);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.sidebar.mobile .sidebar-logo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.mobile .sidebar-nav {
|
||||
flex-direction: row;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.sidebar.mobile .nav-item {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.sidebar.mobile .nav-item i {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,41 +1,82 @@
|
||||
<template>
|
||||
<div class="comment-form">
|
||||
<!-- Индикатор "ответ на" -->
|
||||
<div v-if="replyingTo" class="reply-indicator">
|
||||
<i data-lucide="corner-down-right"></i>
|
||||
<span>Ответ для <strong>{{ replyingTo.author_name }}</strong></span>
|
||||
<button class="reply-cancel" @click="$emit('cancel-reply')" title="Отменить">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FormField :label="replyingTo ? 'Ваш ответ' : 'Новый комментарий'">
|
||||
<template #actions>
|
||||
<div class="format-buttons">
|
||||
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('bold')" title="Жирный">
|
||||
<i data-lucide="bold"></i>
|
||||
</button>
|
||||
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('italic')" title="Курсив">
|
||||
<i data-lucide="italic"></i>
|
||||
</button>
|
||||
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('underline')" title="Подчёркивание">
|
||||
<i data-lucide="underline"></i>
|
||||
</button>
|
||||
<button type="button" class="format-btn" @mousedown.prevent="triggerFileInput" title="Прикрепить файл">
|
||||
<i data-lucide="paperclip"></i>
|
||||
<!-- Desktop: Inline форма -->
|
||||
<template v-if="!isMobile">
|
||||
<!-- Индикатор "ответ на" -->
|
||||
<div v-if="replyingTo" class="reply-indicator">
|
||||
<i data-lucide="corner-down-right"></i>
|
||||
<span>Ответ для <strong>{{ replyingTo.author_name }}</strong></span>
|
||||
<button class="reply-cancel" @click="$emit('cancel-reply')" title="Отменить">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FormField :label="replyingTo ? 'Ваш ответ' : 'Новый комментарий'">
|
||||
<template #actions>
|
||||
<div class="format-buttons">
|
||||
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('bold')" title="Жирный">
|
||||
<i data-lucide="bold"></i>
|
||||
</button>
|
||||
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('italic')" title="Курсив">
|
||||
<i data-lucide="italic"></i>
|
||||
</button>
|
||||
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('underline')" title="Подчёркивание">
|
||||
<i data-lucide="underline"></i>
|
||||
</button>
|
||||
<button type="button" class="format-btn" @mousedown.prevent="triggerFileInput" title="Прикрепить файл">
|
||||
<i data-lucide="paperclip"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<RichTextEditor
|
||||
:modelValue="modelValue"
|
||||
@update:modelValue="$emit('update:modelValue', $event)"
|
||||
:placeholder="replyingTo ? 'Напишите ответ...' : 'Напишите комментарий...'"
|
||||
:show-toolbar="false"
|
||||
ref="editorRef"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<!-- Превью прикреплённых файлов -->
|
||||
<div v-if="files.length > 0" class="attached-files">
|
||||
<div
|
||||
v-for="(file, index) in files"
|
||||
:key="file.name + '-' + index"
|
||||
class="attached-file"
|
||||
>
|
||||
<div class="attached-file-icon">
|
||||
<i v-if="isArchive(file)" data-lucide="archive"></i>
|
||||
<i v-else data-lucide="image"></i>
|
||||
</div>
|
||||
<span class="attached-file-name" :title="file.name">{{ file.name }}</span>
|
||||
<button class="attached-file-remove" @click="removeFile(index)" title="Удалить">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<RichTextEditor
|
||||
:modelValue="modelValue"
|
||||
@update:modelValue="$emit('update:modelValue', $event)"
|
||||
:placeholder="replyingTo ? 'Напишите ответ...' : 'Напишите комментарий...'"
|
||||
:show-toolbar="false"
|
||||
ref="editorRef"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn-send-comment"
|
||||
@click="$emit('send')"
|
||||
:disabled="!canSend || isSending"
|
||||
>
|
||||
<span v-if="isSending" class="btn-loader"></span>
|
||||
<template v-else>
|
||||
<i data-lucide="send"></i>
|
||||
Отправить
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Скрытый input для файлов -->
|
||||
<!-- Mobile: Кнопка открытия формы -->
|
||||
<template v-else>
|
||||
<button class="btn-open-form" @click="openMobileForm">
|
||||
<i data-lucide="message-square-plus"></i>
|
||||
{{ replyingTo ? 'Написать ответ' : 'Написать комментарий' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Скрытый input для файлов (общий) -->
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInputRef"
|
||||
@@ -45,42 +86,94 @@
|
||||
style="display: none"
|
||||
>
|
||||
|
||||
<!-- Превью прикреплённых файлов -->
|
||||
<div v-if="files.length > 0" class="attached-files">
|
||||
<div
|
||||
v-for="(file, index) in files"
|
||||
:key="file.name + '-' + index"
|
||||
class="attached-file"
|
||||
>
|
||||
<div class="attached-file-icon">
|
||||
<i v-if="isArchive(file)" data-lucide="archive"></i>
|
||||
<i v-else data-lucide="image"></i>
|
||||
<!-- Mobile: Fullscreen форма -->
|
||||
<Teleport to="body">
|
||||
<Transition name="mobile-form">
|
||||
<div v-if="isMobile && mobileFormOpen" class="mobile-form-overlay">
|
||||
<div class="mobile-form-panel">
|
||||
<div class="mobile-form-header">
|
||||
<button class="btn-close" @click="closeMobileForm">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
<h3 class="panel-title">{{ replyingTo ? 'Ответ' : 'Новый комментарий' }}</h3>
|
||||
<div class="header-spacer"></div>
|
||||
</div>
|
||||
|
||||
<!-- Индикатор "ответ на" -->
|
||||
<div v-if="replyingTo" class="mobile-reply-indicator">
|
||||
<i data-lucide="corner-down-right"></i>
|
||||
<span>Ответ для <strong>{{ replyingTo.author_name }}</strong></span>
|
||||
</div>
|
||||
|
||||
<div class="mobile-form-body">
|
||||
<RichTextEditor
|
||||
:modelValue="modelValue"
|
||||
@update:modelValue="$emit('update:modelValue', $event)"
|
||||
:placeholder="replyingTo ? 'Напишите ответ...' : 'Напишите комментарий...'"
|
||||
:show-toolbar="false"
|
||||
ref="mobileEditorRef"
|
||||
class="mobile-editor"
|
||||
/>
|
||||
|
||||
<!-- Превью прикреплённых файлов -->
|
||||
<div v-if="files.length > 0" class="attached-files mobile">
|
||||
<div
|
||||
v-for="(file, index) in files"
|
||||
:key="file.name + '-' + index"
|
||||
class="attached-file"
|
||||
>
|
||||
<div class="attached-file-icon">
|
||||
<i v-if="isArchive(file)" data-lucide="archive"></i>
|
||||
<i v-else data-lucide="image"></i>
|
||||
</div>
|
||||
<span class="attached-file-name" :title="file.name">{{ file.name }}</span>
|
||||
<button class="attached-file-remove" @click="removeFile(index)" title="Удалить">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mobile-form-footer">
|
||||
<div class="mobile-format-buttons">
|
||||
<button class="btn-format" @click="applyMobileFormat('bold')" title="Жирный">
|
||||
<i data-lucide="bold"></i>
|
||||
</button>
|
||||
<button class="btn-format" @click="applyMobileFormat('italic')" title="Курсив">
|
||||
<i data-lucide="italic"></i>
|
||||
</button>
|
||||
<button class="btn-format" @click="applyMobileFormat('underline')" title="Подчёркивание">
|
||||
<i data-lucide="underline"></i>
|
||||
</button>
|
||||
<button class="btn-format" @click="triggerFileInput" title="Прикрепить файл">
|
||||
<i data-lucide="paperclip"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="btn-send"
|
||||
@click="handleMobileSend"
|
||||
:disabled="!canSend || isSending"
|
||||
>
|
||||
<span v-if="isSending" class="btn-loader"></span>
|
||||
<template v-else>
|
||||
<i data-lucide="send"></i>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="attached-file-name" :title="file.name">{{ file.name }}</span>
|
||||
<button class="attached-file-remove" @click="removeFile(index)" title="Удалить">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn-send-comment"
|
||||
@click="$emit('send')"
|
||||
:disabled="!canSend || isSending"
|
||||
>
|
||||
<span v-if="isSending" class="btn-loader"></span>
|
||||
<template v-else>
|
||||
<i data-lucide="send"></i>
|
||||
Отправить
|
||||
</template>
|
||||
</button>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUpdated } from 'vue'
|
||||
import { ref, computed, onMounted, onUpdated, watch, nextTick } from 'vue'
|
||||
import FormField from '../ui/FormField.vue'
|
||||
import RichTextEditor from '../ui/RichTextEditor.vue'
|
||||
import { useMobile } from '../../composables/useMobile'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -97,11 +190,47 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue', 'send', 'cancel-reply'])
|
||||
const emit = defineEmits(['update:modelValue', 'send', 'cancel-reply'])
|
||||
|
||||
const editorRef = ref(null)
|
||||
const mobileEditorRef = ref(null)
|
||||
const fileInputRef = ref(null)
|
||||
const files = ref([])
|
||||
const mobileFormOpen = ref(false)
|
||||
|
||||
// Открытие мобильной формы
|
||||
const openMobileForm = async () => {
|
||||
mobileFormOpen.value = true
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
}
|
||||
|
||||
// Закрытие мобильной формы
|
||||
const closeMobileForm = () => {
|
||||
mobileFormOpen.value = false
|
||||
emit('cancel-reply')
|
||||
}
|
||||
|
||||
// Отправка из мобильной формы
|
||||
const handleMobileSend = () => {
|
||||
emit('send')
|
||||
// Форма закроется после успешной отправки через watch
|
||||
}
|
||||
|
||||
// Закрытие формы когда isSending становится false после отправки
|
||||
watch(() => props.isSending, (newVal, oldVal) => {
|
||||
if (oldVal === true && newVal === false && mobileFormOpen.value) {
|
||||
// Отправка завершена, закрываем форму
|
||||
closeMobileForm()
|
||||
}
|
||||
})
|
||||
|
||||
// Открытие формы при выборе ответа
|
||||
watch(() => props.replyingTo, (newVal) => {
|
||||
if (newVal && isMobile.value) {
|
||||
openMobileForm()
|
||||
}
|
||||
})
|
||||
|
||||
const allowedExtensions = ['png', 'jpg', 'jpeg', 'zip', 'rar']
|
||||
const archiveExtensions = ['zip', 'rar']
|
||||
@@ -197,6 +326,10 @@ const applyFormat = (command) => {
|
||||
editorRef.value?.applyFormat(command)
|
||||
}
|
||||
|
||||
const applyMobileFormat = (command) => {
|
||||
mobileEditorRef.value?.applyFormat(command)
|
||||
}
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
@@ -221,6 +354,7 @@ defineExpose({
|
||||
.comment-form {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding-top: 16px;
|
||||
padding-bottom: calc(var(--safe-area-bottom, 0px));
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
@@ -420,4 +554,215 @@ defineExpose({
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* ========== MOBILE: Кнопка открытия формы ========== */
|
||||
.btn-open-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: #000;
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-open-form i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.btn-open-form:active {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
/* ========== MOBILE: Fullscreen Form ========== */
|
||||
.mobile-form-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #18181b;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.mobile-form-panel {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mobile-form-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-form-header .panel-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mobile-form-header .btn-close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-form-header .btn-close i {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.mobile-form-header .header-spacer {
|
||||
width: 36px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-reply-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(0, 212, 170, 0.08);
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.mobile-reply-indicator i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mobile-reply-indicator strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mobile-form-body {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mobile-editor {
|
||||
flex: 1;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.mobile-editor :deep(.editor-content) {
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.attached-files.mobile {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.mobile-form-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
padding-bottom: calc(12px + var(--safe-area-bottom, 0px));
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-format-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-format {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-format i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn-format:active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-send {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: #000;
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-send i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.btn-send:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-send:not(:disabled):active {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
/* Mobile form transition */
|
||||
.mobile-form-enter-active,
|
||||
.mobile-form-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mobile-form-enter-from,
|
||||
.mobile-form-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -402,6 +402,7 @@ defineExpose({
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0; /* Важно для flex overflow */
|
||||
}
|
||||
|
||||
.comments-list {
|
||||
@@ -413,10 +414,19 @@ defineExpose({
|
||||
padding: 2px; /* Для outline при редактировании */
|
||||
padding-right: 6px;
|
||||
margin-bottom: 16px;
|
||||
min-height: 200px;
|
||||
min-height: 100px;
|
||||
max-height: calc(100vh - 400px);
|
||||
}
|
||||
|
||||
/* Mobile: убираем max-height, используем flex */
|
||||
@media (max-width: 768px) {
|
||||
.comments-list {
|
||||
max-height: none;
|
||||
min-height: 50px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.comments-loading,
|
||||
.comments-empty {
|
||||
display: flex;
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field-row" :class="{ mobile: isMobile }">
|
||||
<FormField label="Срок выполнения">
|
||||
<DatePicker v-model="form.dueDate" />
|
||||
</FormField>
|
||||
@@ -106,6 +106,9 @@ import FileUploader from '../ui/FileUploader.vue'
|
||||
import DatePicker from '../DatePicker.vue'
|
||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||
import { getFullUrl } from '../../api'
|
||||
import { useMobile } from '../../composables/useMobile'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
const props = defineProps({
|
||||
card: {
|
||||
@@ -370,6 +373,11 @@ defineExpose({
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.field-row.mobile {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.format-buttons {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
@close="tryClose"
|
||||
>
|
||||
<template #header>
|
||||
<h2>{{ panelTitle }}</h2>
|
||||
<span v-if="!isNew && card?.dateCreate" class="header-date">
|
||||
Создано: {{ formatDate(card.dateCreate) }}
|
||||
</span>
|
||||
<div class="header-title-block">
|
||||
<h2>{{ panelTitle }}</h2>
|
||||
<span v-if="!isNew && card?.dateCreate" class="header-date">
|
||||
Создано: {{ formatDate(card.dateCreate) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Вкладки (только для существующих задач) -->
|
||||
<TabsPanel
|
||||
@@ -43,7 +45,8 @@
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<!-- Footer: скрываем на вкладке комментариев -->
|
||||
<template #footer v-if="activeTab !== 'comments'">
|
||||
<div class="footer-left">
|
||||
<IconButton
|
||||
v-if="!isNew"
|
||||
@@ -146,6 +149,9 @@ import ConfirmDialog from '../ConfirmDialog.vue'
|
||||
import TaskEditTab from './TaskEditTab.vue'
|
||||
import TaskCommentsTab from './TaskCommentsTab.vue'
|
||||
import { taskImageApi, commentImageApi, getFullUrl } from '../../api'
|
||||
import { useMobile } from '../../composables/useMobile'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
@@ -402,7 +408,7 @@ watch(() => props.show, async (newVal) => {
|
||||
|
||||
await nextTick()
|
||||
editTabRef.value?.saveInitialForm()
|
||||
editTabRef.value?.focusTitle()
|
||||
// Не фокусируем поле автоматически, чтобы не открывалась клавиатура
|
||||
refreshIcons()
|
||||
}
|
||||
})
|
||||
@@ -412,13 +418,22 @@ onUpdated(refreshIcons)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-title-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.header-title-block h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-date {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.header-tabs {
|
||||
margin-left: auto;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
|
||||
337
front_vue/src/components/ui/MobileSelect.vue
Normal file
337
front_vue/src/components/ui/MobileSelect.vue
Normal file
@@ -0,0 +1,337 @@
|
||||
<template>
|
||||
<!-- Кнопка-триггер -->
|
||||
<button class="mobile-select-btn" :class="[variant, { compact }]" @click="open = true">
|
||||
<i v-if="icon" :data-lucide="icon" class="btn-icon"></i>
|
||||
<span v-if="!compact" class="btn-label">{{ displayValue }}</span>
|
||||
<i data-lucide="chevron-down" class="btn-arrow"></i>
|
||||
</button>
|
||||
|
||||
<!-- Fullscreen панель выбора -->
|
||||
<Teleport to="body">
|
||||
<Transition name="slide-up">
|
||||
<div v-if="open" class="mobile-select-overlay" @click.self="open = false">
|
||||
<div class="mobile-select-panel">
|
||||
<!-- Header панели -->
|
||||
<div class="panel-header">
|
||||
<button class="panel-back" @click="open = false">
|
||||
<i data-lucide="arrow-left"></i>
|
||||
</button>
|
||||
<h3 class="panel-title">{{ title }}</h3>
|
||||
</div>
|
||||
|
||||
<!-- Список опций -->
|
||||
<div class="panel-options">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.id"
|
||||
class="option-item"
|
||||
:class="{ active: modelValue === option.id }"
|
||||
@click="selectOption(option.id)"
|
||||
>
|
||||
<span class="option-label">{{ option.label }}</span>
|
||||
<i v-if="modelValue === option.id" data-lucide="check" class="option-check"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUpdated } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [Number, String, null],
|
||||
default: null
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
required: true
|
||||
// [{ id: 1, label: 'Option 1' }, ...]
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Выберите'
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Выберите...'
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default' // 'default' | 'accent'
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const open = ref(false)
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (props.modelValue === null || props.modelValue === undefined) {
|
||||
return props.placeholder
|
||||
}
|
||||
const option = props.options.find(o => o.id === props.modelValue)
|
||||
return option?.label || props.placeholder
|
||||
})
|
||||
|
||||
const selectOption = (id) => {
|
||||
emit('update:modelValue', id)
|
||||
open.value = false
|
||||
}
|
||||
|
||||
// Refresh icons
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
setTimeout(() => window.lucide.createIcons(), 10)
|
||||
}
|
||||
}
|
||||
|
||||
watch(open, (val) => {
|
||||
if (val) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
refreshIcons()
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(refreshIcons)
|
||||
onUpdated(refreshIcons)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Кнопка триггер */
|
||||
.mobile-select-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
min-height: 40px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.mobile-select-btn:hover {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-select-btn:active {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Акцентная версия (для проекта) */
|
||||
.mobile-select-btn.accent {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.mobile-select-btn.accent:hover {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-select-btn.accent:active {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.mobile-select-btn.accent .btn-icon,
|
||||
.mobile-select-btn.accent .btn-arrow {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Компактный режим (только иконка) */
|
||||
.mobile-select-btn.compact {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.mobile-select-btn.compact .btn-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mobile-select-btn.compact .btn-arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.btn-label {
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-arrow {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
opacity: 0.4;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* Overlay */
|
||||
.mobile-select-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Панель */
|
||||
.mobile-select-panel {
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
background: var(--bg-body);
|
||||
border-radius: 0 0 20px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.panel-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.panel-back:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.panel-back i {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Опции */
|
||||
.panel-options {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.option-item:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
.option-item:active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.option-item.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.option-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.option-check {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Анимация */
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-active .mobile-select-panel,
|
||||
.slide-up-leave-active .mobile-select-panel {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-enter-from .mobile-select-panel,
|
||||
.slide-up-leave-to .mobile-select-panel {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="select-dropdown" ref="dropdownRef">
|
||||
<!-- Триггер -->
|
||||
<button class="dropdown-trigger" @click="toggleDropdown" :disabled="disabled">
|
||||
<button class="dropdown-trigger" :class="{ mobile: isMobile }" @click="toggleDropdown" :disabled="disabled">
|
||||
<slot name="selected" :selected="selectedOption">
|
||||
<img
|
||||
v-if="selectedOption?.avatar"
|
||||
@@ -15,8 +15,8 @@
|
||||
<i data-lucide="chevron-down" class="dropdown-arrow"></i>
|
||||
</button>
|
||||
|
||||
<!-- Выпадающий список -->
|
||||
<Transition name="dropdown">
|
||||
<!-- Desktop: выпадающий список -->
|
||||
<Transition v-if="!isMobile" name="dropdown">
|
||||
<div v-if="isOpen" class="dropdown-menu" :class="{ 'open-up': openUp }">
|
||||
<input
|
||||
v-if="searchable"
|
||||
@@ -68,11 +68,86 @@
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Mobile: fullscreen панель -->
|
||||
<Teleport to="body">
|
||||
<Transition name="mobile-select">
|
||||
<div v-if="isMobile && isOpen" class="mobile-select-overlay" @click.self="closeDropdown">
|
||||
<div
|
||||
class="mobile-select-panel"
|
||||
:class="{ dragging: isDragging }"
|
||||
:style="panelDragStyle"
|
||||
@click.stop
|
||||
ref="mobilePanelRef"
|
||||
>
|
||||
<div
|
||||
class="mobile-select-handle"
|
||||
@touchstart="onDragStart"
|
||||
@touchmove="onDragMove"
|
||||
@touchend="onDragEnd"
|
||||
></div>
|
||||
|
||||
<div v-if="searchable" class="mobile-select-search" @click.stop>
|
||||
<i data-lucide="search"></i>
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
:placeholder="searchPlaceholder"
|
||||
ref="mobileSearchRef"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="mobile-select-list">
|
||||
<!-- Опция "не выбрано" -->
|
||||
<button
|
||||
v-if="allowEmpty"
|
||||
class="mobile-select-item"
|
||||
:class="{ active: !modelValue }"
|
||||
@click="selectOption(null)"
|
||||
>
|
||||
<span class="no-selection-icon">—</span>
|
||||
<span class="item-label">{{ emptyLabel }}</span>
|
||||
<i v-if="!modelValue" data-lucide="check" class="check-icon"></i>
|
||||
</button>
|
||||
|
||||
<!-- Опции -->
|
||||
<button
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.value"
|
||||
class="mobile-select-item"
|
||||
:class="{ active: modelValue === option.value }"
|
||||
@click="selectOption(option.value)"
|
||||
>
|
||||
<img
|
||||
v-if="option.avatar"
|
||||
:src="option.avatar"
|
||||
:alt="option.label"
|
||||
class="option-avatar"
|
||||
>
|
||||
<div class="option-content">
|
||||
<span class="option-label">{{ option.label }}</span>
|
||||
<span v-if="option.subtitle" class="option-subtitle">{{ option.subtitle }}</span>
|
||||
</div>
|
||||
<i v-if="modelValue === option.value" data-lucide="check" class="check-icon"></i>
|
||||
</button>
|
||||
|
||||
<!-- Пустой результат поиска -->
|
||||
<div v-if="searchable && filteredOptions.length === 0" class="mobile-select-empty">
|
||||
Ничего не найдено
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted, onUpdated, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, onUpdated, nextTick } from 'vue'
|
||||
import { useMobile } from '../../composables/useMobile'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -114,11 +189,64 @@ const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const dropdownRef = ref(null)
|
||||
const searchInputRef = ref(null)
|
||||
const mobileSearchRef = ref(null)
|
||||
const mobilePanelRef = ref(null)
|
||||
const listRef = ref(null)
|
||||
const isOpen = ref(false)
|
||||
const openUp = ref(false)
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Drag to dismiss
|
||||
const isDragging = ref(false)
|
||||
const dragStartY = ref(0)
|
||||
const dragCurrentY = ref(0)
|
||||
const panelHeight = ref(0)
|
||||
|
||||
const panelDragStyle = computed(() => {
|
||||
if (!isDragging.value) return {}
|
||||
const deltaY = dragCurrentY.value - dragStartY.value
|
||||
if (deltaY < 0) return {} // Не двигать вверх
|
||||
return {
|
||||
transform: `translateY(${deltaY}px)`,
|
||||
transition: 'none'
|
||||
}
|
||||
})
|
||||
|
||||
const onDragStart = (e) => {
|
||||
isDragging.value = true
|
||||
dragStartY.value = e.touches[0].clientY
|
||||
dragCurrentY.value = e.touches[0].clientY
|
||||
if (mobilePanelRef.value) {
|
||||
panelHeight.value = mobilePanelRef.value.offsetHeight
|
||||
}
|
||||
}
|
||||
|
||||
const onDragMove = (e) => {
|
||||
if (!isDragging.value) return
|
||||
dragCurrentY.value = e.touches[0].clientY
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
if (!isDragging.value) return
|
||||
|
||||
const deltaY = dragCurrentY.value - dragStartY.value
|
||||
const threshold = panelHeight.value * 0.3 // 30% от высоты панели
|
||||
|
||||
if (deltaY > threshold) {
|
||||
// Закрыть панель
|
||||
closeDropdown()
|
||||
}
|
||||
|
||||
isDragging.value = false
|
||||
dragStartY.value = 0
|
||||
dragCurrentY.value = 0
|
||||
}
|
||||
|
||||
const closeDropdown = () => {
|
||||
isOpen.value = false
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
// Выбранная опция
|
||||
const selectedOption = computed(() => {
|
||||
if (!props.modelValue) return null
|
||||
@@ -165,8 +293,9 @@ const selectOption = (value) => {
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
// Закрытие при клике вне
|
||||
// Закрытие при клике вне (только для десктопа)
|
||||
const handleClickOutside = (e) => {
|
||||
if (isMobile.value) return // На мобильных закрытие только по кнопке
|
||||
if (dropdownRef.value && !dropdownRef.value.contains(e.target)) {
|
||||
isOpen.value = false
|
||||
searchQuery.value = ''
|
||||
@@ -416,4 +545,194 @@ onUpdated(refreshIcons)
|
||||
.dropdown-menu.open-up.dropdown-leave-to {
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
/* ========== MOBILE: Fullscreen Select ========== */
|
||||
.dropdown-trigger.mobile {
|
||||
height: 48px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mobile-select-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.mobile-select-panel {
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
background: #18181b;
|
||||
border-top-left-radius: 16px;
|
||||
border-top-right-radius: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mobile-select-handle {
|
||||
width: 100%;
|
||||
padding: 12px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.mobile-select-handle::after {
|
||||
content: '';
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.mobile-select-panel.dragging .mobile-select-handle {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.mobile-select-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-select-search i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-select-search input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.mobile-select-search input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mobile-select-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.mobile-select-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.mobile-select-item:active {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.mobile-select-item.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mobile-select-item .option-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.mobile-select-item .option-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-select-item .option-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mobile-select-item .option-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mobile-select-item.active .option-subtitle {
|
||||
color: var(--accent);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.mobile-select-item .no-selection-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-select-item .item-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mobile-select-item .check-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-select-empty {
|
||||
padding: 40px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* Mobile select transition */
|
||||
.mobile-select-enter-active,
|
||||
.mobile-select-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.mobile-select-enter-active .mobile-select-panel,
|
||||
.mobile-select-leave-active .mobile-select-panel {
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
.mobile-select-enter-from,
|
||||
.mobile-select-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mobile-select-enter-from .mobile-select-panel,
|
||||
.mobile-select-leave-to .mobile-select-panel {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
>
|
||||
<div
|
||||
class="panel"
|
||||
:class="{ mobile: isMobile }"
|
||||
:style="{ width: panelWidth + 'px' }"
|
||||
ref="panelRef"
|
||||
@mousedown="overlayMouseDownTarget = false"
|
||||
@@ -41,6 +42,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, onUpdated, watch } from 'vue'
|
||||
import { useMobile } from '../../composables/useMobile'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@@ -142,10 +146,22 @@ const refreshIcons = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Блокировка скролла body при открытии панели
|
||||
const lockBodyScroll = () => {
|
||||
document.body.classList.add('panel-open')
|
||||
}
|
||||
|
||||
const unlockBodyScroll = () => {
|
||||
document.body.classList.remove('panel-open')
|
||||
}
|
||||
|
||||
// Восстановление ширины при открытии (из localStorage или дефолтная)
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal) {
|
||||
panelWidth.value = getSavedWidth()
|
||||
lockBodyScroll()
|
||||
} else {
|
||||
unlockBodyScroll()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -155,6 +171,7 @@ onUpdated(refreshIcons)
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', onResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
unlockBodyScroll()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -164,6 +181,9 @@ onUnmounted(() => {
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
/* Блокируем горизонтальные свайпы на мобильных */
|
||||
touch-action: pan-y pinch-zoom;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.panel {
|
||||
@@ -172,6 +192,7 @@ onUnmounted(() => {
|
||||
right: 0;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh; /* Динамическая высота для iOS */
|
||||
background: #18181b;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -205,9 +226,10 @@ onUnmounted(() => {
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-content :deep(h2) {
|
||||
@@ -216,11 +238,21 @@ onUnmounted(() => {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-content :deep(.header-title-block) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.header-content :deep(.header-date) {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.header-content :deep(.header-tabs) {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -299,4 +331,38 @@ onUnmounted(() => {
|
||||
.panel-leave-to .panel {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
/* ========== MOBILE: Fullscreen ========== */
|
||||
.panel.mobile {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
border-radius: 0;
|
||||
/* Блокируем горизонтальные свайпы, чтобы не срабатывала навигация браузера */
|
||||
touch-action: pan-y pinch-zoom;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.panel.mobile .resize-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panel.mobile .panel-header {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.panel.mobile .panel-body {
|
||||
padding: 16px;
|
||||
padding-bottom: calc(16px + var(--safe-area-bottom, 0px));
|
||||
min-height: 0; /* Важно для flex overflow */
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel.mobile .panel-footer {
|
||||
padding: 16px;
|
||||
padding-bottom: calc(16px + var(--safe-area-bottom, 0px));
|
||||
}
|
||||
|
||||
.panel.mobile .header-content :deep(h2) {
|
||||
font-size: 18px;
|
||||
}
|
||||
</style>
|
||||
|
||||
40
front_vue/src/composables/useMobile.js
Normal file
40
front_vue/src/composables/useMobile.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
// Брейкпоинт для мобильных устройств (берётся из config.js)
|
||||
export const MOBILE_BREAKPOINT = window.APP_CONFIG?.MOBILE_BREAKPOINT ?? 768
|
||||
|
||||
// Composable для определения мобильного устройства
|
||||
export function useMobile() {
|
||||
const isMobile = ref(false)
|
||||
|
||||
const checkMobile = () => {
|
||||
isMobile.value = window.innerWidth <= MOBILE_BREAKPOINT
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkMobile)
|
||||
})
|
||||
|
||||
return { isMobile, MOBILE_BREAKPOINT }
|
||||
}
|
||||
|
||||
// Функция для добавления класса на body (вызывать в App.vue)
|
||||
export function initMobileClass() {
|
||||
const updateBodyClass = () => {
|
||||
if (window.innerWidth <= MOBILE_BREAKPOINT) {
|
||||
document.body.classList.add('is-mobile')
|
||||
} else {
|
||||
document.body.classList.remove('is-mobile')
|
||||
}
|
||||
}
|
||||
|
||||
updateBodyClass()
|
||||
window.addEventListener('resize', updateBodyClass)
|
||||
|
||||
return () => window.removeEventListener('resize', updateBodyClass)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<div class="app" :class="{ mobile: isMobile }">
|
||||
<!-- Боковая панель навигации -->
|
||||
<Sidebar />
|
||||
|
||||
@@ -33,6 +33,27 @@
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Мобильные фильтры -->
|
||||
<template #mobile-filters>
|
||||
<MobileSelect
|
||||
v-model="mobileProjectId"
|
||||
:options="projectOptions"
|
||||
title="Выберите проект"
|
||||
placeholder="Проект"
|
||||
variant="accent"
|
||||
@update:model-value="onMobileProjectChange"
|
||||
/>
|
||||
<MobileSelect
|
||||
v-model="activeDepartment"
|
||||
:options="departmentOptions"
|
||||
title="Фильтр по отделам"
|
||||
placeholder="Все отделы"
|
||||
icon="filter"
|
||||
compact
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #stats>
|
||||
<div class="header-stats">
|
||||
<div class="stat">
|
||||
@@ -45,6 +66,15 @@
|
||||
|
||||
<!-- Список архивных задач -->
|
||||
<main class="main">
|
||||
<!-- Мобильный заголовок над карточками -->
|
||||
<div v-if="isMobile" class="mobile-archive-header">
|
||||
<div class="archive-title-row">
|
||||
<span class="archive-dot"></span>
|
||||
<span class="archive-title">В архиве</span>
|
||||
<span class="archive-count">{{ filteredCards.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="archive-list">
|
||||
<ArchiveCard
|
||||
v-for="card in filteredCards"
|
||||
@@ -73,6 +103,7 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Панель редактирования задачи -->
|
||||
<TaskPanel
|
||||
:show="panelOpen"
|
||||
@@ -110,12 +141,45 @@ import ArchiveCard from '../components/ArchiveCard.vue'
|
||||
import TaskPanel from '../components/TaskPanel'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import ProjectSelector from '../components/ProjectSelector.vue'
|
||||
import MobileSelect from '../components/ui/MobileSelect.vue'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import { cardsApi } from '../api'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
// ==================== STORE ====================
|
||||
const store = useProjectsStore()
|
||||
|
||||
// ==================== MOBILE ====================
|
||||
const mobileProjectId = computed({
|
||||
get: () => store.currentProjectId,
|
||||
set: () => {}
|
||||
})
|
||||
|
||||
const projectOptions = computed(() => {
|
||||
return store.projects.map(p => ({
|
||||
id: p.id,
|
||||
label: p.name
|
||||
}))
|
||||
})
|
||||
|
||||
const departmentOptions = computed(() => {
|
||||
return [
|
||||
{ id: null, label: 'Все отделы' },
|
||||
...store.departments.map(d => ({
|
||||
id: d.id,
|
||||
label: d.name_departments
|
||||
}))
|
||||
]
|
||||
})
|
||||
|
||||
const onMobileProjectChange = async (projectId) => {
|
||||
await store.setCurrentProject(projectId)
|
||||
activeDepartment.value = null
|
||||
await fetchCards()
|
||||
}
|
||||
|
||||
// ==================== КАРТОЧКИ ====================
|
||||
const cards = ref([])
|
||||
const loading = ref(true)
|
||||
@@ -437,4 +501,92 @@ onMounted(async () => {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ========== MOBILE ========== */
|
||||
.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;
|
||||
padding-bottom: calc(64px + var(--safe-area-bottom, 0px));
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app.mobile .main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 16px 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app.mobile .archive-list {
|
||||
/* 60px header + 40px title + 64px nav + safe-area */
|
||||
max-height: calc(100dvh - 60px - 40px - 64px - var(--safe-area-bottom, 0px));
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.app.mobile .archive-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app.mobile .archive-list {
|
||||
max-width: none;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app.mobile .empty-state {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.app.mobile .empty-state i {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
/* Мобильный заголовок над карточками */
|
||||
.mobile-archive-header {
|
||||
padding: 12px 0 16px 4px;
|
||||
}
|
||||
|
||||
.archive-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.archive-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--orange, #ff9f43);
|
||||
}
|
||||
|
||||
.archive-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.archive-count {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<div class="app" :class="{ mobile: isMobile }">
|
||||
<!-- Боковая панель навигации -->
|
||||
<Sidebar />
|
||||
|
||||
@@ -7,14 +7,11 @@
|
||||
<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 }"
|
||||
@@ -33,6 +30,28 @@
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Мобильный: Проект (текст) + Отделы (иконка) -->
|
||||
<template #mobile-filters>
|
||||
<MobileSelect
|
||||
v-model="mobileProjectId"
|
||||
:options="projectOptions"
|
||||
title="Выберите проект"
|
||||
placeholder="Проект"
|
||||
variant="accent"
|
||||
@update:model-value="onMobileProjectChange"
|
||||
/>
|
||||
<MobileSelect
|
||||
v-model="activeDepartment"
|
||||
:options="departmentOptions"
|
||||
title="Фильтр по отделам"
|
||||
placeholder="Все отделы"
|
||||
icon="filter"
|
||||
compact
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Десктоп: статистика -->
|
||||
<template #stats>
|
||||
<div class="header-stats">
|
||||
<div class="stat">
|
||||
@@ -90,18 +109,44 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
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 { useProjectsStore } from '../stores/projects'
|
||||
import { cardsApi } from '../api'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
// ==================== STORE ====================
|
||||
const store = useProjectsStore()
|
||||
|
||||
// ==================== МОБИЛЬНЫЕ СЕЛЕКТОРЫ ====================
|
||||
const mobileProjectId = ref(null)
|
||||
|
||||
const projectOptions = computed(() =>
|
||||
store.projects.map(p => ({ id: p.id, label: p.name }))
|
||||
)
|
||||
|
||||
const departmentOptions = computed(() => [
|
||||
{ id: null, label: 'Все отделы' },
|
||||
...store.departments.map(d => ({ id: d.id, label: d.name_departments }))
|
||||
])
|
||||
|
||||
const onMobileProjectChange = async (projectId) => {
|
||||
await store.selectProject(projectId)
|
||||
await onProjectChange()
|
||||
}
|
||||
|
||||
// Синхронизируем с текущим проектом
|
||||
watch(() => store.currentProjectId, (id) => {
|
||||
mobileProjectId.value = id
|
||||
}, { immediate: true })
|
||||
|
||||
// ==================== КАРТОЧКИ ====================
|
||||
const cards = ref([])
|
||||
|
||||
@@ -225,6 +270,21 @@ onUnmounted(() => {
|
||||
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;
|
||||
@@ -233,12 +293,26 @@ onUnmounted(() => {
|
||||
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;
|
||||
}
|
||||
|
||||
/* Кнопка фильтра */
|
||||
@@ -255,6 +329,8 @@ onUnmounted(() => {
|
||||
letter-spacing: 0.3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-tag:hover {
|
||||
@@ -302,6 +378,11 @@ onUnmounted(() => {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* ========== MOBILE: статистика ========== */
|
||||
.app.mobile .header-stats {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Основная область с доской */
|
||||
.main {
|
||||
flex: 1;
|
||||
@@ -331,4 +412,14 @@ onUnmounted(() => {
|
||||
.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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<div class="app" :class="{ mobile: isMobile }">
|
||||
<Sidebar />
|
||||
|
||||
<div class="main-wrapper">
|
||||
@@ -11,7 +11,8 @@
|
||||
<span>Загрузка...</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="team-grid">
|
||||
<!-- Desktop: grid -->
|
||||
<div v-else-if="!isMobile" class="team-grid">
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
@@ -37,8 +38,53 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: swipe cards -->
|
||||
<template v-else>
|
||||
<div class="mobile-cards" ref="mobileCardsRef" @scroll="onCardsScroll">
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
class="mobile-card"
|
||||
>
|
||||
<div class="mobile-avatar">
|
||||
<img :src="getFullUrl(user.avatar_url)" :alt="user.name">
|
||||
</div>
|
||||
|
||||
<div class="mobile-info">
|
||||
<h2 class="mobile-name">{{ user.name }}</h2>
|
||||
<span class="mobile-username">@{{ user.username }}</span>
|
||||
<span v-if="user.department" class="mobile-department">{{ user.department }}</span>
|
||||
</div>
|
||||
|
||||
<a
|
||||
: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 :src="getFullUrl(user.avatar_url)" :alt="user.name">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -47,9 +93,14 @@ import { ref, onMounted, onUpdated } from 'vue'
|
||||
import Sidebar from '../components/Sidebar.vue'
|
||||
import Header from '../components/Header.vue'
|
||||
import { usersApi, getFullUrl } from '../api'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
const users = ref([])
|
||||
const loading = ref(true)
|
||||
const mobileCardsRef = ref(null)
|
||||
const currentUserIndex = ref(0)
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
@@ -64,6 +115,24 @@ const fetchUsers = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const onCardsScroll = () => {
|
||||
if (!mobileCardsRef.value) return
|
||||
const container = mobileCardsRef.value
|
||||
const cardWidth = container.offsetWidth
|
||||
const scrollLeft = container.scrollLeft
|
||||
currentUserIndex.value = Math.round(scrollLeft / cardWidth)
|
||||
}
|
||||
|
||||
const scrollToUser = (index) => {
|
||||
if (!mobileCardsRef.value) return
|
||||
const container = mobileCardsRef.value
|
||||
const cardWidth = container.offsetWidth
|
||||
container.scrollTo({
|
||||
left: index * cardWidth,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
@@ -90,11 +159,32 @@ onUpdated(refreshIcons)
|
||||
margin-left: 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 0 20px 20px;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.main::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.main::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.main::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.main::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.loading {
|
||||
@@ -220,4 +310,194 @@ onUpdated(refreshIcons)
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app.mobile .loading {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
/* Mobile swipe cards */
|
||||
.mobile-cards {
|
||||
width: 100%;
|
||||
/* Высота = экран - header(60px) - индикаторы(70px) - навигация(64px) - safe-area */
|
||||
height: calc(100dvh - 60px - 70px - 64px - var(--safe-area-bottom, 0px));
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-behavior: smooth;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.mobile-cards::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-card {
|
||||
flex: 0 0 100%;
|
||||
width: 100%;
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
padding-top: 0;
|
||||
gap: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mobile-avatar {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(0, 212, 170, 0.3),
|
||||
0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.mobile-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.mobile-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mobile-name {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mobile-username {
|
||||
font-size: 16px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mobile-department {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
border-radius: 20px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mobile-telegram {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 14px 28px;
|
||||
background: linear-gradient(135deg, #0088cc 0%, #00a0dc 100%);
|
||||
color: white;
|
||||
border-radius: 30px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 8px 24px rgba(0, 136, 204, 0.3);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mobile-telegram:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.mobile-telegram i {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Mobile team footer - фиксированный над навигацией */
|
||||
.mobile-team-footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(64px + var(--safe-area-bottom, 0px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.team-indicators {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.team-indicators .indicator-dot {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 2px solid transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.team-indicators .indicator-dot img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.team-indicators .indicator-dot.active {
|
||||
border-color: var(--accent);
|
||||
opacity: 1;
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 4px 12px rgba(0, 212, 170, 0.3);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user