1
0

Исправление ошибок фронта

Правим фронт от ошибок
This commit is contained in:
2026-01-15 15:27:39 +07:00
parent 6f35b84725
commit 7e1482f515
11 changed files with 513 additions and 60 deletions

1
.gitignore vendored
View File

@@ -11,3 +11,4 @@ backend/public/*
deploy.js deploy.js
deploy.php deploy.php
.vscode .vscode
.cursorrules

View File

@@ -12,6 +12,7 @@
@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="archiveTask"
@move-request="handleMoveRequest"
/> />
</div> </div>
@@ -29,17 +30,59 @@
></button> ></button>
</div> </div>
</div> </div>
<!-- Мобильная панель перемещения карточки -->
<MoveCardPanel
:open="movePanel.open"
:card-id="movePanel.cardId"
:card-title="movePanel.cardTitle"
:current-column-id="movePanel.columnId"
:columns="movePanelColumns"
@close="closeMovePanel"
@move="handleDropCard"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
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 { cardsApi } from '../api' import { cardsApi } from '../api'
import { useMobile } from '../composables/useMobile' import { useMobile } from '../composables/useMobile'
const { isMobile } = useMobile() const { isMobile } = useMobile()
// Состояние для мобильной панели перемещения
const movePanel = ref({
open: false,
cardId: null,
cardTitle: '',
columnId: null
})
const handleMoveRequest = ({ cardId, cardTitle, columnId }) => {
movePanel.value = {
open: true,
cardId,
cardTitle,
columnId
}
}
const closeMovePanel = () => {
movePanel.value.open = false
}
// Колонки для панели перемещения (только id, title, color)
const movePanelColumns = computed(() => {
return props.columns.map(col => ({
id: col.id,
title: col.name_columns,
color: col.color
}))
})
// Мобильный свайп // Мобильный свайп
const columnsRef = ref(null) const columnsRef = ref(null)
const currentColumnIndex = ref(0) const currentColumnIndex = ref(0)

View File

@@ -1,10 +1,14 @@
<template> <template>
<div <div
class="card" class="card"
draggable="true" :draggable="!isMobile"
@dragstart="handleDragStart" @dragstart="handleDragStart"
@dragend="handleDragEnd" @dragend="handleDragEnd"
:class="{ dragging: isDragging, 'has-label-color': cardLabelColor }" @touchstart="handleTouchStart"
@touchend="handleTouchEnd"
@touchmove="handleTouchMove"
@contextmenu="handleContextMenu"
:class="{ dragging: isDragging, 'has-label-color': cardLabelColor, 'long-pressing': isLongPressing }"
:style="cardLabelColor ? { '--label-bg': cardLabelColor } : {}" :style="cardLabelColor ? { '--label-bg': cardLabelColor } : {}"
> >
<div class="card-header"> <div class="card-header">
@@ -85,7 +89,65 @@ const props = defineProps({
} }
}) })
const emit = defineEmits(['archive']) const emit = defineEmits(['archive', 'move-request'])
// Long-press для мобильной версии
const isLongPressing = ref(false)
let longPressTimer = null
let touchStartPos = { x: 0, y: 0 }
const handleTouchStart = (e) => {
if (!isMobile.value) return
const touch = e.touches[0]
touchStartPos = { x: touch.clientX, y: touch.clientY }
longPressTimer = setTimeout(() => {
isLongPressing.value = true
// Предотвращаем стандартное поведение (выделение текста, контекстное меню)
e.preventDefault()
// Вибрация если поддерживается
if (navigator.vibrate) {
navigator.vibrate(30)
}
// Эмитим запрос на перемещение
emit('move-request', {
cardId: props.card.id,
cardTitle: props.card.title,
columnId: props.columnId
})
isLongPressing.value = false
}, 500) // 500ms для long-press
}
const handleTouchMove = (e) => {
if (!longPressTimer) return
const touch = e.touches[0]
const moveX = Math.abs(touch.clientX - touchStartPos.x)
const moveY = Math.abs(touch.clientY - touchStartPos.y)
// Если сдвинулись больше 10px - отменяем long-press
if (moveX > 10 || moveY > 10) {
clearTimeout(longPressTimer)
longPressTimer = null
}
}
const handleTouchEnd = () => {
if (longPressTimer) {
clearTimeout(longPressTimer)
longPressTimer = null
}
isLongPressing.value = false
}
// Блокируем контекстное меню на мобильных
const handleContextMenu = (e) => {
if (isMobile.value) {
e.preventDefault()
}
}
const refreshIcons = () => { const refreshIcons = () => {
if (window.lucide) { if (window.lucide) {
@@ -196,6 +258,10 @@ const handleArchive = () => {
padding: 14px 16px; padding: 14px 16px;
cursor: grab; cursor: grab;
transition: all 0.2s; transition: all 0.2s;
/* Отключаем выделение текста на мобильных при long-press */
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
} }
.card:hover { .card:hover {
@@ -216,6 +282,11 @@ const handleArchive = () => {
cursor: grabbing; cursor: grabbing;
} }
.card.long-pressing {
transform: scale(0.98);
box-shadow: 0 0 0 2px var(--accent);
}
.card-header { .card-header {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -32,6 +32,7 @@
:labels="labels" :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)"
/> />
</template> </template>
@@ -62,7 +63,7 @@ const props = defineProps({
} }
}) })
const emit = defineEmits(['drop-card', 'open-task', 'create-task', 'archive-task']) const emit = defineEmits(['drop-card', 'open-task', 'create-task', 'archive-task', 'move-request'])
const refreshIcons = () => { const refreshIcons = () => {
if (window.lucide) { if (window.lucide) {

View File

@@ -19,8 +19,10 @@
class="btn-confirm" class="btn-confirm"
:class="variant" :class="variant"
@click="handleConfirm" @click="handleConfirm"
:disabled="isLoading"
> >
{{ confirmText }} <span v-if="isLoading" class="btn-loader"></span>
<span v-else>{{ confirmText }}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -62,6 +64,11 @@ const props = defineProps({
variant: { variant: {
type: String, type: String,
default: 'default' default: 'default'
},
// Состояние загрузки (блокирует кнопку подтверждения)
isLoading: {
type: Boolean,
default: false
} }
}) })
@@ -179,6 +186,27 @@ const handleDiscard = () => {
background: #d97706; background: #d97706;
} }
.btn-confirm:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.btn-loader {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Transition */ /* Transition */
.dialog-enter-active, .dialog-enter-active,
.dialog-leave-active { .dialog-leave-active {

View File

@@ -428,12 +428,13 @@ defineExpose({
} }
.btn-loader { .btn-loader {
width: 16px; display: inline-block;
height: 16px; width: 18px;
border: 2px solid rgba(0, 0, 0, 0.2); height: 18px;
border: 2px solid rgba(0, 0, 0, 0.3);
border-top-color: #000; border-top-color: #000;
border-radius: 50%; border-radius: 50%;
animation: spin 0.8s linear infinite; animation: spin 0.7s linear infinite;
} }
@keyframes spin { @keyframes spin {

View File

@@ -349,6 +349,8 @@ const stopRefresh = () => {
// Отправка комментария // Отправка комментария
const sendComment = async () => { const sendComment = async () => {
if (isSendingComment.value) return // Защита от повторного вызова
const hasText = newCommentText.value.trim() const hasText = newCommentText.value.trim()
const files = commentFormRef.value?.getFiles() || [] const files = commentFormRef.value?.getFiles() || []
const hasFiles = files.length > 0 const hasFiles = files.length > 0
@@ -524,6 +526,7 @@ const removeNewFile = (index) => {
} }
const saveEditPanel = async () => { const saveEditPanel = async () => {
if (isSavingEdit.value) return // Защита от повторного вызова
if (!editingCommentText.value.trim() || !editingCommentId.value) return if (!editingCommentText.value.trim() || !editingCommentId.value) return
isSavingEdit.value = true isSavingEdit.value = true
@@ -1041,11 +1044,12 @@ defineExpose({
} }
.btn-loader { .btn-loader {
width: 16px; display: inline-block;
height: 16px; width: 18px;
border: 2px solid rgba(0, 0, 0, 0.2); height: 18px;
border: 2px solid rgba(0, 0, 0, 0.3);
border-top-color: #000; border-top-color: #000;
border-radius: 50%; border-radius: 50%;
animation: spin 0.8s linear infinite; animation: spin 0.7s linear infinite;
} }
</style> </style>

View File

@@ -153,6 +153,9 @@ import { useMobile } from '../../composables/useMobile'
const { isMobile } = useMobile() const { isMobile } = useMobile()
// Состояние загрузки для кнопки сохранения
const isSaving = ref(false)
const props = defineProps({ const props = defineProps({
show: Boolean, show: Boolean,
card: Object, card: Object,
@@ -181,6 +184,11 @@ const props = defineProps({
isProjectAdmin: { isProjectAdmin: {
type: Boolean, type: Boolean,
default: false default: false
},
// Callback для сохранения (возвращает Promise)
onSave: {
type: Function,
default: null
} }
}) })
@@ -188,7 +196,6 @@ const emit = defineEmits(['close', 'save', 'delete', 'archive', 'restore'])
// State // State
const isNew = ref(true) const isNew = ref(true)
const isSaving = ref(false)
const activeTab = ref('edit') const activeTab = ref('edit')
const commentsCount = ref(0) const commentsCount = ref(0)
@@ -263,11 +270,12 @@ const cancelClose = () => {
// Save // Save
const handleSave = async () => { const handleSave = async () => {
if (!canSave.value) return if (!canSave.value || isSaving.value) return
isSaving.value = true isSaving.value = true
editTabRef.value.fileError = '' editTabRef.value.fileError = ''
try {
if (props.card?.id) { if (props.card?.id) {
// Upload new files // Upload new files
const newFiles = editTabRef.value.getNewFiles() const newFiles = editTabRef.value.getNewFiles()
@@ -279,7 +287,6 @@ const handleSave = async () => {
file.preview = result.file.url file.preview = result.file.url
} else { } else {
editTabRef.value.fileError = result.errors?.file || 'Ошибка загрузки файла' editTabRef.value.fileError = result.errors?.file || 'Ошибка загрузки файла'
isSaving.value = false
return return
} }
} }
@@ -291,7 +298,6 @@ const handleSave = async () => {
const result = await taskImageApi.delete(props.card.id, fileNames) const result = await taskImageApi.delete(props.card.id, fileNames)
if (!result.success) { if (!result.success) {
editTabRef.value.fileError = result.errors?.file || 'Ошибка удаления файла' editTabRef.value.fileError = result.errors?.file || 'Ошибка удаления файла'
isSaving.value = false
return return
} }
} }
@@ -300,12 +306,20 @@ const handleSave = async () => {
} }
const formData = editTabRef.value.getFormData() const formData = editTabRef.value.getFormData()
emit('save', { const taskData = {
...formData, ...formData,
id: props.card?.id id: props.card?.id
}) }
// Вызываем callback и ждём завершения
if (props.onSave) {
await props.onSave(taskData)
} else {
emit('save', taskData)
}
} finally {
isSaving.value = false isSaving.value = false
}
} }
// Delete // Delete
@@ -397,6 +411,7 @@ watch(() => props.show, async (newVal) => {
activeTab.value = 'edit' activeTab.value = 'edit'
commentsCount.value = props.card?.comments_count || 0 commentsCount.value = props.card?.comments_count || 0
previewImage.value = null previewImage.value = null
isSaving.value = false // Сброс состояния кнопки сохранения
// Reset comments tab // Reset comments tab
commentsTabRef.value?.reset() commentsTabRef.value?.reset()
@@ -466,6 +481,10 @@ onUpdated(refreshIcons)
} }
.btn-save { .btn-save {
display: flex;
align-items: center;
justify-content: center;
min-width: 120px;
padding: 12px 28px; padding: 12px 28px;
background: var(--accent); background: var(--accent);
border: none; border: none;
@@ -488,12 +507,13 @@ onUpdated(refreshIcons)
} }
.btn-loader { .btn-loader {
width: 16px; display: inline-block;
height: 16px; width: 18px;
border: 2px solid rgba(0, 0, 0, 0.2); height: 18px;
border: 2px solid rgba(0, 0, 0, 0.3);
border-top-color: #000; border-top-color: #000;
border-radius: 50%; border-radius: 50%;
animation: spin 0.8s linear infinite; animation: spin 0.7s linear infinite;
} }
@keyframes spin { @keyframes spin {

View File

@@ -0,0 +1,266 @@
<template>
<!-- Fullscreen панель выбора колонки -->
<Teleport to="body">
<Transition name="slide-up">
<div v-if="open" class="move-card-overlay" @click.self="close">
<div class="move-card-panel">
<!-- Header панели -->
<div class="panel-header">
<button class="panel-back" @click="close">
<i data-lucide="x"></i>
</button>
<h3 class="panel-title">Переместить в</h3>
</div>
<!-- Информация о карточке -->
<div class="card-info" v-if="cardTitle">
<span class="card-title">{{ cardTitle }}</span>
</div>
<!-- Список колонок -->
<div class="panel-options">
<button
v-for="column in columns"
:key="column.id"
class="option-item"
:class="{ active: currentColumnId === column.id }"
@click="selectColumn(column.id)"
>
<span class="column-dot" :style="{ background: column.color }"></span>
<span class="option-label">{{ column.title }}</span>
<span v-if="currentColumnId === column.id" class="current-badge">текущая</span>
<i v-else data-lucide="arrow-right" class="option-arrow"></i>
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { watch, onMounted, onUpdated } from 'vue'
const props = defineProps({
open: {
type: Boolean,
default: false
},
cardId: {
type: [Number, String],
default: null
},
cardTitle: {
type: String,
default: ''
},
currentColumnId: {
type: [Number, String],
default: null
},
columns: {
type: Array,
default: () => []
// [{ id: 1, title: 'Бэклог', color: '#fff' }, ...]
}
})
const emit = defineEmits(['close', 'move'])
const close = () => {
emit('close')
}
const selectColumn = (columnId) => {
if (columnId !== props.currentColumnId) {
emit('move', {
cardId: props.cardId,
fromColumnId: props.currentColumnId,
toColumnId: columnId,
toIndex: 0 // В начало колонки
})
}
close()
}
// Refresh icons
const refreshIcons = () => {
if (window.lucide) {
setTimeout(() => window.lucide.createIcons(), 10)
}
}
watch(() => props.open, (val) => {
if (val) {
document.body.style.overflow = 'hidden'
refreshIcons()
} else {
document.body.style.overflow = ''
}
})
onMounted(refreshIcons)
onUpdated(refreshIcons)
</script>
<style scoped>
/* Overlay */
.move-card-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 1100;
display: flex;
align-items: flex-end;
}
/* Панель */
.move-card-panel {
width: 100%;
max-height: 70vh;
background: var(--bg-body);
border-radius: 20px 20px 0 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.panel-back {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: 8px;
transition: all 0.15s;
}
.panel-back:active {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary);
}
.panel-back i {
width: 20px;
height: 20px;
}
.panel-title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
/* Информация о карточке */
.card-info {
padding: 12px 20px;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.card-title {
font-size: 14px;
color: var(--text-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Опции */
.panel-options {
flex: 1;
overflow-y: auto;
padding: 8px;
padding-bottom: max(16px, env(safe-area-inset-bottom));
}
.option-item {
display: flex;
align-items: center;
gap: 12px;
width: calc(100% + 16px);
margin-left: -8px;
margin-right: -8px;
padding: 16px 16px;
background: none;
border: none;
border-radius: 0;
color: var(--text-secondary);
font-family: inherit;
font-size: 16px;
text-align: left;
cursor: pointer;
transition: all 0.15s;
}
.option-item:active {
background: rgba(255, 255, 255, 0.08);
}
.option-item.active {
background: var(--accent-soft);
color: var(--accent);
}
.column-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.option-label {
flex: 1;
}
.current-badge {
font-size: 12px;
color: var(--text-muted);
background: rgba(255, 255, 255, 0.1);
padding: 4px 8px;
border-radius: 6px;
}
.option-arrow {
width: 18px;
height: 18px;
color: var(--text-muted);
opacity: 0.5;
}
/* Анимация */
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.3s ease;
}
.slide-up-enter-active .move-card-panel,
.slide-up-leave-active .move-card-panel {
transition: transform 0.3s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
opacity: 0;
}
.slide-up-enter-from .move-card-panel,
.slide-up-leave-to .move-card-panel {
transform: translateY(100%);
}
</style>

View File

@@ -108,8 +108,8 @@
:users="store.users" :users="store.users"
:current-user-id="store.currentUserId" :current-user-id="store.currentUserId"
:is-project-admin="store.isProjectAdmin" :is-project-admin="store.isProjectAdmin"
:on-save="handleSaveTask"
@close="closePanel" @close="closePanel"
@save="handleSaveTask"
@delete="handleDeleteTask" @delete="handleDeleteTask"
@restore="handleRestoreFromPanel" @restore="handleRestoreFromPanel"
/> />
@@ -120,6 +120,8 @@
title="Удалить задачу?" title="Удалить задачу?"
message="Задача будет удалена безвозвратно. Это действие нельзя отменить." message="Задача будет удалена безвозвратно. Это действие нельзя отменить."
confirm-text="Удалить" confirm-text="Удалить"
variant="danger"
:is-loading="isDeleting"
@confirm="handleConfirmDelete" @confirm="handleConfirmDelete"
@cancel="confirmDialogOpen = false" @cancel="confirmDialogOpen = false"
/> />
@@ -141,6 +143,10 @@ import { useMobile } from '../composables/useMobile'
const { isMobile } = useMobile() const { isMobile } = useMobile()
// ==================== СОСТОЯНИЯ ЗАГРУЗКИ ====================
const isRestoring = ref(false)
const isDeleting = ref(false)
// ==================== STORE ==================== // ==================== STORE ====================
const store = useProjectsStore() const store = useProjectsStore()
@@ -288,22 +294,34 @@ const confirmDelete = (cardId) => {
} }
const handleConfirmDelete = async () => { const handleConfirmDelete = async () => {
if (cardToDelete.value) { if (isDeleting.value || !cardToDelete.value) return
isDeleting.value = true
try {
const result = await cardsApi.delete(cardToDelete.value) const result = await cardsApi.delete(cardToDelete.value)
if (result.success) { if (result.success) {
cards.value = cards.value.filter(c => c.id !== cardToDelete.value) cards.value = cards.value.filter(c => c.id !== cardToDelete.value)
} }
}
confirmDialogOpen.value = false confirmDialogOpen.value = false
cardToDelete.value = null cardToDelete.value = null
} finally {
isDeleting.value = false
}
} }
// ==================== ВОССТАНОВЛЕНИЕ ==================== // ==================== ВОССТАНОВЛЕНИЕ ====================
const handleRestore = async (cardId) => { const handleRestore = async (cardId) => {
if (isRestoring.value) return
isRestoring.value = true
try {
const result = await cardsApi.setArchive(cardId, 0) const result = await cardsApi.setArchive(cardId, 0)
if (result.success) { if (result.success) {
cards.value = cards.value.filter(c => c.id !== cardId) cards.value = cards.value.filter(c => c.id !== cardId)
} }
} finally {
isRestoring.value = false
}
} }
const handleRestoreFromPanel = async (cardId) => { const handleRestoreFromPanel = async (cardId) => {

View File

@@ -93,8 +93,8 @@
:users="store.users" :users="store.users"
:current-user-id="store.currentUserId" :current-user-id="store.currentUserId"
:is-project-admin="store.isProjectAdmin" :is-project-admin="store.isProjectAdmin"
:on-save="handleSaveTask"
@close="closePanel" @close="closePanel"
@save="handleSaveTask"
@delete="handleDeleteTask" @delete="handleDeleteTask"
@archive="handleArchiveTask" @archive="handleArchiveTask"
/> />