Фиксы...
This commit is contained in:
@@ -2,11 +2,11 @@
|
||||
window.APP_CONFIG = {
|
||||
API_BASE: 'http://192.168.1.6',
|
||||
|
||||
// Интервалы автообновления данных (в секундах)
|
||||
// Интервалы автообновления данных (в секундах, 0 = отключено)
|
||||
REFRESH_INTERVALS: {
|
||||
cards: 2, // Карточки на доске
|
||||
comments: 5, // Комментарии к задаче
|
||||
invites: 10 // Приглашения на странице без проектов
|
||||
invites: 5 // Приглашения (страница: задачи + страница без проектов)
|
||||
},
|
||||
|
||||
// Брейкпоинт для мобильной версии (px)
|
||||
|
||||
@@ -396,11 +396,15 @@ export const serverSettings = {
|
||||
parseDate(dateStr) {
|
||||
if (!dateStr) return null
|
||||
// Добавляем таймзону сервера для корректного парсинга
|
||||
const normalized = dateStr.replace(' ', 'T')
|
||||
let normalized = dateStr.replace(' ', 'T')
|
||||
// Если уже есть таймзона — не добавляем
|
||||
if (normalized.includes('+') || normalized.includes('Z')) {
|
||||
return new Date(normalized)
|
||||
}
|
||||
// Если нет времени (только дата YYYY-MM-DD) — добавляем 00:00:00
|
||||
if (normalized.length === 10) {
|
||||
normalized += 'T00:00:00'
|
||||
}
|
||||
return new Date(normalized + this.timezoneOffset)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,7 +321,7 @@ const saveTask = async (taskData, columnId) => {
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
// Добавляем локально с ID от сервера
|
||||
// Добавляем локально с данными от сервера
|
||||
localCards.value.push({
|
||||
id: parseInt(result.id),
|
||||
id_department: taskData.departmentId,
|
||||
@@ -332,8 +332,9 @@ const saveTask = async (taskData, columnId) => {
|
||||
descript_full: taskData.details,
|
||||
avatar_img: taskData.assignee,
|
||||
column_id: columnId,
|
||||
date: taskData.dueDate,
|
||||
date_create: new Date().toISOString().split('T')[0],
|
||||
date: result.date,
|
||||
date_create: result.date_create,
|
||||
date_closed: result.date_closed,
|
||||
order: maxOrder,
|
||||
files: result.files || []
|
||||
})
|
||||
|
||||
@@ -84,7 +84,7 @@ const dialogShowDiscard = computed(() => props.showDiscard ?? config.value.showD
|
||||
const dialogVariant = computed(() => props.variant ?? config.value.variant ?? 'default')
|
||||
const dialogDiscardVariant = computed(() => props.discardVariant ?? config.value.discardVariant ?? 'default')
|
||||
|
||||
const emit = defineEmits(['confirm', 'cancel', 'discard'])
|
||||
const emit = defineEmits(['confirm', 'cancel', 'discard', 'update:show'])
|
||||
|
||||
// Внутреннее состояние загрузки: null | 'confirm' | 'discard'
|
||||
const loading = ref(null)
|
||||
@@ -104,8 +104,9 @@ const handleConfirm = async () => {
|
||||
loading.value = 'confirm'
|
||||
try {
|
||||
await props.action()
|
||||
// Успех — эмитим confirm для закрытия
|
||||
// Успех — эмитим confirm и закрываем диалог
|
||||
emit('confirm')
|
||||
emit('update:show', false)
|
||||
} catch (e) {
|
||||
console.error('ConfirmDialog action failed:', e)
|
||||
// При ошибке — не закрываем диалог
|
||||
@@ -113,14 +114,16 @@ const handleConfirm = async () => {
|
||||
loading.value = null
|
||||
}
|
||||
} else {
|
||||
// Простой режим — просто эмитим
|
||||
// Простой режим — просто эмитим и закрываем
|
||||
emit('confirm')
|
||||
emit('update:show', false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
if (loading.value) return
|
||||
emit('cancel')
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
const handleDiscard = async () => {
|
||||
@@ -131,8 +134,9 @@ const handleDiscard = async () => {
|
||||
loading.value = 'discard'
|
||||
try {
|
||||
await props.discardAction()
|
||||
// Успех — эмитим discard для закрытия
|
||||
// Успех — эмитим discard и закрываем диалог
|
||||
emit('discard')
|
||||
emit('update:show', false)
|
||||
} catch (e) {
|
||||
console.error('ConfirmDialog discardAction failed:', e)
|
||||
// При ошибке — не закрываем диалог
|
||||
@@ -140,8 +144,9 @@ const handleDiscard = async () => {
|
||||
loading.value = null
|
||||
}
|
||||
} else {
|
||||
// Простой режим — просто эмитим
|
||||
// Простой режим — просто эмитим и закрываем
|
||||
emit('discard')
|
||||
emit('update:show', false)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -785,6 +785,25 @@ const handleSave = async () => {
|
||||
if (result.success) {
|
||||
const newProjectId = result.id
|
||||
|
||||
// Создаём колонки для нового проекта
|
||||
const createdColumnIds = []
|
||||
for (const column of form.value.columns) {
|
||||
if (column.tempId) {
|
||||
const colResult = await store.addColumn(column.name_columns, column.color)
|
||||
if (colResult.success) {
|
||||
createdColumnIds.push(colResult.id)
|
||||
} else {
|
||||
toast.error('Ошибка создания колонки')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Устанавливаем последнюю колонку как финальную (id_ready)
|
||||
if (createdColumnIds.length > 0) {
|
||||
const lastColumnId = createdColumnIds[createdColumnIds.length - 1]
|
||||
await store.reorderColumns(createdColumnIds) // Это автоматически установит id_ready
|
||||
}
|
||||
|
||||
// Создаём отделы для нового проекта
|
||||
for (const department of form.value.departments) {
|
||||
if (department.tempId) {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
<div class="comment-actions">
|
||||
<IconButton
|
||||
v-if="canReply"
|
||||
icon="reply"
|
||||
variant="ghost"
|
||||
small
|
||||
@@ -110,6 +111,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
canReply: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
getFullUrl: {
|
||||
type: Function,
|
||||
required: true
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
:level="comment.level"
|
||||
:is-own="comment.id_accounts === currentUserId"
|
||||
:can-edit="canEditComment(comment)"
|
||||
:can-reply="canComment"
|
||||
:get-full-url="getFullUrl"
|
||||
@reply="startReply"
|
||||
@edit="startEditComment"
|
||||
@@ -68,6 +69,7 @@ import ContentEditorPanel from './ContentEditorPanel.vue'
|
||||
import Loader from '../ui/Loader.vue'
|
||||
import { commentsApi, commentImageApi, getFullUrl } from '../../api'
|
||||
import { useLucideIcons } from '../../composables/useLucideIcons'
|
||||
import { useAutoRefresh } from '../../composables/useAutoRefresh'
|
||||
|
||||
const props = defineProps({
|
||||
taskId: {
|
||||
@@ -150,8 +152,12 @@ const editorAvatarUrl = computed(() => {
|
||||
// Refs
|
||||
const commentsListRef = ref(null)
|
||||
|
||||
// Интервал обновления
|
||||
let refreshInterval = null
|
||||
// Автообновление комментариев
|
||||
const { start: startRefresh, stop: stopRefresh } = useAutoRefresh('comments', async () => {
|
||||
if (props.active && props.taskId) {
|
||||
await loadComments(true)
|
||||
}
|
||||
})
|
||||
|
||||
// Построение дерева комментариев
|
||||
const commentsTree = computed(() => {
|
||||
@@ -243,23 +249,7 @@ const loadComments = async (silent = false) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Автообновление
|
||||
const startRefresh = () => {
|
||||
stopRefresh()
|
||||
const interval = (window.APP_CONFIG?.REFRESH_INTERVALS?.comments || 30) * 1000
|
||||
refreshInterval = setInterval(async () => {
|
||||
if (props.active && props.taskId) {
|
||||
await loadComments(true)
|
||||
}
|
||||
}, interval)
|
||||
}
|
||||
|
||||
const stopRefresh = () => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval)
|
||||
refreshInterval = null
|
||||
}
|
||||
}
|
||||
// startRefresh и stopRefresh определены через useAutoRefresh выше
|
||||
|
||||
// ========== Редактор: открытие/закрытие ==========
|
||||
|
||||
@@ -402,6 +392,8 @@ const updateComment = async (text, newFiles, filesToDelete) => {
|
||||
// ========== Редактирование (права) ==========
|
||||
|
||||
const canEditComment = (comment) => {
|
||||
// Для архивных задач — нельзя редактировать комментарии
|
||||
if (!props.canComment) return false
|
||||
return comment.id_accounts === props.currentUserId || props.isProjectAdmin
|
||||
}
|
||||
|
||||
|
||||
@@ -49,8 +49,8 @@
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Footer: скрываем на вкладке комментариев или если нет прав редактировать -->
|
||||
<template #footer v-if="activeTab !== 'comments' && canEdit">
|
||||
<!-- Footer: скрываем на вкладке комментариев, показываем если есть права редактировать или это архивная задача -->
|
||||
<template #footer v-if="activeTab !== 'comments' && (canEdit || isArchived)">
|
||||
<div class="footer-left">
|
||||
<IconButton
|
||||
v-if="!isNew"
|
||||
@@ -74,13 +74,19 @@
|
||||
@click="handleRestore"
|
||||
/>
|
||||
</div>
|
||||
<!-- Кнопки сохранения — только для редактируемых задач -->
|
||||
<ActionButtons
|
||||
v-if="canEdit"
|
||||
:save-text="isNew ? 'Создать' : 'Сохранить'"
|
||||
:loading="isSaving"
|
||||
:disabled="!canSave"
|
||||
@save="handleSave"
|
||||
@cancel="tryClose"
|
||||
/>
|
||||
<!-- Для архивных — только кнопка закрытия -->
|
||||
<button v-else class="btn-close-panel" @click="tryClose">
|
||||
Закрыть
|
||||
</button>
|
||||
</template>
|
||||
</SlidePanel>
|
||||
|
||||
@@ -228,14 +234,18 @@ const canArchive = computed(() => {
|
||||
})
|
||||
|
||||
// Право на редактирование (для новой — create_task, для существующей — canEditTask)
|
||||
// Архивные задачи нельзя редактировать
|
||||
const canEdit = computed(() => {
|
||||
if (props.isArchived) return false
|
||||
if (isNew.value) return store.can('create_task')
|
||||
return store.canEditTask(props.card)
|
||||
})
|
||||
|
||||
// Право на создание комментариев
|
||||
// Архивные задачи нельзя комментировать
|
||||
const canComment = computed(() => {
|
||||
if (isNew.value) return false // В новой задаче нельзя комментировать
|
||||
if (props.isArchived) return false // Архивные нельзя комментировать
|
||||
return store.canCreateComment(props.card)
|
||||
})
|
||||
|
||||
@@ -429,7 +439,7 @@ watch(() => props.show, async (newVal) => {
|
||||
isSaving.value = false // Сброс состояния кнопки сохранения
|
||||
|
||||
// Обновляем права пользователя (могли измениться администратором)
|
||||
store.fetchUsers()
|
||||
await store.fetchUsers()
|
||||
|
||||
// Reset comments tab
|
||||
commentsTabRef.value?.reset()
|
||||
@@ -472,4 +482,21 @@ watch(() => props.show, async (newVal) => {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-close-panel {
|
||||
padding: 12px 24px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-close-panel:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<!-- Кнопка-триггер -->
|
||||
<button class="mobile-select-btn" :class="[variant, { compact }]" @click="open = true">
|
||||
<button class="mobile-select-btn" :class="[variant, { compact, 'has-selection': hasSelection }]" @click="open = true">
|
||||
<i v-if="icon" :data-lucide="icon" class="btn-icon"></i>
|
||||
<span v-if="!compact" class="btn-label">{{ displayValue }}</span>
|
||||
<i data-lucide="chevron-down" class="btn-arrow"></i>
|
||||
@@ -85,6 +85,14 @@ const displayValue = computed(() => {
|
||||
return option?.label || props.placeholder
|
||||
})
|
||||
|
||||
// Проверка: выбран ли не-дефолтный вариант (первый option обычно "Все")
|
||||
const hasSelection = computed(() => {
|
||||
if (props.modelValue === null || props.modelValue === undefined) return false
|
||||
// Если первый option = null — значит "Все", проверяем что выбрано что-то другое
|
||||
const firstOption = props.options[0]
|
||||
return firstOption && props.modelValue !== firstOption.id
|
||||
})
|
||||
|
||||
const selectOption = (id) => {
|
||||
emit('update:modelValue', id)
|
||||
open.value = false
|
||||
@@ -191,6 +199,17 @@ onUpdated(refreshIcons)
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Подсветка когда выбран активный фильтр */
|
||||
.mobile-select-btn.has-selection {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mobile-select-btn.has-selection .btn-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
|
||||
60
front_vue/src/composables/useAutoRefresh.js
Normal file
60
front_vue/src/composables/useAutoRefresh.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Composable для автообновления данных
|
||||
*
|
||||
* @param {string} key - ключ из REFRESH_INTERVALS (cards, comments, invites)
|
||||
* @param {Function} callback - функция для вызова при каждом обновлении
|
||||
*
|
||||
* @example
|
||||
* const { start, stop } = useAutoRefresh('cards', async () => {
|
||||
* await fetchCards()
|
||||
* })
|
||||
*
|
||||
* onMounted(() => start())
|
||||
* onUnmounted(() => stop())
|
||||
*/
|
||||
export function useAutoRefresh(key, callback) {
|
||||
let timer = null
|
||||
const isActive = ref(false)
|
||||
|
||||
// Получаем интервал из конфига (в секундах), конвертируем в мс
|
||||
const getInterval = () => {
|
||||
const seconds = window.APP_CONFIG?.REFRESH_INTERVALS?.[key] ?? 30
|
||||
return seconds * 1000
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
stop() // Очищаем предыдущий если был
|
||||
|
||||
const interval = getInterval()
|
||||
|
||||
// 0 = отключено
|
||||
if (interval <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
isActive.value = true
|
||||
timer = setInterval(async () => {
|
||||
try {
|
||||
await callback()
|
||||
} catch (e) {
|
||||
console.error(`[AutoRefresh:${key}] Error:`, e)
|
||||
}
|
||||
}, interval)
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
isActive.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
isActive
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,7 @@ import { useProjectsStore } from '../stores/projects'
|
||||
import { cardsApi } from '../api'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
import { useDepartmentFilter } from '../composables/useDepartmentFilter'
|
||||
import { useAutoRefresh } from '../composables/useAutoRefresh'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
@@ -237,37 +238,25 @@ const onProjectSaved = async (projectId) => {
|
||||
}
|
||||
|
||||
// ==================== АВТООБНОВЛЕНИЕ (POLLING) ====================
|
||||
const CARDS_REFRESH_INTERVAL = (window.APP_CONFIG?.REFRESH_INTERVALS?.cards ?? 30) * 1000
|
||||
const INVITES_REFRESH_INTERVAL = (window.APP_CONFIG?.REFRESH_INTERVALS?.invites ?? 30) * 1000
|
||||
let pollTimer = null
|
||||
let invitesPollTimer = null
|
||||
const { start: startCardsPolling, stop: stopCardsPolling } = useAutoRefresh('cards', async () => {
|
||||
// Не обновляем когда открыта модалка — это может прерывать клики
|
||||
if (panelOpen.value || projectPanelOpen.value) return
|
||||
console.log('[AutoRefresh] Обновление данных...')
|
||||
await fetchCards(true) // silent = true, без Loader
|
||||
})
|
||||
|
||||
const { start: startInvitesPolling, stop: stopInvitesPolling } = useAutoRefresh('invites', async () => {
|
||||
await store.fetchPendingInvitesCount()
|
||||
})
|
||||
|
||||
const startPolling = () => {
|
||||
// Polling карточек
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
pollTimer = setInterval(async () => {
|
||||
// Не обновляем когда открыта модалка — это может прерывать клики
|
||||
if (panelOpen.value || projectPanelOpen.value) return
|
||||
console.log('[AutoRefresh] Обновление данных...')
|
||||
await fetchCards(true) // silent = true, без Loader
|
||||
}, CARDS_REFRESH_INTERVAL)
|
||||
|
||||
// Polling приглашений (для бейджа)
|
||||
if (invitesPollTimer) clearInterval(invitesPollTimer)
|
||||
invitesPollTimer = setInterval(async () => {
|
||||
await store.fetchPendingInvitesCount()
|
||||
}, INVITES_REFRESH_INTERVAL)
|
||||
startCardsPolling()
|
||||
startInvitesPolling()
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
if (invitesPollTimer) {
|
||||
clearInterval(invitesPollTimer)
|
||||
invitesPollTimer = null
|
||||
}
|
||||
stopCardsPolling()
|
||||
stopInvitesPolling()
|
||||
}
|
||||
|
||||
// ==================== ИНИЦИАЛИЗАЦИЯ ====================
|
||||
|
||||
@@ -96,13 +96,14 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, onUpdated, nextTick, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUpdated, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ProjectPanel from '../components/ProjectPanel.vue'
|
||||
import NotificationCard from '../components/ui/NotificationCard.vue'
|
||||
import LogoutButton from '../components/ui/LogoutButton.vue'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
import { useAutoRefresh } from '../composables/useAutoRefresh'
|
||||
import { projectInviteApi, getFullUrl, cardsApi } from '../api'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
@@ -243,27 +244,21 @@ const refreshIcons = () => {
|
||||
}
|
||||
|
||||
// Периодическое обновление приглашений
|
||||
let refreshInterval = null
|
||||
const REFRESH_MS = (window.APP_CONFIG?.REFRESH_INTERVALS?.invites || 30) * 1000
|
||||
const { start: startInvitesRefresh, stop: stopInvitesRefresh } = useAutoRefresh('invites', async () => {
|
||||
// Не обновляем если показывается анимация успеха
|
||||
if (!showSuccess.value) {
|
||||
await loadInvites()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadInvites()
|
||||
refreshIcons()
|
||||
|
||||
// Запускаем периодическое обновление
|
||||
refreshInterval = setInterval(() => {
|
||||
// Не обновляем если показывается анимация успеха
|
||||
if (!showSuccess.value) {
|
||||
loadInvites()
|
||||
}
|
||||
}, REFRESH_MS)
|
||||
startInvitesRefresh()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval)
|
||||
refreshInterval = null
|
||||
}
|
||||
stopInvitesRefresh()
|
||||
})
|
||||
|
||||
onUpdated(refreshIcons)
|
||||
|
||||
Reference in New Issue
Block a user