1
0
Files
TaskBoard/front_vue/src/components/Card.vue
Falknat 5018a2d123 Мобильная версия
1. Адаптация и разработка мобильного варианта.
2026-01-15 09:33:16 +07:00

363 lines
8.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<template>
<div
class="card"
draggable="true"
@dragstart="handleDragStart"
@dragend="handleDragEnd"
:class="{ dragging: isDragging, 'has-label-color': cardLabelColor }"
:style="cardLabelColor ? { '--label-bg': cardLabelColor } : {}"
>
<div class="card-header">
<div class="tag-group">
<span v-if="cardLabel" class="label-icon">{{ cardLabel.icon }}</span>
<span
v-if="cardDepartment"
class="tag"
:style="{ color: cardDepartment.color }"
>
{{ cardDepartment.name_departments }}
</span>
</div>
<div class="header-right">
<button
v-if="canArchive"
class="btn-archive-card"
:class="{ 'always-visible': isMobile }"
@click.stop="handleArchive"
title="В архив"
>
<i data-lucide="archive"></i>
</button>
<span v-if="card.files && card.files.length" class="files-indicator" :title="card.files.length + ' изображений'">
<i data-lucide="image-plus"></i>
</span>
<div v-if="card.assignee" class="assignee">
<img
v-if="isAvatarUrl(card.assignee)"
:src="getFullUrl(card.assignee)"
alt="avatar"
class="assignee-img"
/>
<span v-else>{{ card.assignee }}</span>
</div>
</div>
</div>
<h3 class="card-title">{{ card.title }}</h3>
<p v-if="card.description" class="card-description">
{{ card.description }}
</p>
<div class="card-footer">
<span v-if="card.dateCreate" class="date-create">
Создано: {{ formatDateWithYear(card.dateCreate) }}
</span>
<span v-if="card.dueDate && Number(columnId) !== doneColumnId" class="due-date" :class="dueDateStatus">
{{ daysLeftText }}
</span>
<span v-if="doneColumnId && Number(columnId) === doneColumnId && card.dateClosed" class="date-closed">
Закрыто: {{ closedDateText }}
</span>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUpdated } from 'vue'
import { getFullUrl } from '../api'
import { useMobile } from '../composables/useMobile'
const { isMobile } = useMobile()
const props = defineProps({
card: Object,
columnId: [String, Number],
doneColumnId: Number,
index: Number,
departments: {
type: Array,
default: () => []
},
labels: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['archive'])
const refreshIcons = () => {
if (window.lucide) {
window.lucide.createIcons()
}
}
onMounted(refreshIcons)
onUpdated(refreshIcons)
const isDragging = ref(false)
const handleDragStart = (e) => {
isDragging.value = true
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('cardId', props.card.id.toString())
e.dataTransfer.setData('columnId', props.columnId)
}
const handleDragEnd = () => {
isDragging.value = false
}
// Получаем отдел по id
const cardDepartment = computed(() => {
if (!props.card.departmentId) return null
return props.departments.find(d => d.id === props.card.departmentId) || null
})
// Получаем лейбл по id
const cardLabel = computed(() => {
if (!props.card.labelId) return null
return props.labels.find(l => l.id === props.card.labelId) || null
})
// Цвет лейбла для фона карточки
const cardLabelColor = computed(() => {
return cardLabel.value?.color || null
})
const formatDateWithYear = (dateStr) => {
const date = new Date(dateStr)
const day = date.getDate()
const months = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
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) => {
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(() => {
return props.doneColumnId && Number(props.columnId) === props.doneColumnId
})
const handleArchive = () => {
emit('archive', props.card.id)
}
</script>
<style scoped>
.card {
background: var(--bg-card);
border-radius: 12px;
padding: 14px 16px;
cursor: grab;
transition: all 0.2s;
}
.card:hover {
background: var(--bg-card-hover);
}
.card.has-label-color {
background: color-mix(in srgb, var(--label-bg) 15%, var(--bg-card));
border-left: 3px solid var(--label-bg);
}
.card.has-label-color:hover {
background: color-mix(in srgb, var(--label-bg) 20%, var(--bg-card-hover));
}
.card.dragging {
opacity: 0.4;
cursor: grabbing;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.tag-group {
display: flex;
align-items: center;
gap: 6px;
}
.label-icon {
font-size: 14px;
}
.tag {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.assignee {
width: 24px;
height: 24px;
background: var(--blue);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 8px;
font-weight: 600;
color: white;
overflow: hidden;
}
.assignee-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.card-title {
font-size: 14px;
font-weight: 500;
line-height: 1.5;
color: var(--text-primary);
}
.card-description {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
margin-top: 8px;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.date-create {
font-size: 11px;
color: var(--text-muted);
opacity: 0.7;
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.files-indicator {
display: flex;
align-items: center;
color: var(--text-muted);
}
.files-indicator i {
width: 14px;
height: 14px;
}
.due-date {
font-size: 11px;
color: var(--text-muted);
}
.due-date.soon {
color: var(--orange);
}
.due-date.overdue {
color: var(--red);
}
.date-closed {
font-size: 11px;
color: var(--green, #00d4aa);
}
.btn-archive-card {
display: none;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
}
.btn-archive-card i,
.btn-archive-card svg {
width: 18px;
height: 18px;
}
.card:hover .btn-archive-card {
display: flex;
}
.btn-archive-card:hover {
background: var(--orange);
color: #000;
}
/* Mobile: всегда показываем кнопку архивирования */
.btn-archive-card.always-visible {
display: flex;
}
</style>