Исправления фронта
Множество оптимизаций по фронту
This commit is contained in:
@@ -49,8 +49,8 @@
|
|||||||
|
|
||||||
<!-- Даты: создано и выполнено -->
|
<!-- Даты: создано и выполнено -->
|
||||||
<div class="card-dates">
|
<div class="card-dates">
|
||||||
<span class="date-created">{{ formatDateFull(card.dateCreate) }}</span>
|
<span class="date-created">{{ formatDateTime(card.dateCreate) }}</span>
|
||||||
<span class="date-closed">{{ formatDateFull(card.dateClosed) }}</span>
|
<span class="date-closed">{{ formatDateTime(card.dateClosed) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Кнопки действий (всегда видны) -->
|
<!-- Кнопки действий (всегда видны) -->
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
|
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<span class="date-create">
|
<span class="date-create">
|
||||||
Создано: {{ formatDateWithYear(card.dateCreate) }}
|
Создано: {{ formatShort(card.dateCreate) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="card.dateClosed" class="date-closed">
|
<span v-if="card.dateClosed" class="date-closed">
|
||||||
Закрыто: {{ closedDateText }}
|
Закрыто: {{ closedDateText }}
|
||||||
@@ -138,45 +138,35 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUpdated } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { getFullUrl } from '../api'
|
import { getFullUrl } from '../api'
|
||||||
import { useMobile } from '../composables/useMobile'
|
import { useMobile } from '../composables/useMobile'
|
||||||
|
import { useLucideIcons } from '../composables/useLucideIcons'
|
||||||
|
import { useDateFormat } from '../composables/useDateFormat'
|
||||||
|
import { useProjectsStore } from '../stores/projects'
|
||||||
|
|
||||||
const { isMobile } = useMobile()
|
const { isMobile } = useMobile()
|
||||||
|
const { formatShort, formatDateTime, formatRelative } = useDateFormat()
|
||||||
|
const store = useProjectsStore()
|
||||||
|
|
||||||
|
useLucideIcons()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
card: Object,
|
card: Object
|
||||||
departments: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
labels: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['click', 'restore', 'delete'])
|
const emit = defineEmits(['click', 'restore', 'delete'])
|
||||||
|
|
||||||
const refreshIcons = () => {
|
|
||||||
if (window.lucide) {
|
|
||||||
window.lucide.createIcons()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(refreshIcons)
|
|
||||||
onUpdated(refreshIcons)
|
|
||||||
|
|
||||||
// Получаем отдел по id
|
// Получаем отдел по id
|
||||||
const cardDepartment = computed(() => {
|
const cardDepartment = computed(() => {
|
||||||
if (!props.card.departmentId) return null
|
if (!props.card.departmentId) return null
|
||||||
return props.departments.find(d => d.id === props.card.departmentId) || null
|
return store.departments.find(d => d.id === props.card.departmentId) || null
|
||||||
})
|
})
|
||||||
|
|
||||||
// Получаем лейбл по id
|
// Получаем лейбл по id
|
||||||
const cardLabel = computed(() => {
|
const cardLabel = computed(() => {
|
||||||
if (!props.card.labelId) return null
|
if (!props.card.labelId) return null
|
||||||
return props.labels.find(l => l.id === props.card.labelId) || null
|
return store.labels.find(l => l.id === props.card.labelId) || null
|
||||||
})
|
})
|
||||||
|
|
||||||
// Цвет лейбла для акцента
|
// Цвет лейбла для акцента
|
||||||
@@ -189,41 +179,8 @@ const isAvatarUrl = (value) => {
|
|||||||
return value && (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/'))
|
return value && (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/'))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Полная дата с временем
|
// Даты через composable
|
||||||
const formatDateFull = (dateStr) => {
|
const closedDateText = computed(() => formatRelative(props.card.dateClosed))
|
||||||
if (!dateStr) return '—'
|
|
||||||
const date = new Date(dateStr)
|
|
||||||
const day = date.getDate().toString().padStart(2, '0')
|
|
||||||
const months = ['янв', 'фев', 'мар', 'апр', 'мая', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
|
|
||||||
const year = date.getFullYear()
|
|
||||||
const hours = date.getHours().toString().padStart(2, '0')
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -5,13 +5,10 @@
|
|||||||
v-for="column in filteredColumns"
|
v-for="column in filteredColumns"
|
||||||
:key="column.id"
|
:key="column.id"
|
||||||
:column="column"
|
:column="column"
|
||||||
:departments="departments"
|
|
||||||
:labels="labels"
|
|
||||||
:done-column-id="doneColumnId"
|
|
||||||
@drop-card="handleDropCard"
|
@drop-card="handleDropCard"
|
||||||
@open-task="(card) => emit('open-task', { card, columnId: column.id })"
|
@open-task="(card) => emit('open-task', { card, columnId: column.id })"
|
||||||
@create-task="emit('create-task', column.id)"
|
@create-task="emit('create-task', column.id)"
|
||||||
@archive-task="archiveTask"
|
@archive-task="confirmArchive"
|
||||||
@move-request="handleMoveRequest"
|
@move-request="handleMoveRequest"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,6 +38,15 @@
|
|||||||
@close="closeMovePanel"
|
@close="closeMovePanel"
|
||||||
@move="handleDropCard"
|
@move="handleDropCard"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Диалог подтверждения архивации -->
|
||||||
|
<ConfirmDialog
|
||||||
|
:show="archiveDialogOpen"
|
||||||
|
type="archive"
|
||||||
|
:action="handleConfirmArchive"
|
||||||
|
@confirm="archiveDialogOpen = false"
|
||||||
|
@cancel="archiveDialogOpen = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -48,10 +54,13 @@
|
|||||||
import { ref, computed, onMounted, onUpdated, watch } from 'vue'
|
import { ref, computed, onMounted, onUpdated, watch } from 'vue'
|
||||||
import Column from './Column.vue'
|
import Column from './Column.vue'
|
||||||
import MoveCardPanel from './ui/MoveCardPanel.vue'
|
import MoveCardPanel from './ui/MoveCardPanel.vue'
|
||||||
|
import ConfirmDialog from './ConfirmDialog.vue'
|
||||||
import { cardsApi } from '../api'
|
import { cardsApi } from '../api'
|
||||||
import { useMobile } from '../composables/useMobile'
|
import { useMobile } from '../composables/useMobile'
|
||||||
|
import { useProjectsStore } from '../stores/projects'
|
||||||
|
|
||||||
const { isMobile } = useMobile()
|
const { isMobile } = useMobile()
|
||||||
|
const store = useProjectsStore()
|
||||||
|
|
||||||
// Состояние для мобильной панели перемещения
|
// Состояние для мобильной панели перемещения
|
||||||
const movePanel = ref({
|
const movePanel = ref({
|
||||||
@@ -76,7 +85,7 @@ const closeMovePanel = () => {
|
|||||||
|
|
||||||
// Колонки для панели перемещения (только id, title, color)
|
// Колонки для панели перемещения (только id, title, color)
|
||||||
const movePanelColumns = computed(() => {
|
const movePanelColumns = computed(() => {
|
||||||
return props.columns.map(col => ({
|
return store.columns.map(col => ({
|
||||||
id: col.id,
|
id: col.id,
|
||||||
title: col.name_columns,
|
title: col.name_columns,
|
||||||
color: col.color
|
color: col.color
|
||||||
@@ -111,19 +120,6 @@ const currentColumnTitle = computed(() => {
|
|||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
activeDepartment: Number,
|
activeDepartment: Number,
|
||||||
doneColumnId: Number,
|
|
||||||
departments: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
labels: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
columns: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
cards: {
|
cards: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
@@ -155,7 +151,7 @@ watch(() => props.cards, (newCards) => {
|
|||||||
|
|
||||||
// Собираем колонки с карточками (используем localCards, сортируем по order)
|
// Собираем колонки с карточками (используем localCards, сортируем по order)
|
||||||
const columnsWithCards = computed(() => {
|
const columnsWithCards = computed(() => {
|
||||||
return props.columns.map(col => ({
|
return store.columns.map(col => ({
|
||||||
id: col.id,
|
id: col.id,
|
||||||
title: col.name_columns,
|
title: col.name_columns,
|
||||||
color: col.color,
|
color: col.color,
|
||||||
@@ -205,8 +201,8 @@ const inProgressTasks = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const completedTasks = computed(() => {
|
const completedTasks = computed(() => {
|
||||||
if (!props.doneColumnId) return 0
|
if (!store.doneColumnId) return 0
|
||||||
const col = filteredColumns.value.find(c => c.id === props.doneColumnId)
|
const col = filteredColumns.value.find(c => c.id === store.doneColumnId)
|
||||||
return col ? col.cards.length : 0
|
return col ? col.cards.length : 0
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -227,9 +223,9 @@ const handleDropCard = async ({ cardId, fromColumnId, toColumnId, toIndex }) =>
|
|||||||
card.column_id = toColumnId
|
card.column_id = toColumnId
|
||||||
|
|
||||||
// Обновляем date_closed при перемещении в/из колонки "Готово"
|
// Обновляем date_closed при перемещении в/из колонки "Готово"
|
||||||
if (props.doneColumnId && toColumnId === props.doneColumnId && fromColumnId !== props.doneColumnId) {
|
if (store.doneColumnId && toColumnId === store.doneColumnId && fromColumnId !== store.doneColumnId) {
|
||||||
card.date_closed = new Date().toISOString()
|
card.date_closed = new Date().toISOString()
|
||||||
} else if (props.doneColumnId && fromColumnId === props.doneColumnId && toColumnId !== props.doneColumnId) {
|
} else if (store.doneColumnId && fromColumnId === store.doneColumnId && toColumnId !== store.doneColumnId) {
|
||||||
card.date_closed = null
|
card.date_closed = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,12 +333,38 @@ const deleteTask = async (cardId, columnId) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== АРХИВАЦИЯ С ПОДТВЕРЖДЕНИЕМ ====================
|
||||||
|
const archiveDialogOpen = ref(false)
|
||||||
|
const cardToArchive = ref(null)
|
||||||
|
|
||||||
|
const confirmArchive = (cardId) => {
|
||||||
|
cardToArchive.value = cardId
|
||||||
|
archiveDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmArchive = async () => {
|
||||||
|
if (!cardToArchive.value) {
|
||||||
|
throw new Error('Задача не выбрана')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await cardsApi.setArchive(cardToArchive.value, 1)
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Ошибка архивации задачи')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем из локального списка
|
||||||
|
const index = localCards.value.findIndex(c => c.id === cardToArchive.value)
|
||||||
|
if (index !== -1) {
|
||||||
|
localCards.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
cardToArchive.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Публичный метод для архивации из TaskPanel (без диалога — там свой)
|
||||||
const archiveTask = async (cardId) => {
|
const archiveTask = async (cardId) => {
|
||||||
// Архивируем на сервере
|
|
||||||
const result = await cardsApi.setArchive(cardId, 1)
|
const result = await cardsApi.setArchive(cardId, 1)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Удаляем из локального списка (задача уходит в архив)
|
|
||||||
const index = localCards.value.findIndex(c => c.id === cardId)
|
const index = localCards.value.findIndex(c => c.id === cardId)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
localCards.value.splice(index, 1)
|
localCards.value.splice(index, 1)
|
||||||
|
|||||||
@@ -62,12 +62,12 @@
|
|||||||
|
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<span v-if="card.dateCreate" class="date-create">
|
<span v-if="card.dateCreate" class="date-create">
|
||||||
Создано: {{ formatDateWithYear(card.dateCreate) }}
|
Создано: {{ formatShort(card.dateCreate) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="card.dueDate && Number(columnId) !== doneColumnId" class="due-date" :class="dueDateStatus">
|
<span v-if="card.dueDate && Number(columnId) !== store.doneColumnId" class="due-date" :class="dueDateStatus">
|
||||||
{{ daysLeftText }}
|
{{ daysLeftText }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="doneColumnId && Number(columnId) === doneColumnId && card.dateClosed" class="date-closed">
|
<span v-if="store.doneColumnId && Number(columnId) === store.doneColumnId && card.dateClosed" class="date-closed">
|
||||||
Закрыто: {{ closedDateText }}
|
Закрыто: {{ closedDateText }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,25 +75,23 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUpdated } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { getFullUrl } from '../api'
|
import { getFullUrl } from '../api'
|
||||||
import { useMobile } from '../composables/useMobile'
|
import { useMobile } from '../composables/useMobile'
|
||||||
|
import { useLucideIcons } from '../composables/useLucideIcons'
|
||||||
|
import { useDateFormat } from '../composables/useDateFormat'
|
||||||
|
import { useProjectsStore } from '../stores/projects'
|
||||||
|
|
||||||
const { isMobile } = useMobile()
|
const { isMobile } = useMobile()
|
||||||
|
const { formatShort, formatRelative, getDaysLeftText, getDueDateStatus } = useDateFormat()
|
||||||
|
const store = useProjectsStore()
|
||||||
|
|
||||||
|
useLucideIcons()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
card: Object,
|
card: Object,
|
||||||
columnId: [String, Number],
|
columnId: [String, Number],
|
||||||
doneColumnId: Number,
|
index: Number
|
||||||
index: Number,
|
|
||||||
departments: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
labels: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['archive', 'move-request'])
|
const emit = defineEmits(['archive', 'move-request'])
|
||||||
@@ -190,15 +188,6 @@ const handleMouseLeave = () => {
|
|||||||
isLongPressing.value = false
|
isLongPressing.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshIcons = () => {
|
|
||||||
if (window.lucide) {
|
|
||||||
window.lucide.createIcons()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(refreshIcons)
|
|
||||||
onUpdated(refreshIcons)
|
|
||||||
|
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
|
|
||||||
const handleDragStart = (e) => {
|
const handleDragStart = (e) => {
|
||||||
@@ -215,13 +204,13 @@ const handleDragEnd = () => {
|
|||||||
// Получаем отдел по id
|
// Получаем отдел по id
|
||||||
const cardDepartment = computed(() => {
|
const cardDepartment = computed(() => {
|
||||||
if (!props.card.departmentId) return null
|
if (!props.card.departmentId) return null
|
||||||
return props.departments.find(d => d.id === props.card.departmentId) || null
|
return store.departments.find(d => d.id === props.card.departmentId) || null
|
||||||
})
|
})
|
||||||
|
|
||||||
// Получаем лейбл по id
|
// Получаем лейбл по id
|
||||||
const cardLabel = computed(() => {
|
const cardLabel = computed(() => {
|
||||||
if (!props.card.labelId) return null
|
if (!props.card.labelId) return null
|
||||||
return props.labels.find(l => l.id === props.card.labelId) || null
|
return store.labels.find(l => l.id === props.card.labelId) || null
|
||||||
})
|
})
|
||||||
|
|
||||||
// Цвет лейбла для фона карточки
|
// Цвет лейбла для фона карточки
|
||||||
@@ -229,62 +218,18 @@ const cardLabelColor = computed(() => {
|
|||||||
return cardLabel.value?.color || null
|
return cardLabel.value?.color || null
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatDateWithYear = (dateStr) => {
|
// Даты через composable
|
||||||
const date = new Date(dateStr)
|
const dueDateStatus = computed(() => getDueDateStatus(props.card.dueDate))
|
||||||
const day = date.getDate()
|
const daysLeftText = computed(() => getDaysLeftText(props.card.dueDate))
|
||||||
const months = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
|
const closedDateText = computed(() => formatRelative(props.card.dateClosed))
|
||||||
return `${day} ${months[date.getMonth()]} ${date.getFullYear()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDaysLeft = () => {
|
|
||||||
if (!props.card.dueDate) return null
|
|
||||||
const due = new Date(props.card.dueDate)
|
|
||||||
const today = new Date()
|
|
||||||
today.setHours(0, 0, 0, 0)
|
|
||||||
due.setHours(0, 0, 0, 0)
|
|
||||||
return Math.round((due - today) / (1000 * 60 * 60 * 24))
|
|
||||||
}
|
|
||||||
|
|
||||||
const dueDateStatus = computed(() => {
|
|
||||||
const days = getDaysLeft()
|
|
||||||
if (days === null) return ''
|
|
||||||
if (days < 0) return 'overdue'
|
|
||||||
if (days <= 2) return 'soon'
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const daysLeftText = computed(() => {
|
|
||||||
const days = getDaysLeft()
|
|
||||||
if (days === null) return ''
|
|
||||||
if (days < 0) return `Просрочено: ${Math.abs(days)} дн.`
|
|
||||||
if (days === 0) return 'Сегодня'
|
|
||||||
if (days === 1) return 'Завтра'
|
|
||||||
return `Осталось: ${days} дн.`
|
|
||||||
})
|
|
||||||
|
|
||||||
const isAvatarUrl = (value) => {
|
const isAvatarUrl = (value) => {
|
||||||
return value && (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/'))
|
return value && (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/'))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Форматирование даты закрытия (относительный формат)
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Можно ли архивировать (только если колонка "Готово")
|
// Можно ли архивировать (только если колонка "Готово")
|
||||||
const canArchive = computed(() => {
|
const canArchive = computed(() => {
|
||||||
return props.doneColumnId && Number(props.columnId) === props.doneColumnId
|
return store.doneColumnId && Number(props.columnId) === store.doneColumnId
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleArchive = () => {
|
const handleArchive = () => {
|
||||||
|
|||||||
@@ -26,10 +26,7 @@
|
|||||||
<Card
|
<Card
|
||||||
:card="card"
|
:card="card"
|
||||||
:column-id="column.id"
|
:column-id="column.id"
|
||||||
:done-column-id="doneColumnId"
|
|
||||||
:index="index"
|
:index="index"
|
||||||
:departments="departments"
|
|
||||||
:labels="labels"
|
|
||||||
@click="emit('open-task', card)"
|
@click="emit('open-task', card)"
|
||||||
@archive="emit('archive-task', $event)"
|
@archive="emit('archive-task', $event)"
|
||||||
@move-request="emit('move-request', $event)"
|
@move-request="emit('move-request', $event)"
|
||||||
@@ -51,16 +48,7 @@ import { useMobile } from '../composables/useMobile'
|
|||||||
const { isMobile } = useMobile()
|
const { isMobile } = useMobile()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
column: Object,
|
column: Object
|
||||||
doneColumnId: Number,
|
|
||||||
departments: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
labels: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['drop-card', 'open-task', 'create-task', 'archive-task', 'move-request'])
|
const emit = defineEmits(['drop-card', 'open-task', 'create-task', 'archive-task', 'move-request'])
|
||||||
|
|||||||
@@ -2,27 +2,28 @@
|
|||||||
<Transition name="dialog">
|
<Transition name="dialog">
|
||||||
<div v-if="show" class="dialog-overlay" @click.self="handleCancel">
|
<div v-if="show" class="dialog-overlay" @click.self="handleCancel">
|
||||||
<div class="dialog">
|
<div class="dialog">
|
||||||
<h3>{{ title }}</h3>
|
<h3>{{ dialogTitle }}</h3>
|
||||||
<p v-html="message"></p>
|
<p v-html="dialogMessage"></p>
|
||||||
<div class="dialog-buttons">
|
<div class="dialog-buttons">
|
||||||
<button class="btn-cancel" @click="handleCancel">
|
<button class="btn-cancel" @click="handleCancel" :disabled="loading">
|
||||||
{{ cancelText }}
|
{{ dialogCancelText }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="showDiscard"
|
v-if="dialogShowDiscard"
|
||||||
class="btn-discard"
|
class="btn-discard"
|
||||||
@click="handleDiscard"
|
@click="handleDiscard"
|
||||||
|
:disabled="loading"
|
||||||
>
|
>
|
||||||
{{ discardText }}
|
{{ dialogDiscardText }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn-confirm"
|
class="btn-confirm"
|
||||||
:class="variant"
|
:class="dialogVariant"
|
||||||
@click="handleConfirm"
|
@click="handleConfirm"
|
||||||
:disabled="isLoading"
|
:disabled="loading"
|
||||||
>
|
>
|
||||||
<span v-if="isLoading" class="btn-loader"></span>
|
<span v-if="loading" class="btn-loader"></span>
|
||||||
<span v-else>{{ confirmText }}</span>
|
<span v-else>{{ dialogConfirmText }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,58 +32,87 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { DIALOGS } from '../stores/dialogs'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: {
|
show: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
title: {
|
// Тип диалога из конфига (archive, restore, deleteTask, etc.)
|
||||||
|
type: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'Подтверждение'
|
default: null
|
||||||
},
|
},
|
||||||
message: {
|
// Прямые props (переопределяют type если заданы)
|
||||||
type: String,
|
title: String,
|
||||||
default: 'Вы уверены?'
|
message: String,
|
||||||
},
|
confirmText: String,
|
||||||
confirmText: {
|
cancelText: String,
|
||||||
type: String,
|
discardText: String,
|
||||||
default: 'Подтвердить'
|
showDiscard: Boolean,
|
||||||
},
|
variant: String,
|
||||||
cancelText: {
|
// Async callback для подтверждения — сам управляет loading
|
||||||
type: String,
|
action: {
|
||||||
default: 'Отмена'
|
type: Function,
|
||||||
},
|
default: null
|
||||||
discardText: {
|
|
||||||
type: String,
|
|
||||||
default: 'Не сохранять'
|
|
||||||
},
|
|
||||||
showDiscard: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
// Варианты: 'default', 'danger', 'warning'
|
|
||||||
variant: {
|
|
||||||
type: String,
|
|
||||||
default: 'default'
|
|
||||||
},
|
|
||||||
// Состояние загрузки (блокирует кнопку подтверждения)
|
|
||||||
isLoading: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Получаем конфиг по типу
|
||||||
|
const config = computed(() => props.type ? DIALOGS[props.type] : {})
|
||||||
|
|
||||||
|
// Computed свойства с fallback: props → config → default
|
||||||
|
const dialogTitle = computed(() => props.title ?? config.value.title ?? 'Подтверждение')
|
||||||
|
const dialogMessage = computed(() => props.message ?? config.value.message ?? 'Вы уверены?')
|
||||||
|
const dialogConfirmText = computed(() => props.confirmText ?? config.value.confirmText ?? 'Подтвердить')
|
||||||
|
const dialogCancelText = computed(() => props.cancelText ?? 'Отмена')
|
||||||
|
const dialogDiscardText = computed(() => props.discardText ?? 'Не сохранять')
|
||||||
|
const dialogShowDiscard = computed(() => props.showDiscard ?? config.value.showDiscard ?? false)
|
||||||
|
const dialogVariant = computed(() => props.variant ?? config.value.variant ?? 'default')
|
||||||
|
|
||||||
const emit = defineEmits(['confirm', 'cancel', 'discard'])
|
const emit = defineEmits(['confirm', 'cancel', 'discard'])
|
||||||
|
|
||||||
const handleConfirm = () => {
|
// Внутреннее состояние загрузки
|
||||||
emit('confirm')
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// Сброс состояния при закрытии диалога
|
||||||
|
watch(() => props.show, (newVal) => {
|
||||||
|
if (!newVal) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (loading.value) return
|
||||||
|
|
||||||
|
// Если есть async action — вызываем его и управляем loading
|
||||||
|
if (props.action) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await props.action()
|
||||||
|
// Успех — эмитим confirm для закрытия
|
||||||
|
emit('confirm')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('ConfirmDialog action failed:', e)
|
||||||
|
// При ошибке — не закрываем диалог
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Простой режим — просто эмитим
|
||||||
|
emit('confirm')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
|
if (loading.value) return
|
||||||
emit('cancel')
|
emit('cancel')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDiscard = () => {
|
const handleDiscard = () => {
|
||||||
|
if (loading.value) return
|
||||||
emit('discard')
|
emit('discard')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
<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>
|
|
||||||
40
front_vue/src/components/DepartmentTags.vue
Normal file
40
front_vue/src/components/DepartmentTags.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<div class="filters">
|
||||||
|
<ProjectSelector @change="$emit('project-change')" />
|
||||||
|
<div class="filter-divider"></div>
|
||||||
|
<button
|
||||||
|
class="filter-tag"
|
||||||
|
:class="{ active: modelValue === null }"
|
||||||
|
@click="$emit('update:modelValue', null)"
|
||||||
|
>
|
||||||
|
Все
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="dept in store.departments"
|
||||||
|
:key="dept.id"
|
||||||
|
class="filter-tag"
|
||||||
|
:class="{ active: modelValue === dept.id }"
|
||||||
|
@click="$emit('update:modelValue', modelValue === dept.id ? null : dept.id)"
|
||||||
|
>
|
||||||
|
{{ dept.name_departments }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import ProjectSelector from './ProjectSelector.vue'
|
||||||
|
import { useProjectsStore } from '../stores/projects'
|
||||||
|
|
||||||
|
const store = useProjectsStore()
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Number,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['update:modelValue', 'project-change'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Стили определены в PageLayout.vue через :deep(.filters) -->
|
||||||
@@ -36,6 +36,7 @@ import { onMounted, onUpdated } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { authApi } from '../api'
|
import { authApi } from '../api'
|
||||||
import { useMobile } from '../composables/useMobile'
|
import { useMobile } from '../composables/useMobile'
|
||||||
|
import { clearAuthCache } from '../router'
|
||||||
|
|
||||||
const { isMobile } = useMobile()
|
const { isMobile } = useMobile()
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ defineProps({
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
|
clearAuthCache()
|
||||||
await authApi.logout()
|
await authApi.logout()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
|
|||||||
193
front_vue/src/components/PageLayout.vue
Normal file
193
front_vue/src/components/PageLayout.vue
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app" :class="{ mobile: isMobile }">
|
||||||
|
<!-- Боковая панель навигации -->
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
<!-- Основной контент -->
|
||||||
|
<div class="main-wrapper">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальные окна (вне main-wrapper) -->
|
||||||
|
<slot name="modals"></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import Sidebar from './Sidebar.vue'
|
||||||
|
import { useMobile } from '../composables/useMobile'
|
||||||
|
|
||||||
|
const { isMobile } = useMobile()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ========== APP CONTAINER ========== */
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== MAIN WRAPPER ========== */
|
||||||
|
.main-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0; /* Позволяет flex-item сжиматься меньше контента */
|
||||||
|
margin-left: 64px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== FILTERS (deep для слотов) ========== */
|
||||||
|
.app :deep(.filters) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app :deep(.filter-divider) {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
margin: 0 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app :deep(.filter-tag) {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app :deep(.filter-tag:hover) {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app :deep(.filter-tag.active) {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== HEADER STATS ========== */
|
||||||
|
.app :deep(.header-stats) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app :deep(.stat) {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app :deep(.stat-value) {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app :deep(.stat-label) {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app :deep(.stat-divider) {
|
||||||
|
width: 1px;
|
||||||
|
height: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== MAIN CONTENT AREA ========== */
|
||||||
|
.app :deep(.main) {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0 36px 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стилизация горизонтального скроллбара (для доски) */
|
||||||
|
.app :deep(.main)::-webkit-scrollbar {
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app :deep(.main)::-webkit-scrollbar-track {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 0 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app :deep(.main)::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 212, 170, 0.4);
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app :deep(.main)::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(0, 212, 170, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 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 :deep(.filters) {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app.mobile :deep(.filters)::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Скрываем статистику на мобильных */
|
||||||
|
.app.mobile :deep(.header-stats) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Мобильный main — базовые flex-стили, padding управляется через класс .main.mobile в каждой странице */
|
||||||
|
.app.mobile :deep(.main) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,769 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="comment-form">
|
|
||||||
<!-- 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>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<!-- 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"
|
|
||||||
accept=".png,.jpg,.jpeg,.zip,.rar,image/png,image/jpeg,application/zip,application/x-rar-compressed"
|
|
||||||
multiple
|
|
||||||
@change="handleFileSelect"
|
|
||||||
style="display: none"
|
|
||||||
>
|
|
||||||
|
|
||||||
<!-- 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>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
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: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
},
|
|
||||||
replyingTo: {
|
|
||||||
type: Object,
|
|
||||||
default: null
|
|
||||||
},
|
|
||||||
isSending: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
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']
|
|
||||||
const maxFileSize = 10 * 1024 * 1024 // 10 MB
|
|
||||||
|
|
||||||
// Можно отправить если есть текст или файлы
|
|
||||||
const canSend = computed(() => {
|
|
||||||
return props.modelValue.trim() || files.value.length > 0
|
|
||||||
})
|
|
||||||
|
|
||||||
// Проверка расширения файла
|
|
||||||
const getFileExt = (file) => {
|
|
||||||
return file.name?.split('.').pop()?.toLowerCase() || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const isArchive = (file) => {
|
|
||||||
return archiveExtensions.includes(getFileExt(file))
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAllowedFile = (file) => {
|
|
||||||
return allowedExtensions.includes(getFileExt(file))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Открыть диалог выбора файлов
|
|
||||||
const triggerFileInput = () => {
|
|
||||||
fileInputRef.value?.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обработка выбора файлов
|
|
||||||
const handleFileSelect = (event) => {
|
|
||||||
const selectedFiles = event.target.files
|
|
||||||
if (selectedFiles) {
|
|
||||||
processFiles(Array.from(selectedFiles))
|
|
||||||
}
|
|
||||||
event.target.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обработка файлов
|
|
||||||
const processFiles = (fileList) => {
|
|
||||||
for (const file of fileList) {
|
|
||||||
// Проверка типа
|
|
||||||
if (!isAllowedFile(file)) {
|
|
||||||
console.warn(`Файл "${file.name}" не поддерживается.`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверка размера
|
|
||||||
if (file.size > maxFileSize) {
|
|
||||||
console.warn(`Файл "${file.name}" слишком большой.`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем дубликат
|
|
||||||
const isDuplicate = files.value.some(
|
|
||||||
f => f.name === file.name && f.size === file.size
|
|
||||||
)
|
|
||||||
if (isDuplicate) continue
|
|
||||||
|
|
||||||
// Читаем файл как base64
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = (e) => {
|
|
||||||
files.value.push({
|
|
||||||
file: file,
|
|
||||||
name: file.name,
|
|
||||||
size: file.size,
|
|
||||||
data: e.target.result
|
|
||||||
})
|
|
||||||
refreshIcons()
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Удаление файла из списка
|
|
||||||
const removeFile = (index) => {
|
|
||||||
files.value.splice(index, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получить файлы для отправки
|
|
||||||
const getFiles = () => {
|
|
||||||
return files.value.map(f => ({
|
|
||||||
name: f.name,
|
|
||||||
data: f.data
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Очистить файлы
|
|
||||||
const clearFiles = () => {
|
|
||||||
files.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyFormat = (command) => {
|
|
||||||
editorRef.value?.applyFormat(command)
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyMobileFormat = (command) => {
|
|
||||||
mobileEditorRef.value?.applyFormat(command)
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshIcons = () => {
|
|
||||||
if (window.lucide) {
|
|
||||||
window.lucide.createIcons()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(refreshIcons)
|
|
||||||
onUpdated(refreshIcons)
|
|
||||||
|
|
||||||
// Expose для внешнего доступа
|
|
||||||
defineExpose({
|
|
||||||
setContent: (text) => editorRef.value?.setContent(text),
|
|
||||||
focus: () => editorRef.value?.$el?.focus(),
|
|
||||||
applyFormat,
|
|
||||||
getFiles,
|
|
||||||
clearFiles,
|
|
||||||
hasFiles: computed(() => files.value.length > 0)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.format-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.format-btn {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 5px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.format-btn:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
border-color: rgba(255, 255, 255, 0.15);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.format-btn:active {
|
|
||||||
background: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.format-btn i {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-send-comment {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px;
|
|
||||||
background: var(--accent);
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #000;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-send-comment:hover {
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-send-comment:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-send-comment i {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-loader {
|
|
||||||
display: inline-block;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border: 2px solid rgba(0, 0, 0, 0.3);
|
|
||||||
border-top-color: #000;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.7s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Индикатор ответа */
|
|
||||||
.reply-indicator {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: rgba(0, 212, 170, 0.08);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reply-indicator i {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reply-indicator strong {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reply-cancel {
|
|
||||||
margin-left: auto;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reply-cancel:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reply-cancel i {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Прикреплённые файлы */
|
|
||||||
.attached-files {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attached-file {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.attached-file-icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.attached-file-icon i {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attached-file-name {
|
|
||||||
max-width: 120px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attached-file-remove {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.15s;
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attached-file-remove:hover {
|
|
||||||
background: rgba(239, 68, 68, 0.2);
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attached-file-remove i {
|
|
||||||
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>
|
|
||||||
@@ -82,12 +82,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUpdated } from 'vue'
|
import { computed } from 'vue'
|
||||||
import IconButton from '../ui/IconButton.vue'
|
import IconButton from '../ui/IconButton.vue'
|
||||||
import { serverSettings } from '../../api'
|
|
||||||
import { useMobile } from '../../composables/useMobile'
|
import { useMobile } from '../../composables/useMobile'
|
||||||
|
import { useLucideIcons } from '../../composables/useLucideIcons'
|
||||||
|
import { useDateFormat } from '../../composables/useDateFormat'
|
||||||
|
|
||||||
const { isMobile } = useMobile()
|
const { isMobile } = useMobile()
|
||||||
|
const { formatTimeAgo } = useDateFormat()
|
||||||
|
|
||||||
|
useLucideIcons()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
comment: {
|
comment: {
|
||||||
@@ -131,36 +135,8 @@ const openPreview = (file) => {
|
|||||||
emit('preview-file', file)
|
emit('preview-file', file)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Форматирование даты комментария
|
// Форматирование даты комментария (таймзона сервера учитывается автоматически)
|
||||||
const formattedDate = computed(() => {
|
const formattedDate = computed(() => formatTimeAgo(props.comment.date_create))
|
||||||
if (!props.comment.date_create) return ''
|
|
||||||
// Используем таймзону сервера из настроек
|
|
||||||
const date = serverSettings.parseDate(props.comment.date_create)
|
|
||||||
const now = new Date()
|
|
||||||
const diff = now - date
|
|
||||||
const minutes = Math.floor(diff / 60000)
|
|
||||||
const hours = Math.floor(diff / 3600000)
|
|
||||||
const days = Math.floor(diff / 86400000)
|
|
||||||
|
|
||||||
if (minutes < 1) return 'только что'
|
|
||||||
if (minutes < 60) return `${minutes} мин. назад`
|
|
||||||
if (hours < 24) return `${hours} ч. назад`
|
|
||||||
if (days < 7) return `${days} дн. назад`
|
|
||||||
|
|
||||||
const day = date.getDate()
|
|
||||||
const months = ['янв', 'фев', 'мар', 'апр', 'мая', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
|
|
||||||
const time = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
|
||||||
return `${day} ${months[date.getMonth()]} ${time}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const refreshIcons = () => {
|
|
||||||
if (window.lucide) {
|
|
||||||
window.lucide.createIcons()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(refreshIcons)
|
|
||||||
onUpdated(refreshIcons)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
392
front_vue/src/components/TaskPanel/ContentEditorPanel.vue
Normal file
392
front_vue/src/components/TaskPanel/ContentEditorPanel.vue
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
<template>
|
||||||
|
<SlidePanel
|
||||||
|
:show="show"
|
||||||
|
:width="500"
|
||||||
|
:min-width="400"
|
||||||
|
:max-width="700"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="editor-panel-header">
|
||||||
|
<div v-if="avatarUrl" class="editor-author-avatar">
|
||||||
|
<img :src="avatarUrl" alt="">
|
||||||
|
</div>
|
||||||
|
<div class="editor-header-text">
|
||||||
|
<h2>{{ title }}</h2>
|
||||||
|
<span v-if="subtitle" class="editor-author-name">{{ subtitle }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="editor-panel-content">
|
||||||
|
<!-- Редактор текста -->
|
||||||
|
<div class="editor-form-section">
|
||||||
|
<div class="editor-form-label">
|
||||||
|
<span>{{ textLabel }}</span>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<RichTextEditor
|
||||||
|
:modelValue="localText"
|
||||||
|
@update:modelValue="localText = $event"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:show-toolbar="false"
|
||||||
|
ref="editorRef"
|
||||||
|
class="editor-textarea"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Файлы -->
|
||||||
|
<div class="editor-form-section">
|
||||||
|
<div class="editor-form-label">
|
||||||
|
<span>Файлы</span>
|
||||||
|
</div>
|
||||||
|
<FileUploader
|
||||||
|
:files="allFiles"
|
||||||
|
:get-full-url="getFileFullUrl"
|
||||||
|
dropzone-text="Перетащите файлы сюда"
|
||||||
|
dropzone-subtext="или нажмите для выбора"
|
||||||
|
@add="handleFileAdd"
|
||||||
|
@remove="handleFileRemove"
|
||||||
|
@preview="handleFilePreview"
|
||||||
|
@error="handleFileError"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<ActionButtons
|
||||||
|
:save-text="saveButtonText"
|
||||||
|
save-icon="check"
|
||||||
|
:loading="isSaving"
|
||||||
|
:disabled="!canSave"
|
||||||
|
@save="handleSave"
|
||||||
|
@cancel="handleClose"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</SlidePanel>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
|
import SlidePanel from '../ui/SlidePanel.vue'
|
||||||
|
import RichTextEditor from '../ui/RichTextEditor.vue'
|
||||||
|
import FileUploader from '../ui/FileUploader.vue'
|
||||||
|
import ActionButtons from '../ui/ActionButtons.vue'
|
||||||
|
import { getFullUrl } from '../../api'
|
||||||
|
import { useLucideIcons } from '../../composables/useLucideIcons'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// Управление видимостью
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
// Заголовок панели
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: 'Редактирование'
|
||||||
|
},
|
||||||
|
// Подзаголовок (например, имя автора)
|
||||||
|
subtitle: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
// URL аватара (опционально)
|
||||||
|
avatarUrl: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
// Начальный текст
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
// Placeholder для редактора
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: 'Введите текст...'
|
||||||
|
},
|
||||||
|
// Лейбл для поля текста
|
||||||
|
textLabel: {
|
||||||
|
type: String,
|
||||||
|
default: 'Текст'
|
||||||
|
},
|
||||||
|
// Существующие файлы (для редактирования)
|
||||||
|
existingFiles: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
// Функция получения URL файла
|
||||||
|
fileUrlGetter: {
|
||||||
|
type: Function,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
// Текст кнопки сохранения
|
||||||
|
saveButtonText: {
|
||||||
|
type: String,
|
||||||
|
default: 'Сохранить'
|
||||||
|
},
|
||||||
|
// Состояние сохранения
|
||||||
|
isSaving: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['save', 'close', 'update:show'])
|
||||||
|
|
||||||
|
const { refreshIcons } = useLucideIcons()
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const editorRef = ref(null)
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
const localText = ref('')
|
||||||
|
const allFiles = ref([])
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const canSave = computed(() => {
|
||||||
|
const hasText = localText.value.trim()
|
||||||
|
const hasNewFiles = allFiles.value.some(f => f.isNew)
|
||||||
|
return hasText || hasNewFiles
|
||||||
|
})
|
||||||
|
|
||||||
|
// Получить полный URL файла
|
||||||
|
const getFileFullUrl = (url) => {
|
||||||
|
if (props.fileUrlGetter) {
|
||||||
|
return props.fileUrlGetter(url)
|
||||||
|
}
|
||||||
|
return getFullUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// File handlers
|
||||||
|
const handleFileAdd = (file) => {
|
||||||
|
allFiles.value.push(file)
|
||||||
|
refreshIcons()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileRemove = (index) => {
|
||||||
|
const file = allFiles.value[index]
|
||||||
|
if (file.isNew) {
|
||||||
|
// Новый файл - удаляем сразу
|
||||||
|
allFiles.value.splice(index, 1)
|
||||||
|
} else {
|
||||||
|
// Существующий файл - помечаем на удаление
|
||||||
|
allFiles.value[index] = { ...file, toDelete: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFilePreview = (file) => {
|
||||||
|
// Можно добавить emit для превью если нужно
|
||||||
|
console.log('Preview file:', file.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileError = (message) => {
|
||||||
|
console.warn(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format
|
||||||
|
const applyFormat = (command) => {
|
||||||
|
editorRef.value?.applyFormat(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const handleSave = () => {
|
||||||
|
// Собираем новые файлы
|
||||||
|
const newFiles = allFiles.value
|
||||||
|
.filter(f => f.isNew)
|
||||||
|
.map(f => ({
|
||||||
|
name: f.name,
|
||||||
|
data: f.preview
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Собираем файлы на удаление
|
||||||
|
const filesToDelete = allFiles.value
|
||||||
|
.filter(f => f.toDelete)
|
||||||
|
.map(f => f.name)
|
||||||
|
|
||||||
|
emit('save', {
|
||||||
|
text: localText.value,
|
||||||
|
newFiles,
|
||||||
|
filesToDelete
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close')
|
||||||
|
emit('update:show', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset state when panel opens
|
||||||
|
const resetState = () => {
|
||||||
|
localText.value = props.text || ''
|
||||||
|
|
||||||
|
// Преобразуем существующие файлы в формат FileUploader
|
||||||
|
allFiles.value = (props.existingFiles || []).map(file => ({
|
||||||
|
name: file.name,
|
||||||
|
size: file.size || 0,
|
||||||
|
preview: file.url,
|
||||||
|
isNew: false,
|
||||||
|
toDelete: false
|
||||||
|
}))
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
editorRef.value?.setContent(localText.value)
|
||||||
|
refreshIcons()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch show prop
|
||||||
|
watch(() => props.show, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
resetState()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch text/existingFiles for external updates
|
||||||
|
watch(() => props.text, (newVal) => {
|
||||||
|
if (props.show) {
|
||||||
|
localText.value = newVal || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.existingFiles, (newVal) => {
|
||||||
|
if (props.show) {
|
||||||
|
allFiles.value = (newVal || []).map(file => ({
|
||||||
|
name: file.name,
|
||||||
|
size: file.size || 0,
|
||||||
|
preview: file.url,
|
||||||
|
isNew: false,
|
||||||
|
toDelete: false
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// Expose
|
||||||
|
defineExpose({
|
||||||
|
reset: resetState
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ========== Panel Header ========== */
|
||||||
|
.editor-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-panel-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-author-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-author-avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-author-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Content ========== */
|
||||||
|
.editor-panel-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-form-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-form-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-btn {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-btn:active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-btn i {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-textarea {
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-textarea :deep(.editor-content) {
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -85,10 +85,7 @@
|
|||||||
<!-- Диалог удаления файла -->
|
<!-- Диалог удаления файла -->
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:show="showDeleteFileDialog"
|
:show="showDeleteFileDialog"
|
||||||
title="Удалить изображение?"
|
type="deleteFile"
|
||||||
message="Изображение будет удалено из задачи."
|
|
||||||
confirm-text="Удалить"
|
|
||||||
variant="danger"
|
|
||||||
@confirm="confirmDeleteFile"
|
@confirm="confirmDeleteFile"
|
||||||
@cancel="showDeleteFileDialog = false"
|
@cancel="showDeleteFileDialog = false"
|
||||||
/>
|
/>
|
||||||
@@ -96,7 +93,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, watch, onMounted, onUpdated, nextTick } from 'vue'
|
import { ref, reactive, computed, watch, nextTick } from 'vue'
|
||||||
import FormField from '../ui/FormField.vue'
|
import FormField from '../ui/FormField.vue'
|
||||||
import TextInput from '../ui/TextInput.vue'
|
import TextInput from '../ui/TextInput.vue'
|
||||||
import RichTextEditor from '../ui/RichTextEditor.vue'
|
import RichTextEditor from '../ui/RichTextEditor.vue'
|
||||||
@@ -107,9 +104,12 @@ import DatePicker from '../DatePicker.vue'
|
|||||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||||
import { getFullUrl } from '../../api'
|
import { getFullUrl } from '../../api'
|
||||||
import { useMobile } from '../../composables/useMobile'
|
import { useMobile } from '../../composables/useMobile'
|
||||||
|
import { useLucideIcons } from '../../composables/useLucideIcons'
|
||||||
|
|
||||||
const { isMobile } = useMobile()
|
const { isMobile } = useMobile()
|
||||||
|
|
||||||
|
const { refreshIcons } = useLucideIcons()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
card: {
|
card: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -286,12 +286,6 @@ const confirmDeleteFile = () => {
|
|||||||
fileToDeleteIndex.value = null
|
fileToDeleteIndex.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshIcons = () => {
|
|
||||||
if (window.lucide) {
|
|
||||||
window.lucide.createIcons()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get form data for saving
|
// Get form data for saving
|
||||||
const getFormData = () => {
|
const getFormData = () => {
|
||||||
return {
|
return {
|
||||||
@@ -335,9 +329,6 @@ const setDetailsContent = (content) => {
|
|||||||
detailsEditorRef.value?.setContent(content)
|
detailsEditorRef.value?.setContent(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(refreshIcons)
|
|
||||||
onUpdated(refreshIcons)
|
|
||||||
|
|
||||||
// Expose for parent
|
// Expose for parent
|
||||||
defineExpose({
|
defineExpose({
|
||||||
form,
|
form,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<div class="header-title-block">
|
<div class="header-title-block">
|
||||||
<h2>{{ panelTitle }}</h2>
|
<h2>{{ panelTitle }}</h2>
|
||||||
<span v-if="!isNew && card?.dateCreate" class="header-date">
|
<span v-if="!isNew && card?.dateCreate" class="header-date">
|
||||||
Создано: {{ formatDate(card.dateCreate) }}
|
Создано: {{ formatFull(card.dateCreate) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -26,9 +26,9 @@
|
|||||||
v-show="activeTab === 'edit' || isNew"
|
v-show="activeTab === 'edit' || isNew"
|
||||||
ref="editTabRef"
|
ref="editTabRef"
|
||||||
:card="card"
|
:card="card"
|
||||||
:departments="departments"
|
:departments="store.departments"
|
||||||
:labels="labels"
|
:labels="store.labels"
|
||||||
:users="users"
|
:users="store.users"
|
||||||
@preview-image="openImagePreview"
|
@preview-image="openImagePreview"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -37,8 +37,10 @@
|
|||||||
v-show="activeTab === 'comments'"
|
v-show="activeTab === 'comments'"
|
||||||
ref="commentsTabRef"
|
ref="commentsTabRef"
|
||||||
:task-id="card?.id"
|
:task-id="card?.id"
|
||||||
:current-user-id="currentUserId"
|
:current-user-id="store.currentUserId"
|
||||||
:is-project-admin="isProjectAdmin"
|
:current-user-name="store.currentUserName"
|
||||||
|
:current-user-avatar="store.currentUserAvatar"
|
||||||
|
:is-project-admin="store.isProjectAdmin"
|
||||||
:active="activeTab === 'comments'"
|
:active="activeTab === 'comments'"
|
||||||
@comments-loaded="commentsCount = $event"
|
@comments-loaded="commentsCount = $event"
|
||||||
@preview-file="openImagePreview"
|
@preview-file="openImagePreview"
|
||||||
@@ -70,27 +72,20 @@
|
|||||||
@click="handleRestore"
|
@click="handleRestore"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-right">
|
<ActionButtons
|
||||||
<button class="btn-cancel" @click="tryClose">Отмена</button>
|
:save-text="isNew ? 'Создать' : 'Сохранить'"
|
||||||
<button
|
:loading="isSaving"
|
||||||
class="btn-save"
|
:disabled="!canSave"
|
||||||
@click="handleSave"
|
@save="handleSave"
|
||||||
:disabled="!canSave || isSaving"
|
@cancel="tryClose"
|
||||||
>
|
/>
|
||||||
<span v-if="isSaving" class="btn-loader"></span>
|
|
||||||
<span v-else>{{ isNew ? 'Создать' : 'Сохранить' }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</SlidePanel>
|
</SlidePanel>
|
||||||
|
|
||||||
<!-- Диалог несохранённых изменений -->
|
<!-- Диалог несохранённых изменений -->
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:show="showUnsavedDialog"
|
:show="showUnsavedDialog"
|
||||||
title="Обнаружены изменения"
|
type="unsavedChanges"
|
||||||
message="У вас есть несохранённые изменения.<br>Что вы хотите сделать?"
|
|
||||||
confirm-text="Сохранить"
|
|
||||||
:show-discard="true"
|
|
||||||
@confirm="confirmSave"
|
@confirm="confirmSave"
|
||||||
@cancel="cancelClose"
|
@cancel="cancelClose"
|
||||||
@discard="confirmDiscard"
|
@discard="confirmDiscard"
|
||||||
@@ -99,33 +94,27 @@
|
|||||||
<!-- Диалог удаления задачи -->
|
<!-- Диалог удаления задачи -->
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:show="showDeleteDialog"
|
:show="showDeleteDialog"
|
||||||
title="Удалить задачу?"
|
type="deleteTask"
|
||||||
message="Это действие нельзя отменить.<br>Задача будет удалена навсегда."
|
:action="confirmDelete"
|
||||||
confirm-text="Удалить"
|
@confirm="showDeleteDialog = false"
|
||||||
variant="danger"
|
|
||||||
@confirm="confirmDelete"
|
|
||||||
@cancel="showDeleteDialog = false"
|
@cancel="showDeleteDialog = false"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Диалог архивации задачи -->
|
<!-- Диалог архивации задачи -->
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:show="showArchiveDialog"
|
:show="showArchiveDialog"
|
||||||
title="Архивировать задачу?"
|
type="archive"
|
||||||
message="Задача будет перемещена в архив.<br>Вы сможете восстановить её позже."
|
:action="confirmArchive"
|
||||||
confirm-text="В архив"
|
@confirm="showArchiveDialog = false"
|
||||||
variant="warning"
|
|
||||||
@confirm="confirmArchive"
|
|
||||||
@cancel="showArchiveDialog = false"
|
@cancel="showArchiveDialog = false"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Диалог разархивации задачи -->
|
<!-- Диалог разархивации задачи -->
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:show="showRestoreDialog"
|
:show="showRestoreDialog"
|
||||||
title="Вернуть из архива?"
|
type="restore"
|
||||||
message="Задача будет возвращена на доску<br>в колонку «Готово»."
|
:action="confirmRestore"
|
||||||
confirm-text="Вернуть"
|
@confirm="showRestoreDialog = false"
|
||||||
variant="warning"
|
|
||||||
@confirm="confirmRestore"
|
|
||||||
@cancel="showRestoreDialog = false"
|
@cancel="showRestoreDialog = false"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -140,18 +129,25 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted, onUpdated, nextTick } from 'vue'
|
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||||
import SlidePanel from '../ui/SlidePanel.vue'
|
import SlidePanel from '../ui/SlidePanel.vue'
|
||||||
import TabsPanel from '../ui/TabsPanel.vue'
|
import TabsPanel from '../ui/TabsPanel.vue'
|
||||||
import IconButton from '../ui/IconButton.vue'
|
import IconButton from '../ui/IconButton.vue'
|
||||||
|
import ActionButtons from '../ui/ActionButtons.vue'
|
||||||
import ImagePreview from '../ui/ImagePreview.vue'
|
import ImagePreview from '../ui/ImagePreview.vue'
|
||||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||||
import TaskEditTab from './TaskEditTab.vue'
|
import TaskEditTab from './TaskEditTab.vue'
|
||||||
import TaskCommentsTab from './TaskCommentsTab.vue'
|
import TaskCommentsTab from './TaskCommentsTab.vue'
|
||||||
import { taskImageApi, commentImageApi, getFullUrl } from '../../api'
|
import { taskImageApi, commentImageApi, getFullUrl } from '../../api'
|
||||||
import { useMobile } from '../../composables/useMobile'
|
import { useMobile } from '../../composables/useMobile'
|
||||||
|
import { useLucideIcons } from '../../composables/useLucideIcons'
|
||||||
|
import { useDateFormat } from '../../composables/useDateFormat'
|
||||||
|
import { useProjectsStore } from '../../stores/projects'
|
||||||
|
|
||||||
const { isMobile } = useMobile()
|
const { isMobile } = useMobile()
|
||||||
|
const { refreshIcons } = useLucideIcons()
|
||||||
|
const { formatFull } = useDateFormat()
|
||||||
|
const store = useProjectsStore()
|
||||||
|
|
||||||
// Состояние загрузки для кнопки сохранения
|
// Состояние загрузки для кнопки сохранения
|
||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
@@ -160,33 +156,24 @@ const props = defineProps({
|
|||||||
show: Boolean,
|
show: Boolean,
|
||||||
card: Object,
|
card: Object,
|
||||||
columnId: [String, Number],
|
columnId: [String, Number],
|
||||||
doneColumnId: Number,
|
|
||||||
isArchived: {
|
isArchived: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
departments: {
|
// Callbacks (возвращают Promise)
|
||||||
type: Array,
|
onSave: {
|
||||||
default: () => []
|
type: Function,
|
||||||
},
|
|
||||||
labels: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
users: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
currentUserId: {
|
|
||||||
type: Number,
|
|
||||||
default: null
|
default: null
|
||||||
},
|
},
|
||||||
isProjectAdmin: {
|
onDelete: {
|
||||||
type: Boolean,
|
type: Function,
|
||||||
default: false
|
default: null
|
||||||
},
|
},
|
||||||
// Callback для сохранения (возвращает Promise)
|
onArchive: {
|
||||||
onSave: {
|
type: Function,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
onRestore: {
|
||||||
type: Function,
|
type: Function,
|
||||||
default: null
|
default: null
|
||||||
}
|
}
|
||||||
@@ -233,18 +220,9 @@ const canSave = computed(() => {
|
|||||||
|
|
||||||
// Can archive (только если колонка "Готово")
|
// Can archive (только если колонка "Готово")
|
||||||
const canArchive = computed(() => {
|
const canArchive = computed(() => {
|
||||||
return props.doneColumnId && Number(props.columnId) === props.doneColumnId
|
return store.doneColumnId && Number(props.columnId) === store.doneColumnId
|
||||||
})
|
})
|
||||||
|
|
||||||
// Format date
|
|
||||||
const formatDate = (dateStr) => {
|
|
||||||
if (!dateStr) return ''
|
|
||||||
const date = new Date(dateStr)
|
|
||||||
const day = date.getDate()
|
|
||||||
const months = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
|
|
||||||
return `${day} ${months[date.getMonth()]} ${date.getFullYear()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close handling
|
// Close handling
|
||||||
const tryClose = () => {
|
const tryClose = () => {
|
||||||
if (editTabRef.value?.hasChanges) {
|
if (editTabRef.value?.hasChanges) {
|
||||||
@@ -327,9 +305,16 @@ const handleDelete = () => {
|
|||||||
showDeleteDialog.value = true
|
showDeleteDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmDelete = () => {
|
const confirmDelete = async () => {
|
||||||
showDeleteDialog.value = false
|
if (!props.card?.id) {
|
||||||
emit('delete', props.card.id)
|
throw new Error('Задача не выбрана')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.onDelete) {
|
||||||
|
await props.onDelete(props.card.id)
|
||||||
|
} else {
|
||||||
|
emit('delete', props.card.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Archive
|
// Archive
|
||||||
@@ -337,9 +322,14 @@ const handleArchive = () => {
|
|||||||
showArchiveDialog.value = true
|
showArchiveDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmArchive = () => {
|
const confirmArchive = async () => {
|
||||||
showArchiveDialog.value = false
|
if (!props.card?.id) {
|
||||||
if (props.card?.id) {
|
throw new Error('Задача не выбрана')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.onArchive) {
|
||||||
|
await props.onArchive(props.card.id)
|
||||||
|
} else {
|
||||||
emit('archive', props.card.id)
|
emit('archive', props.card.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,9 +339,14 @@ const handleRestore = () => {
|
|||||||
showRestoreDialog.value = true
|
showRestoreDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmRestore = () => {
|
const confirmRestore = async () => {
|
||||||
showRestoreDialog.value = false
|
if (!props.card?.id) {
|
||||||
if (props.card?.id) {
|
throw new Error('Задача не выбрана')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.onRestore) {
|
||||||
|
await props.onRestore(props.card.id)
|
||||||
|
} else {
|
||||||
emit('restore', props.card.id)
|
emit('restore', props.card.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -397,13 +392,6 @@ const deleteFromPreview = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Icons
|
|
||||||
const refreshIcons = () => {
|
|
||||||
if (window.lucide) {
|
|
||||||
window.lucide.createIcons()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watch show
|
// Watch show
|
||||||
watch(() => props.show, async (newVal) => {
|
watch(() => props.show, async (newVal) => {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
@@ -427,9 +415,6 @@ watch(() => props.show, async (newVal) => {
|
|||||||
refreshIcons()
|
refreshIcons()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(refreshIcons)
|
|
||||||
onUpdated(refreshIcons)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -457,68 +442,4 @@ onUpdated(refreshIcons)
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-right {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-cancel {
|
|
||||||
padding: 12px 24px;
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-cancel:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-save {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 120px;
|
|
||||||
padding: 12px 28px;
|
|
||||||
background: var(--accent);
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #000;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-save:hover {
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-save:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-loader {
|
|
||||||
display: inline-block;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border: 2px solid rgba(0, 0, 0, 0.3);
|
|
||||||
border-top-color: #000;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.7s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export { default as TaskPanel } from './TaskPanel.vue'
|
|||||||
export { default as TaskEditTab } from './TaskEditTab.vue'
|
export { default as TaskEditTab } from './TaskEditTab.vue'
|
||||||
export { default as TaskCommentsTab } from './TaskCommentsTab.vue'
|
export { default as TaskCommentsTab } from './TaskCommentsTab.vue'
|
||||||
export { default as CommentItem } from './CommentItem.vue'
|
export { default as CommentItem } from './CommentItem.vue'
|
||||||
export { default as CommentForm } from './CommentForm.vue'
|
export { default as ContentEditorPanel } from './ContentEditorPanel.vue'
|
||||||
|
|
||||||
// Default export
|
// Default export
|
||||||
export { default } from './TaskPanel.vue'
|
export { default } from './TaskPanel.vue'
|
||||||
|
|||||||
128
front_vue/src/components/ui/ActionButtons.vue
Normal file
128
front_vue/src/components/ui/ActionButtons.vue
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<template>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-cancel"
|
||||||
|
@click="$emit('cancel')"
|
||||||
|
>
|
||||||
|
{{ cancelText }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-save"
|
||||||
|
@click="$emit('save')"
|
||||||
|
:disabled="disabled || loading"
|
||||||
|
>
|
||||||
|
<span v-if="loading" class="btn-loader"></span>
|
||||||
|
<template v-else>
|
||||||
|
<i v-if="saveIcon" :data-lucide="saveIcon"></i>
|
||||||
|
<span>{{ saveText }}</span>
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
// Текст кнопки отмены
|
||||||
|
cancelText: {
|
||||||
|
type: String,
|
||||||
|
default: 'Отмена'
|
||||||
|
},
|
||||||
|
// Текст кнопки сохранения
|
||||||
|
saveText: {
|
||||||
|
type: String,
|
||||||
|
default: 'Сохранить'
|
||||||
|
},
|
||||||
|
// Иконка кнопки сохранения (lucide icon name)
|
||||||
|
saveIcon: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
// Состояние загрузки
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
// Отключить кнопку сохранения
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['save', 'cancel'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 12px 28px;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #000;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save i {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-loader {
|
||||||
|
display: inline-block;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.3);
|
||||||
|
border-top-color: #000;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -68,7 +68,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUpdated } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
import { useLucideIcons } from '../../composables/useLucideIcons'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
files: {
|
files: {
|
||||||
@@ -108,6 +109,8 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['add', 'remove', 'preview', 'error'])
|
const emit = defineEmits(['add', 'remove', 'preview', 'error'])
|
||||||
|
|
||||||
|
useLucideIcons()
|
||||||
|
|
||||||
const fileInputRef = ref(null)
|
const fileInputRef = ref(null)
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
|
|
||||||
@@ -237,16 +240,6 @@ const downloadFile = async (file) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновление иконок
|
|
||||||
const refreshIcons = () => {
|
|
||||||
if (window.lucide) {
|
|
||||||
window.lucide.createIcons()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(refreshIcons)
|
|
||||||
onUpdated(refreshIcons)
|
|
||||||
|
|
||||||
// Экспортируем методы
|
// Экспортируем методы
|
||||||
defineExpose({
|
defineExpose({
|
||||||
triggerFileInput
|
triggerFileInput
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, onUpdated } from 'vue'
|
import { useLucideIcons } from '../../composables/useLucideIcons'
|
||||||
|
|
||||||
|
useLucideIcons()
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
icon: {
|
icon: {
|
||||||
@@ -48,15 +50,6 @@ defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['click'])
|
defineEmits(['click'])
|
||||||
|
|
||||||
const refreshIcons = () => {
|
|
||||||
if (window.lucide) {
|
|
||||||
window.lucide.createIcons()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(refreshIcons)
|
|
||||||
onUpdated(refreshIcons)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -46,7 +46,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onMounted, onUpdated } from 'vue'
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
import { useLucideIcons } from '../../composables/useLucideIcons'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -69,6 +70,8 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'focus', 'blur'])
|
const emit = defineEmits(['update:modelValue', 'focus', 'blur'])
|
||||||
|
|
||||||
|
useLucideIcons()
|
||||||
|
|
||||||
const editorRef = ref(null)
|
const editorRef = ref(null)
|
||||||
let isInternalChange = false
|
let isInternalChange = false
|
||||||
|
|
||||||
@@ -234,20 +237,10 @@ watch(() => props.modelValue, (newVal, oldVal) => {
|
|||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
// Обновление иконок Lucide
|
|
||||||
const refreshIcons = () => {
|
|
||||||
if (window.lucide) {
|
|
||||||
window.lucide.createIcons()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
refreshIcons()
|
|
||||||
setContent(props.modelValue)
|
setContent(props.modelValue)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUpdated(refreshIcons)
|
|
||||||
|
|
||||||
// Экспортируем методы для использования извне
|
// Экспортируем методы для использования извне
|
||||||
defineExpose({
|
defineExpose({
|
||||||
setContent,
|
setContent,
|
||||||
|
|||||||
@@ -41,11 +41,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted, onUpdated, watch } from 'vue'
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useMobile } from '../../composables/useMobile'
|
import { useMobile } from '../../composables/useMobile'
|
||||||
|
import { useLucideIcons } from '../../composables/useLucideIcons'
|
||||||
|
|
||||||
const { isMobile } = useMobile()
|
const { isMobile } = useMobile()
|
||||||
|
|
||||||
|
useLucideIcons()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: {
|
show: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -139,13 +142,6 @@ const handleClose = () => {
|
|||||||
emit('update:show', false)
|
emit('update:show', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновление иконок Lucide
|
|
||||||
const refreshIcons = () => {
|
|
||||||
if (window.lucide) {
|
|
||||||
window.lucide.createIcons()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Блокировка скролла body при открытии панели
|
// Блокировка скролла body при открытии панели
|
||||||
const lockBodyScroll = () => {
|
const lockBodyScroll = () => {
|
||||||
document.body.classList.add('panel-open')
|
document.body.classList.add('panel-open')
|
||||||
@@ -165,9 +161,6 @@ watch(() => props.show, (newVal) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(refreshIcons)
|
|
||||||
onUpdated(refreshIcons)
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('mousemove', onResize)
|
document.removeEventListener('mousemove', onResize)
|
||||||
document.removeEventListener('mouseup', stopResize)
|
document.removeEventListener('mouseup', stopResize)
|
||||||
|
|||||||
166
front_vue/src/composables/useDateFormat.js
Normal file
166
front_vue/src/composables/useDateFormat.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Composable для форматирования дат
|
||||||
|
* Автоматически использует таймзону сервера из serverSettings
|
||||||
|
*
|
||||||
|
* Использование:
|
||||||
|
* import { useDateFormat } from '@/composables/useDateFormat'
|
||||||
|
* const { formatShort, formatRelative, getDaysLeftText } = useDateFormat()
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { serverSettings } from '../api'
|
||||||
|
|
||||||
|
// Константы месяцев
|
||||||
|
const MONTHS_SHORT = ['янв', 'фев', 'мар', 'апр', 'мая', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
|
||||||
|
const MONTHS_FULL = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
|
||||||
|
|
||||||
|
// Для дат типа "1 май" (именительный падеж)
|
||||||
|
const MONTHS_SHORT_NOM = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
|
||||||
|
|
||||||
|
export function useDateFormat() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсинг даты с учётом таймзоны сервера
|
||||||
|
*/
|
||||||
|
const parseDate = (dateStr) => {
|
||||||
|
if (!dateStr) return null
|
||||||
|
return serverSettings.parseDate(dateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Короткий формат: "1 янв 2025"
|
||||||
|
*/
|
||||||
|
const formatShort = (dateStr) => {
|
||||||
|
const date = parseDate(dateStr)
|
||||||
|
if (!date) return ''
|
||||||
|
const day = date.getDate()
|
||||||
|
return `${day} ${MONTHS_SHORT_NOM[date.getMonth()]} ${date.getFullYear()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полный формат: "1 января 2025"
|
||||||
|
*/
|
||||||
|
const formatFull = (dateStr) => {
|
||||||
|
const date = parseDate(dateStr)
|
||||||
|
if (!date) return ''
|
||||||
|
const day = date.getDate()
|
||||||
|
return `${day} ${MONTHS_FULL[date.getMonth()]} ${date.getFullYear()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* С временем: "01 янв 2025, 14:30"
|
||||||
|
*/
|
||||||
|
const formatDateTime = (dateStr) => {
|
||||||
|
const date = parseDate(dateStr)
|
||||||
|
if (!date) return '—'
|
||||||
|
const day = date.getDate().toString().padStart(2, '0')
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const hours = date.getHours().toString().padStart(2, '0')
|
||||||
|
const minutes = date.getMinutes().toString().padStart(2, '0')
|
||||||
|
return `${day} ${MONTHS_SHORT[date.getMonth()]} ${year}, ${hours}:${minutes}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Расчёт дней между датами
|
||||||
|
* Положительное число = дней осталось, отрицательное = просрочено
|
||||||
|
*/
|
||||||
|
const getDaysUntil = (dateStr) => {
|
||||||
|
const target = parseDate(dateStr)
|
||||||
|
if (!target) return null
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
target.setHours(0, 0, 0, 0)
|
||||||
|
return Math.round((target - today) / (1000 * 60 * 60 * 24))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Расчёт дней назад
|
||||||
|
*/
|
||||||
|
const getDaysAgo = (dateStr) => {
|
||||||
|
const days = getDaysUntil(dateStr)
|
||||||
|
return days !== null ? -days : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Текст для дедлайна: "Сегодня", "Завтра", "Осталось: 5 дн.", "Просрочено: 3 дн."
|
||||||
|
*/
|
||||||
|
const getDaysLeftText = (dateStr) => {
|
||||||
|
const days = getDaysUntil(dateStr)
|
||||||
|
if (days === null) return ''
|
||||||
|
if (days < 0) return `Просрочено: ${Math.abs(days)} дн.`
|
||||||
|
if (days === 0) return 'Сегодня'
|
||||||
|
if (days === 1) return 'Завтра'
|
||||||
|
return `Осталось: ${days} дн.`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Статус дедлайна для CSS классов: 'overdue', 'soon', ''
|
||||||
|
*/
|
||||||
|
const getDueDateStatus = (dateStr) => {
|
||||||
|
const days = getDaysUntil(dateStr)
|
||||||
|
if (days === null) return ''
|
||||||
|
if (days < 0) return 'overdue'
|
||||||
|
if (days <= 2) return 'soon'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Относительная дата в прошлом: "Сегодня", "Вчера", "3 дня назад", или полная дата
|
||||||
|
*/
|
||||||
|
const formatRelative = (dateStr) => {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const daysAgo = getDaysAgo(dateStr)
|
||||||
|
if (daysAgo === null) return ''
|
||||||
|
|
||||||
|
if (daysAgo === 0) return 'Сегодня'
|
||||||
|
if (daysAgo === 1) return 'Вчера'
|
||||||
|
if (daysAgo >= 2 && daysAgo <= 4) return `${daysAgo} дня назад`
|
||||||
|
if (daysAgo >= 5 && daysAgo <= 14) return `${daysAgo} дней назад`
|
||||||
|
return formatShort(dateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Время назад для комментариев: "только что", "5 мин. назад", "2 ч. назад", "3 дн. назад"
|
||||||
|
* Для старых дат — "1 янв 14:30"
|
||||||
|
*/
|
||||||
|
const formatTimeAgo = (dateStr) => {
|
||||||
|
const date = parseDate(dateStr)
|
||||||
|
if (!date) return ''
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now - date
|
||||||
|
const minutes = Math.floor(diff / 60000)
|
||||||
|
const hours = Math.floor(diff / 3600000)
|
||||||
|
const days = Math.floor(diff / 86400000)
|
||||||
|
|
||||||
|
if (minutes < 1) return 'только что'
|
||||||
|
if (minutes < 60) return `${minutes} мин. назад`
|
||||||
|
if (hours < 24) return `${hours} ч. назад`
|
||||||
|
if (days < 7) return `${days} дн. назад`
|
||||||
|
|
||||||
|
const day = date.getDate()
|
||||||
|
const time = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
return `${day} ${MONTHS_SHORT[date.getMonth()]} ${time}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Константы
|
||||||
|
MONTHS_SHORT,
|
||||||
|
MONTHS_FULL,
|
||||||
|
|
||||||
|
// Парсинг
|
||||||
|
parseDate,
|
||||||
|
|
||||||
|
// Форматирование
|
||||||
|
formatShort,
|
||||||
|
formatFull,
|
||||||
|
formatDateTime,
|
||||||
|
formatRelative,
|
||||||
|
formatTimeAgo,
|
||||||
|
|
||||||
|
// Расчёты
|
||||||
|
getDaysUntil,
|
||||||
|
getDaysAgo,
|
||||||
|
getDaysLeftText,
|
||||||
|
getDueDateStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
48
front_vue/src/composables/useDepartmentFilter.js
Normal file
48
front_vue/src/composables/useDepartmentFilter.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useProjectsStore } from '../stores/projects'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable для фильтрации по отделам
|
||||||
|
* Автоматически сохраняет выбор в localStorage
|
||||||
|
* Берёт departments из Pinia store
|
||||||
|
*
|
||||||
|
* Использование:
|
||||||
|
* import { useDepartmentFilter } from '@/composables/useDepartmentFilter'
|
||||||
|
* const { activeDepartment, departmentOptions, resetFilter } = useDepartmentFilter()
|
||||||
|
*/
|
||||||
|
export function useDepartmentFilter() {
|
||||||
|
const store = useProjectsStore()
|
||||||
|
|
||||||
|
// Восстанавливаем из localStorage
|
||||||
|
const savedDepartment = localStorage.getItem('activeDepartment')
|
||||||
|
const activeDepartment = ref(savedDepartment ? parseInt(savedDepartment) : null)
|
||||||
|
|
||||||
|
// Опции для MobileSelect
|
||||||
|
const departmentOptions = computed(() => [
|
||||||
|
{ id: null, label: 'Все отделы' },
|
||||||
|
...store.departments.map(d => ({
|
||||||
|
id: d.id,
|
||||||
|
label: d.name_departments
|
||||||
|
}))
|
||||||
|
])
|
||||||
|
|
||||||
|
// Сохраняем в localStorage при изменении
|
||||||
|
watch(activeDepartment, (newVal) => {
|
||||||
|
if (newVal === null) {
|
||||||
|
localStorage.removeItem('activeDepartment')
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('activeDepartment', newVal.toString())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Сброс фильтра
|
||||||
|
const resetFilter = () => {
|
||||||
|
activeDepartment.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeDepartment,
|
||||||
|
departmentOptions,
|
||||||
|
resetFilter
|
||||||
|
}
|
||||||
|
}
|
||||||
22
front_vue/src/composables/useLucideIcons.js
Normal file
22
front_vue/src/composables/useLucideIcons.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { onMounted, onUpdated } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable для автоматического обновления Lucide иконок
|
||||||
|
* при монтировании и обновлении компонента.
|
||||||
|
*
|
||||||
|
* Использование:
|
||||||
|
* import { useLucideIcons } from '@/composables/useLucideIcons'
|
||||||
|
* useLucideIcons()
|
||||||
|
*/
|
||||||
|
export function useLucideIcons() {
|
||||||
|
const refresh = () => {
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refresh)
|
||||||
|
onUpdated(refresh)
|
||||||
|
|
||||||
|
return { refreshIcons: refresh }
|
||||||
|
}
|
||||||
@@ -5,16 +5,48 @@ import TeamPage from './views/TeamPage.vue'
|
|||||||
import ArchivePage from './views/ArchivePage.vue'
|
import ArchivePage from './views/ArchivePage.vue'
|
||||||
import { authApi } from './api'
|
import { authApi } from './api'
|
||||||
|
|
||||||
// Проверка авторизации
|
// Кэш авторизации (чтобы не делать запрос при каждой навигации)
|
||||||
const checkAuth = async () => {
|
let authCache = {
|
||||||
|
isAuthenticated: null, // null = не проверяли, true/false = результат
|
||||||
|
lastCheck: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Время жизни кэша авторизации (5 минут)
|
||||||
|
const AUTH_CACHE_TTL = 5 * 60 * 1000
|
||||||
|
|
||||||
|
// Проверка авторизации (с кэшированием)
|
||||||
|
const checkAuth = async (forceCheck = false) => {
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
// Используем кэш если он валиден и не форсируем проверку
|
||||||
|
if (!forceCheck && authCache.isAuthenticated !== null && (now - authCache.lastCheck) < AUTH_CACHE_TTL) {
|
||||||
|
return authCache.isAuthenticated
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await authApi.check()
|
const data = await authApi.check()
|
||||||
return data.success === true
|
authCache.isAuthenticated = data.success === true
|
||||||
|
authCache.lastCheck = now
|
||||||
|
return authCache.isAuthenticated
|
||||||
} catch {
|
} catch {
|
||||||
|
// При ошибке сети — используем кэш если есть, иначе false
|
||||||
|
if (authCache.isAuthenticated !== null) {
|
||||||
|
return authCache.isAuthenticated
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сброс кэша (вызывать при logout)
|
||||||
|
export const clearAuthCache = () => {
|
||||||
|
authCache = { isAuthenticated: null, lastCheck: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Установка кэша (вызывать при успешном login)
|
||||||
|
export const setAuthCache = (isAuth) => {
|
||||||
|
authCache = { isAuthenticated: isAuth, lastCheck: Date.now() }
|
||||||
|
}
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -48,9 +80,17 @@ const router = createRouter({
|
|||||||
|
|
||||||
// Navigation guard — проверка авторизации
|
// Navigation guard — проверка авторизации
|
||||||
router.beforeEach(async (to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
const isAuth = await checkAuth()
|
// Если переходим между защищёнными страницами и кэш валиден — не проверяем сеть
|
||||||
|
const needsAuth = to.meta.requiresAuth
|
||||||
|
const fromProtected = from.meta?.requiresAuth
|
||||||
|
|
||||||
if (to.meta.requiresAuth && !isAuth) {
|
// Форсируем проверку только при переходе на защищённую страницу извне
|
||||||
|
// или при переходе на /login (чтобы редиректнуть если уже авторизован)
|
||||||
|
const forceCheck = (needsAuth && !fromProtected) || to.path === '/login'
|
||||||
|
|
||||||
|
const isAuth = await checkAuth(forceCheck)
|
||||||
|
|
||||||
|
if (needsAuth && !isAuth) {
|
||||||
// Не авторизован — на логин
|
// Не авторизован — на логин
|
||||||
next('/login')
|
next('/login')
|
||||||
} else if (to.path === '/login' && isAuth) {
|
} else if (to.path === '/login' && isAuth) {
|
||||||
|
|||||||
54
front_vue/src/stores/dialogs.js
Normal file
54
front_vue/src/stores/dialogs.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Конфигурация диалогов подтверждения
|
||||||
|
* Используется в ConfirmDialog через prop "type"
|
||||||
|
*/
|
||||||
|
export const DIALOGS = {
|
||||||
|
// Архивация задачи
|
||||||
|
archive: {
|
||||||
|
title: 'Архивировать задачу?',
|
||||||
|
message: 'Задача будет перемещена в архив.<br>Вы сможете восстановить её позже.',
|
||||||
|
confirmText: 'В архив',
|
||||||
|
variant: 'warning'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Восстановление из архива
|
||||||
|
restore: {
|
||||||
|
title: 'Вернуть из архива?',
|
||||||
|
message: 'Задача будет возвращена на доску<br>в колонку «Готово».',
|
||||||
|
confirmText: 'Вернуть',
|
||||||
|
variant: 'warning'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Удаление задачи
|
||||||
|
deleteTask: {
|
||||||
|
title: 'Удалить задачу?',
|
||||||
|
message: 'Это действие нельзя отменить.<br>Задача будет удалена навсегда.',
|
||||||
|
confirmText: 'Удалить',
|
||||||
|
variant: 'danger'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Удаление комментария
|
||||||
|
deleteComment: {
|
||||||
|
title: 'Удалить комментарий?',
|
||||||
|
message: 'Комментарий будет удалён навсегда.',
|
||||||
|
confirmText: 'Удалить',
|
||||||
|
variant: 'danger'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Удаление файла
|
||||||
|
deleteFile: {
|
||||||
|
title: 'Удалить изображение?',
|
||||||
|
message: 'Изображение будет удалено из задачи.',
|
||||||
|
confirmText: 'Удалить',
|
||||||
|
variant: 'danger'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Несохранённые изменения
|
||||||
|
unsavedChanges: {
|
||||||
|
title: 'Обнаружены изменения',
|
||||||
|
message: 'У вас есть несохранённые изменения.<br>Что вы хотите сделать?',
|
||||||
|
confirmText: 'Сохранить',
|
||||||
|
showDiscard: true,
|
||||||
|
variant: 'default'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { projectsApi, usersApi, authApi } from '../api'
|
import { projectsApi, usersApi, authApi, cardsApi } from '../api'
|
||||||
|
|
||||||
export const useProjectsStore = defineStore('projects', () => {
|
export const useProjectsStore = defineStore('projects', () => {
|
||||||
// ==================== СОСТОЯНИЕ ====================
|
// ==================== СОСТОЯНИЕ ====================
|
||||||
@@ -9,6 +9,9 @@ export const useProjectsStore = defineStore('projects', () => {
|
|||||||
const labels = ref([])
|
const labels = ref([])
|
||||||
const columns = ref([])
|
const columns = ref([])
|
||||||
const users = ref([])
|
const users = ref([])
|
||||||
|
const cards = ref([]) // Активные карточки текущего проекта
|
||||||
|
const archivedCards = ref([]) // Архивные карточки текущего проекта
|
||||||
|
const cardsLoading = ref(false) // Загрузка карточек
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const initialized = ref(false)
|
const initialized = ref(false)
|
||||||
const currentUser = ref(null) // Текущий авторизованный пользователь
|
const currentUser = ref(null) // Текущий авторизованный пользователь
|
||||||
@@ -34,6 +37,12 @@ export const useProjectsStore = defineStore('projects', () => {
|
|||||||
// ID текущего пользователя
|
// ID текущего пользователя
|
||||||
const currentUserId = computed(() => currentUser.value?.id || null)
|
const currentUserId = computed(() => currentUser.value?.id || null)
|
||||||
|
|
||||||
|
// Имя текущего пользователя
|
||||||
|
const currentUserName = computed(() => currentUser.value?.name || '')
|
||||||
|
|
||||||
|
// Аватар текущего пользователя
|
||||||
|
const currentUserAvatar = computed(() => currentUser.value?.avatar_url || '')
|
||||||
|
|
||||||
// Является ли текущий пользователь админом проекта
|
// Является ли текущий пользователь админом проекта
|
||||||
// Сервер возвращает id_admin: true только если текущий пользователь — админ
|
// Сервер возвращает id_admin: true только если текущий пользователь — админ
|
||||||
const isProjectAdmin = computed(() => {
|
const isProjectAdmin = computed(() => {
|
||||||
@@ -139,6 +148,65 @@ export const useProjectsStore = defineStore('projects', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== КАРТОЧКИ ====================
|
||||||
|
// Загрузка активных карточек (silent = тихое обновление без loading)
|
||||||
|
const fetchCards = async (silent = false) => {
|
||||||
|
if (!currentProjectId.value) {
|
||||||
|
cardsLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!silent) cardsLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await cardsApi.getAll(currentProjectId.value)
|
||||||
|
if (result.success) cards.value = result.data
|
||||||
|
} finally {
|
||||||
|
if (!silent) cardsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка архивных карточек
|
||||||
|
const fetchArchivedCards = async () => {
|
||||||
|
if (!currentProjectId.value) return
|
||||||
|
|
||||||
|
cardsLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await cardsApi.getAll(currentProjectId.value, 1) // archive = 1
|
||||||
|
if (result.success) {
|
||||||
|
archivedCards.value = result.data.map(card => ({
|
||||||
|
id: card.id,
|
||||||
|
title: card.title,
|
||||||
|
description: card.descript,
|
||||||
|
details: card.descript_full,
|
||||||
|
departmentId: card.id_department,
|
||||||
|
labelId: card.id_label,
|
||||||
|
accountId: card.id_account,
|
||||||
|
assignee: card.avatar_img,
|
||||||
|
dueDate: card.date,
|
||||||
|
dateCreate: card.date_create,
|
||||||
|
dateClosed: card.date_closed,
|
||||||
|
columnId: card.column_id,
|
||||||
|
order: card.order ?? 0,
|
||||||
|
comments_count: card.comments_count || 0,
|
||||||
|
files: card.files || (card.file_img || []).map(f => ({
|
||||||
|
name: f.name,
|
||||||
|
url: f.url,
|
||||||
|
size: f.size,
|
||||||
|
preview: f.url
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cardsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистка карточек при смене проекта
|
||||||
|
const clearCards = () => {
|
||||||
|
cards.value = []
|
||||||
|
archivedCards.value = []
|
||||||
|
}
|
||||||
|
|
||||||
// Сброс при выходе
|
// Сброс при выходе
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
projects.value = []
|
projects.value = []
|
||||||
@@ -146,6 +214,8 @@ export const useProjectsStore = defineStore('projects', () => {
|
|||||||
labels.value = []
|
labels.value = []
|
||||||
columns.value = []
|
columns.value = []
|
||||||
users.value = []
|
users.value = []
|
||||||
|
cards.value = []
|
||||||
|
archivedCards.value = []
|
||||||
currentProjectId.value = null
|
currentProjectId.value = null
|
||||||
currentUser.value = null
|
currentUser.value = null
|
||||||
initialized.value = false
|
initialized.value = false
|
||||||
@@ -160,6 +230,9 @@ export const useProjectsStore = defineStore('projects', () => {
|
|||||||
labels,
|
labels,
|
||||||
columns,
|
columns,
|
||||||
users,
|
users,
|
||||||
|
cards,
|
||||||
|
archivedCards,
|
||||||
|
cardsLoading,
|
||||||
loading,
|
loading,
|
||||||
initialized,
|
initialized,
|
||||||
currentProjectId,
|
currentProjectId,
|
||||||
@@ -168,11 +241,16 @@ export const useProjectsStore = defineStore('projects', () => {
|
|||||||
currentProject,
|
currentProject,
|
||||||
doneColumnId,
|
doneColumnId,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
|
currentUserName,
|
||||||
|
currentUserAvatar,
|
||||||
isProjectAdmin,
|
isProjectAdmin,
|
||||||
// Действия
|
// Действия
|
||||||
init,
|
init,
|
||||||
selectProject,
|
selectProject,
|
||||||
fetchProjectData,
|
fetchProjectData,
|
||||||
|
fetchCards,
|
||||||
|
fetchArchivedCards,
|
||||||
|
clearCards,
|
||||||
reset
|
reset
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,37 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app" :class="{ mobile: isMobile }">
|
<PageLayout>
|
||||||
<!-- Боковая панель навигации -->
|
|
||||||
<Sidebar />
|
|
||||||
|
|
||||||
<!-- Основной контент -->
|
|
||||||
<div class="main-wrapper">
|
|
||||||
<!-- Шапка с заголовком и фильтрами -->
|
<!-- Шапка с заголовком и фильтрами -->
|
||||||
<Header title="Архив задач">
|
<Header title="Архив задач">
|
||||||
<template #filters>
|
<template #filters>
|
||||||
<div class="filters">
|
<DepartmentTags
|
||||||
<!-- Выбор проекта -->
|
v-model="activeDepartment"
|
||||||
<ProjectSelector @change="onProjectChange" />
|
@project-change="onProjectChange"
|
||||||
|
/>
|
||||||
<div class="filter-divider"></div>
|
|
||||||
|
|
||||||
<!-- Фильтр по отделам -->
|
|
||||||
<button
|
|
||||||
class="filter-tag"
|
|
||||||
:class="{ active: activeDepartment === null }"
|
|
||||||
@click="activeDepartment = null"
|
|
||||||
>
|
|
||||||
Все
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-for="dept in store.departments"
|
|
||||||
:key="dept.id"
|
|
||||||
class="filter-tag"
|
|
||||||
:class="{ active: activeDepartment === dept.id }"
|
|
||||||
@click="activeDepartment = activeDepartment === dept.id ? null : dept.id"
|
|
||||||
>
|
|
||||||
{{ dept.name_departments }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Мобильные фильтры -->
|
<!-- Мобильные фильтры -->
|
||||||
@@ -58,7 +33,7 @@
|
|||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
<!-- Список архивных задач -->
|
<!-- Список архивных задач -->
|
||||||
<main class="main">
|
<main class="main" :class="{ mobile: isMobile }">
|
||||||
<!-- Мобильный заголовок над карточками -->
|
<!-- Мобильный заголовок над карточками -->
|
||||||
<div v-if="isMobile" class="mobile-archive-header">
|
<div v-if="isMobile" class="mobile-archive-header">
|
||||||
<div class="archive-title-row">
|
<div class="archive-title-row">
|
||||||
@@ -68,20 +43,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="archive-list">
|
<div class="archive-list" :class="{ mobile: isMobile }">
|
||||||
<ArchiveCard
|
<ArchiveCard
|
||||||
v-for="card in filteredCards"
|
v-for="card in filteredCards"
|
||||||
:key="card.id"
|
:key="card.id"
|
||||||
:card="card"
|
:card="card"
|
||||||
:departments="store.departments"
|
|
||||||
:labels="store.labels"
|
|
||||||
@click="openTaskPanel(card)"
|
@click="openTaskPanel(card)"
|
||||||
@restore="handleRestore"
|
@restore="confirmRestore"
|
||||||
@delete="confirmDelete"
|
@delete="confirmDelete"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Пустое состояние -->
|
<!-- Пустое состояние -->
|
||||||
<div v-if="filteredCards.length === 0 && !loading" class="empty-state">
|
<div v-if="filteredCards.length === 0 && !loading" class="empty-state" :class="{ mobile: isMobile }">
|
||||||
<i data-lucide="archive-x"></i>
|
<i data-lucide="archive-x"></i>
|
||||||
<p>Архив пуст</p>
|
<p>Архив пуст</p>
|
||||||
<span>Архивированные задачи появятся здесь</span>
|
<span>Архивированные задачи появятся здесь</span>
|
||||||
@@ -91,71 +64,63 @@
|
|||||||
<Loader v-if="loading" />
|
<Loader v-if="loading" />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- Модальные окна -->
|
||||||
|
<template #modals>
|
||||||
|
<TaskPanel
|
||||||
|
:show="panelOpen"
|
||||||
|
:card="editingCard"
|
||||||
|
:column-id="null"
|
||||||
|
:is-archived="true"
|
||||||
|
:on-save="handleSaveTask"
|
||||||
|
@close="closePanel"
|
||||||
|
@delete="handleDeleteTask"
|
||||||
|
@restore="handleRestoreFromPanel"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Панель редактирования задачи -->
|
<ConfirmDialog
|
||||||
<TaskPanel
|
:show="confirmDialogOpen"
|
||||||
:show="panelOpen"
|
type="deleteTask"
|
||||||
:card="editingCard"
|
:action="handleConfirmDelete"
|
||||||
:column-id="null"
|
@confirm="confirmDialogOpen = false"
|
||||||
:is-archived="true"
|
@cancel="confirmDialogOpen = false"
|
||||||
:departments="store.departments"
|
/>
|
||||||
:labels="store.labels"
|
|
||||||
:users="store.users"
|
|
||||||
:current-user-id="store.currentUserId"
|
|
||||||
:is-project-admin="store.isProjectAdmin"
|
|
||||||
:on-save="handleSaveTask"
|
|
||||||
@close="closePanel"
|
|
||||||
@delete="handleDeleteTask"
|
|
||||||
@restore="handleRestoreFromPanel"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Диалог подтверждения удаления -->
|
<ConfirmDialog
|
||||||
<ConfirmDialog
|
:show="restoreDialogOpen"
|
||||||
:show="confirmDialogOpen"
|
type="restore"
|
||||||
title="Удалить задачу?"
|
:action="handleConfirmRestore"
|
||||||
message="Задача будет удалена безвозвратно. Это действие нельзя отменить."
|
@confirm="restoreDialogOpen = false"
|
||||||
confirm-text="Удалить"
|
@cancel="restoreDialogOpen = false"
|
||||||
variant="danger"
|
/>
|
||||||
:is-loading="isDeleting"
|
</template>
|
||||||
@confirm="handleConfirmDelete"
|
</PageLayout>
|
||||||
@cancel="confirmDialogOpen = false"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import Sidebar from '../components/Sidebar.vue'
|
import PageLayout from '../components/PageLayout.vue'
|
||||||
import Header from '../components/Header.vue'
|
import Header from '../components/Header.vue'
|
||||||
import ArchiveCard from '../components/ArchiveCard.vue'
|
import ArchiveCard from '../components/ArchiveCard.vue'
|
||||||
import TaskPanel from '../components/TaskPanel'
|
import TaskPanel from '../components/TaskPanel'
|
||||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||||
|
import DepartmentTags from '../components/DepartmentTags.vue'
|
||||||
import ProjectSelector from '../components/ProjectSelector.vue'
|
import ProjectSelector from '../components/ProjectSelector.vue'
|
||||||
import MobileSelect from '../components/ui/MobileSelect.vue'
|
import MobileSelect from '../components/ui/MobileSelect.vue'
|
||||||
import Loader from '../components/ui/Loader.vue'
|
import Loader from '../components/ui/Loader.vue'
|
||||||
import { useProjectsStore } from '../stores/projects'
|
import { useProjectsStore } from '../stores/projects'
|
||||||
import { cardsApi } from '../api'
|
import { cardsApi } from '../api'
|
||||||
import { useMobile } from '../composables/useMobile'
|
import { useMobile } from '../composables/useMobile'
|
||||||
|
import { useDepartmentFilter } from '../composables/useDepartmentFilter'
|
||||||
|
|
||||||
const { isMobile } = useMobile()
|
const { isMobile } = useMobile()
|
||||||
|
|
||||||
// ==================== СОСТОЯНИЯ ЗАГРУЗКИ ====================
|
|
||||||
const isRestoring = ref(false)
|
|
||||||
const isDeleting = ref(false)
|
|
||||||
|
|
||||||
// ==================== STORE ====================
|
// ==================== STORE ====================
|
||||||
const store = useProjectsStore()
|
const store = useProjectsStore()
|
||||||
|
|
||||||
// ==================== MOBILE ====================
|
// ==================== ФИЛЬТР ПО ОТДЕЛАМ ====================
|
||||||
const departmentOptions = computed(() => [
|
const { activeDepartment, departmentOptions, resetFilter } = useDepartmentFilter()
|
||||||
{ id: null, label: 'Все отделы' },
|
|
||||||
...store.departments.map(d => ({
|
|
||||||
id: d.id,
|
|
||||||
label: d.name_departments
|
|
||||||
}))
|
|
||||||
])
|
|
||||||
|
|
||||||
// ==================== КАРТОЧКИ ====================
|
// ==================== КАРТОЧКИ ====================
|
||||||
const cards = ref([])
|
const cards = ref([])
|
||||||
@@ -215,22 +180,10 @@ const fetchCards = async () => {
|
|||||||
|
|
||||||
// При смене проекта
|
// При смене проекта
|
||||||
const onProjectChange = async () => {
|
const onProjectChange = async () => {
|
||||||
activeDepartment.value = null
|
resetFilter()
|
||||||
await fetchCards()
|
await fetchCards()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== ФИЛЬТР ПО ОТДЕЛАМ ====================
|
|
||||||
const savedDepartment = localStorage.getItem('activeDepartment')
|
|
||||||
const activeDepartment = ref(savedDepartment ? parseInt(savedDepartment) : null)
|
|
||||||
|
|
||||||
watch(activeDepartment, (newVal) => {
|
|
||||||
if (newVal === null) {
|
|
||||||
localStorage.removeItem('activeDepartment')
|
|
||||||
} else {
|
|
||||||
localStorage.setItem('activeDepartment', newVal.toString())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ==================== ПАНЕЛЬ РЕДАКТИРОВАНИЯ ====================
|
// ==================== ПАНЕЛЬ РЕДАКТИРОВАНИЯ ====================
|
||||||
const panelOpen = ref(false)
|
const panelOpen = ref(false)
|
||||||
const editingCard = ref(null)
|
const editingCard = ref(null)
|
||||||
@@ -291,39 +244,49 @@ const confirmDelete = (cardId) => {
|
|||||||
confirmDialogOpen.value = true
|
confirmDialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleConfirmDelete = async () => {
|
// ==================== ВОССТАНОВЛЕНИЕ С ПОДТВЕРЖДЕНИЕМ ====================
|
||||||
if (isDeleting.value || !cardToDelete.value) return
|
const restoreDialogOpen = ref(false)
|
||||||
|
const cardToRestore = ref(null)
|
||||||
|
|
||||||
isDeleting.value = true
|
const confirmRestore = (cardId) => {
|
||||||
try {
|
cardToRestore.value = cardId
|
||||||
const result = await cardsApi.delete(cardToDelete.value)
|
restoreDialogOpen.value = true
|
||||||
if (result.success) {
|
|
||||||
cards.value = cards.value.filter(c => c.id !== cardToDelete.value)
|
|
||||||
}
|
|
||||||
confirmDialogOpen.value = false
|
|
||||||
cardToDelete.value = null
|
|
||||||
} finally {
|
|
||||||
isDeleting.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== ВОССТАНОВЛЕНИЕ ====================
|
const handleConfirmDelete = async () => {
|
||||||
const handleRestore = async (cardId) => {
|
if (!cardToDelete.value) {
|
||||||
if (isRestoring.value) return
|
throw new Error('Задача не выбрана')
|
||||||
|
|
||||||
isRestoring.value = true
|
|
||||||
try {
|
|
||||||
const result = await cardsApi.setArchive(cardId, 0)
|
|
||||||
if (result.success) {
|
|
||||||
cards.value = cards.value.filter(c => c.id !== cardId)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isRestoring.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await cardsApi.delete(cardToDelete.value)
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Ошибка удаления задачи')
|
||||||
|
}
|
||||||
|
|
||||||
|
cards.value = cards.value.filter(c => c.id !== cardToDelete.value)
|
||||||
|
cardToDelete.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ВОССТАНОВЛЕНИЕ (действие) ====================
|
||||||
|
const handleConfirmRestore = async () => {
|
||||||
|
if (!cardToRestore.value) {
|
||||||
|
throw new Error('Задача не выбрана')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await cardsApi.setArchive(cardToRestore.value, 0)
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Ошибка восстановления задачи')
|
||||||
|
}
|
||||||
|
|
||||||
|
cards.value = cards.value.filter(c => c.id !== cardToRestore.value)
|
||||||
|
cardToRestore.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRestoreFromPanel = async (cardId) => {
|
const handleRestoreFromPanel = async (cardId) => {
|
||||||
await handleRestore(cardId)
|
const result = await cardsApi.setArchive(cardId, 0)
|
||||||
|
if (result.success) {
|
||||||
|
cards.value = cards.value.filter(c => c.id !== cardId)
|
||||||
|
}
|
||||||
closePanel()
|
closePanel()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,98 +300,16 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Контейнер приложения */
|
/* Специфичные стили для архива (вертикальный скролл) */
|
||||||
.app {
|
|
||||||
display: flex;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Основная область контента */
|
|
||||||
.main-wrapper {
|
|
||||||
flex: 1;
|
|
||||||
margin-left: 64px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--bg-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Контейнер фильтров */
|
|
||||||
.filters {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Разделитель между проектом и отделами */
|
|
||||||
.filter-divider {
|
|
||||||
width: 1px;
|
|
||||||
height: 24px;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
margin: 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Кнопка фильтра */
|
|
||||||
.filter-tag {
|
|
||||||
padding: 6px 12px;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-tag:hover {
|
|
||||||
background: var(--bg-card-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Активный фильтр */
|
|
||||||
.filter-tag.active {
|
|
||||||
background: var(--accent);
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Блок статистики */
|
|
||||||
.header-stats {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 10px 20px;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Основная область */
|
|
||||||
.main {
|
.main {
|
||||||
flex: 1;
|
|
||||||
padding: 0 36px 36px;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Мобильный архив — с padding */
|
||||||
|
.main.mobile {
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Список архивных карточек */
|
/* Список архивных карточек */
|
||||||
.archive-list {
|
.archive-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -467,39 +348,12 @@ onMounted(async () => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== MOBILE ========== */
|
/* ========== MOBILE: Архив ========== */
|
||||||
.app.mobile {
|
.archive-list.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 */
|
/* 60px header + 40px title + 64px nav + safe-area */
|
||||||
max-height: calc(100dvh - 60px - 40px - 64px - var(--safe-area-bottom, 0px));
|
max-height: calc(100dvh - 60px - 40px - 64px - var(--safe-area-bottom, 0px));
|
||||||
|
max-width: none;
|
||||||
|
gap: 12px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
@@ -507,20 +361,15 @@ onMounted(async () => {
|
|||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app.mobile .archive-list::-webkit-scrollbar {
|
.archive-list.mobile::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app.mobile .archive-list {
|
.empty-state.mobile {
|
||||||
max-width: none;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app.mobile .empty-state {
|
|
||||||
padding: 40px 20px;
|
padding: 40px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app.mobile .empty-state i {
|
.empty-state.mobile i {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { authApi, cardsApi } from '../api'
|
import { authApi, cardsApi } from '../api'
|
||||||
import { useProjectsStore } from '../stores/projects'
|
import { useProjectsStore } from '../stores/projects'
|
||||||
import { useMobile } from '../composables/useMobile'
|
import { useMobile } from '../composables/useMobile'
|
||||||
|
import { setAuthCache } from '../router'
|
||||||
|
|
||||||
const { isMobile } = useMobile()
|
const { isMobile } = useMobile()
|
||||||
|
|
||||||
@@ -159,6 +160,9 @@ const handleLogin = async () => {
|
|||||||
const data = await authApi.login(login.value, password.value)
|
const data = await authApi.login(login.value, password.value)
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
|
// Устанавливаем кэш авторизации (чтобы навигация между страницами была мгновенной)
|
||||||
|
setAuthCache(true)
|
||||||
|
|
||||||
// Показываем анимацию успеха
|
// Показываем анимацию успеха
|
||||||
showSuccess.value = true
|
showSuccess.value = true
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|||||||
@@ -1,34 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app" :class="{ mobile: isMobile }">
|
<PageLayout>
|
||||||
<!-- Боковая панель навигации -->
|
|
||||||
<Sidebar />
|
|
||||||
|
|
||||||
<!-- Основной контент -->
|
|
||||||
<div class="main-wrapper">
|
|
||||||
<!-- Шапка с заголовком, фильтрами и статистикой -->
|
<!-- Шапка с заголовком, фильтрами и статистикой -->
|
||||||
<Header title="Доска задач">
|
<Header title="Доска задач">
|
||||||
<!-- Десктоп: фильтры в одну строку -->
|
<!-- Десктоп: фильтры в одну строку -->
|
||||||
<template #filters>
|
<template #filters>
|
||||||
<div class="filters">
|
<DepartmentTags
|
||||||
<ProjectSelector @change="onProjectChange" />
|
v-model="activeDepartment"
|
||||||
<div class="filter-divider"></div>
|
@project-change="onProjectChange"
|
||||||
<button
|
/>
|
||||||
class="filter-tag"
|
|
||||||
:class="{ active: activeDepartment === null }"
|
|
||||||
@click="activeDepartment = null"
|
|
||||||
>
|
|
||||||
Все
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-for="dept in store.departments"
|
|
||||||
:key="dept.id"
|
|
||||||
class="filter-tag"
|
|
||||||
:class="{ active: activeDepartment === dept.id }"
|
|
||||||
@click="activeDepartment = activeDepartment === dept.id ? null : dept.id"
|
|
||||||
>
|
|
||||||
{{ dept.name_departments }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Мобильный: Проект + Отделы -->
|
<!-- Мобильный: Проект + Отделы -->
|
||||||
@@ -66,66 +45,56 @@
|
|||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
<!-- Доска с колонками и карточками -->
|
<!-- Доска с колонками и карточками -->
|
||||||
<main class="main">
|
<main class="main" :class="{ mobile: isMobile }">
|
||||||
<Loader v-if="loading" />
|
<Loader v-if="loading" />
|
||||||
<Board
|
<Board
|
||||||
v-else
|
v-else
|
||||||
ref="boardRef"
|
ref="boardRef"
|
||||||
:active-department="activeDepartment"
|
:active-department="activeDepartment"
|
||||||
:departments="store.departments"
|
|
||||||
:labels="store.labels"
|
|
||||||
:columns="store.columns"
|
|
||||||
:cards="cards"
|
:cards="cards"
|
||||||
:done-column-id="store.doneColumnId"
|
|
||||||
@stats-updated="stats = $event"
|
@stats-updated="stats = $event"
|
||||||
@open-task="openTaskPanel"
|
@open-task="openTaskPanel"
|
||||||
@create-task="openNewTaskPanel"
|
@create-task="openNewTaskPanel"
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Панель редактирования/создания задачи -->
|
<!-- Модальные окна -->
|
||||||
<TaskPanel
|
<template #modals>
|
||||||
:show="panelOpen"
|
<TaskPanel
|
||||||
:card="editingCard"
|
:show="panelOpen"
|
||||||
:column-id="editingColumnId"
|
:card="editingCard"
|
||||||
:done-column-id="store.doneColumnId"
|
:column-id="editingColumnId"
|
||||||
:departments="store.departments"
|
:on-save="handleSaveTask"
|
||||||
:labels="store.labels"
|
:on-archive="handleArchiveTask"
|
||||||
:users="store.users"
|
@close="closePanel"
|
||||||
:current-user-id="store.currentUserId"
|
@delete="handleDeleteTask"
|
||||||
:is-project-admin="store.isProjectAdmin"
|
/>
|
||||||
:on-save="handleSaveTask"
|
</template>
|
||||||
@close="closePanel"
|
</PageLayout>
|
||||||
@delete="handleDeleteTask"
|
|
||||||
@archive="handleArchiveTask"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import Sidebar from '../components/Sidebar.vue'
|
import PageLayout from '../components/PageLayout.vue'
|
||||||
import Header from '../components/Header.vue'
|
import Header from '../components/Header.vue'
|
||||||
import Board from '../components/Board.vue'
|
import Board from '../components/Board.vue'
|
||||||
import TaskPanel from '../components/TaskPanel'
|
import TaskPanel from '../components/TaskPanel'
|
||||||
|
import DepartmentTags from '../components/DepartmentTags.vue'
|
||||||
import ProjectSelector from '../components/ProjectSelector.vue'
|
import ProjectSelector from '../components/ProjectSelector.vue'
|
||||||
import MobileSelect from '../components/ui/MobileSelect.vue'
|
import MobileSelect from '../components/ui/MobileSelect.vue'
|
||||||
import Loader from '../components/ui/Loader.vue'
|
import Loader from '../components/ui/Loader.vue'
|
||||||
import { useProjectsStore } from '../stores/projects'
|
import { useProjectsStore } from '../stores/projects'
|
||||||
import { cardsApi } from '../api'
|
import { cardsApi } from '../api'
|
||||||
import { useMobile } from '../composables/useMobile'
|
import { useMobile } from '../composables/useMobile'
|
||||||
|
import { useDepartmentFilter } from '../composables/useDepartmentFilter'
|
||||||
|
|
||||||
const { isMobile } = useMobile()
|
const { isMobile } = useMobile()
|
||||||
|
|
||||||
// ==================== STORE ====================
|
// ==================== STORE ====================
|
||||||
const store = useProjectsStore()
|
const store = useProjectsStore()
|
||||||
|
|
||||||
// ==================== МОБИЛЬНЫЕ СЕЛЕКТОРЫ ====================
|
// ==================== ФИЛЬТР ПО ОТДЕЛАМ ====================
|
||||||
const departmentOptions = computed(() => [
|
const { activeDepartment, departmentOptions, resetFilter } = useDepartmentFilter()
|
||||||
{ id: null, label: 'Все отделы' },
|
|
||||||
...store.departments.map(d => ({ id: d.id, label: d.name_departments }))
|
|
||||||
])
|
|
||||||
|
|
||||||
// ==================== КАРТОЧКИ ====================
|
// ==================== КАРТОЧКИ ====================
|
||||||
const cards = ref([])
|
const cards = ref([])
|
||||||
@@ -153,23 +122,11 @@ const fetchCards = async (silent = false) => {
|
|||||||
|
|
||||||
// При смене проекта — перезагружаем карточки
|
// При смене проекта — перезагружаем карточки
|
||||||
const onProjectChange = async () => {
|
const onProjectChange = async () => {
|
||||||
activeDepartment.value = null
|
resetFilter()
|
||||||
loading.value = true
|
loading.value = true
|
||||||
await fetchCards()
|
await fetchCards()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== ФИЛЬТР ПО ОТДЕЛАМ ====================
|
|
||||||
const savedDepartment = localStorage.getItem('activeDepartment')
|
|
||||||
const activeDepartment = ref(savedDepartment ? parseInt(savedDepartment) : null)
|
|
||||||
|
|
||||||
watch(activeDepartment, (newVal) => {
|
|
||||||
if (newVal === null) {
|
|
||||||
localStorage.removeItem('activeDepartment')
|
|
||||||
} else {
|
|
||||||
localStorage.setItem('activeDepartment', newVal.toString())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ==================== СТАТИСТИКА ====================
|
// ==================== СТАТИСТИКА ====================
|
||||||
const stats = ref({ total: 0, inProgress: 0, done: 0 })
|
const stats = ref({ total: 0, inProgress: 0, done: 0 })
|
||||||
|
|
||||||
@@ -264,171 +221,14 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Контейнер приложения */
|
/* Специфичные стили для доски (горизонтальный скролл) */
|
||||||
.app {
|
|
||||||
display: flex;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Основная область контента */
|
|
||||||
.main-wrapper {
|
|
||||||
flex: 1;
|
|
||||||
margin-left: 64px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--bg-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== MOBILE ========== */
|
|
||||||
.app.mobile {
|
|
||||||
height: 100vh;
|
|
||||||
height: 100dvh; /* Динамическая высота для iOS */
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app.mobile .main-wrapper {
|
|
||||||
margin-left: 0;
|
|
||||||
padding-bottom: calc(64px + var(--safe-area-bottom, 0px)); /* место для нижней навигации + safe area */
|
|
||||||
height: 100vh;
|
|
||||||
height: 100dvh; /* Динамическая высота для iOS */
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Контейнер фильтров */
|
|
||||||
.filters {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* На мобильных — горизонтальный скролл */
|
|
||||||
.app.mobile .filters {
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
overflow-x: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
scrollbar-width: none;
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app.mobile .filters::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Разделитель между проектом и отделами */
|
|
||||||
.filter-divider {
|
|
||||||
width: 1px;
|
|
||||||
height: 24px;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
margin: 0 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Кнопка фильтра */
|
|
||||||
.filter-tag {
|
|
||||||
padding: 6px 12px;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
flex-shrink: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-tag:hover {
|
|
||||||
background: var(--bg-card-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Активный фильтр */
|
|
||||||
.filter-tag.active {
|
|
||||||
background: var(--accent);
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Блок статистики */
|
|
||||||
.header-stats {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 10px 20px;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Разделитель между статами */
|
|
||||||
.stat-divider {
|
|
||||||
width: 1px;
|
|
||||||
height: 16px;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== MOBILE: статистика ========== */
|
|
||||||
.app.mobile .header-stats {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Основная область с доской */
|
|
||||||
.main {
|
.main {
|
||||||
flex: 1;
|
|
||||||
padding: 0 36px 36px;
|
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Стилизация горизонтального скроллбара */
|
/* Мобильная доска — без padding, Board сам управляет layout */
|
||||||
.main::-webkit-scrollbar {
|
.main.mobile {
|
||||||
height: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main::-webkit-scrollbar-track {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 5px;
|
|
||||||
margin: 0 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(0, 212, 170, 0.4);
|
|
||||||
border-radius: 5px;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
background-clip: padding-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(0, 212, 170, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== MOBILE: доска ========== */
|
|
||||||
.app.mobile .main {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0; /* Важно для flex children с overflow */
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user