Добавление логики
1. Получения конфигурациия с бека 2. Время закрытия задачи 3. Изменение фронта под новую локигу конфигурации 4. Обновление структуры бд
This commit is contained in:
@@ -124,4 +124,26 @@ export const taskImageApi = {
|
||||
// ==================== USERS ====================
|
||||
export const usersApi = {
|
||||
getAll: () => request('/api/user', { credentials: 'include' })
|
||||
}
|
||||
|
||||
// ==================== CONFIG ====================
|
||||
export const configApi = {
|
||||
get: () => request('/api/user', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'get_config' })
|
||||
})
|
||||
}
|
||||
|
||||
// Загрузка конфига с сервера и мерж с window.APP_CONFIG
|
||||
export const loadServerConfig = async () => {
|
||||
try {
|
||||
const result = await configApi.get()
|
||||
if (result.success && result.data) {
|
||||
window.APP_CONFIG = { ...window.APP_CONFIG, ...result.data }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки конфига:', error)
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,7 @@ const columnsWithCards = computed(() => {
|
||||
assignee: card.avatar_img,
|
||||
dueDate: card.date,
|
||||
dateCreate: card.date_create,
|
||||
dateClosed: card.date_closed,
|
||||
files: card.files || (card.file_img || []).map(f => ({
|
||||
name: f.name,
|
||||
url: f.url,
|
||||
@@ -133,8 +134,17 @@ const handleDropCard = async ({ cardId, fromColumnId, toColumnId, toIndex }) =>
|
||||
const card = localCards.value.find(c => c.id === cardId)
|
||||
if (!card) return
|
||||
|
||||
const doneColumnId = window.APP_CONFIG.COLUMN_DONE_ID
|
||||
|
||||
// Локально обновляем для мгновенного отклика
|
||||
card.column_id = toColumnId
|
||||
|
||||
// Обновляем date_closed при перемещении в/из колонки "Готово"
|
||||
if (toColumnId === doneColumnId && fromColumnId !== doneColumnId) {
|
||||
card.date_closed = new Date().toISOString()
|
||||
} else if (fromColumnId === doneColumnId && toColumnId !== doneColumnId) {
|
||||
card.date_closed = null
|
||||
}
|
||||
|
||||
// Получаем карточки целевой колонки (без перемещаемой)
|
||||
const columnCards = localCards.value
|
||||
|
||||
@@ -52,9 +52,12 @@
|
||||
<span v-if="card.dateCreate" class="date-create">
|
||||
Создано: {{ formatDateWithYear(card.dateCreate) }}
|
||||
</span>
|
||||
<span v-if="card.dueDate && Number(columnId) !== 4" class="due-date" :class="dueDateStatus">
|
||||
<span v-if="card.dueDate && Number(columnId) !== doneColumnId" class="due-date" :class="dueDateStatus">
|
||||
{{ daysLeftText }}
|
||||
</span>
|
||||
<span v-if="Number(columnId) === doneColumnId && card.dateClosed" class="date-closed">
|
||||
Закрыто: {{ closedDateText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -155,9 +158,28 @@ const isAvatarUrl = (value) => {
|
||||
return value && (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/'))
|
||||
}
|
||||
|
||||
// Можно ли архивировать (только если колонка 4)
|
||||
// Форматирование даты закрытия (относительный формат)
|
||||
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)
|
||||
})
|
||||
|
||||
// ID колонки "Готово" из конфига
|
||||
const doneColumnId = window.APP_CONFIG.COLUMN_DONE_ID
|
||||
|
||||
// Можно ли архивировать (только если колонка "Готово")
|
||||
const canArchive = computed(() => {
|
||||
return Number(props.columnId) === 4
|
||||
return Number(props.columnId) === doneColumnId
|
||||
})
|
||||
|
||||
const handleArchive = () => {
|
||||
@@ -297,6 +319,11 @@ const handleArchive = () => {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.date-closed {
|
||||
font-size: 11px;
|
||||
color: var(--green, #00d4aa);
|
||||
}
|
||||
|
||||
.btn-archive-card {
|
||||
display: none;
|
||||
align-items: center;
|
||||
|
||||
@@ -69,14 +69,12 @@
|
||||
|
||||
<FormField label="Исполнитель">
|
||||
<SelectDropdown
|
||||
v-if="!usersLoading"
|
||||
v-model="form.userId"
|
||||
:options="userOptions"
|
||||
searchable
|
||||
placeholder="Без исполнителя"
|
||||
empty-label="Без исполнителя"
|
||||
/>
|
||||
<div v-else class="users-loading">Загрузка...</div>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
@@ -181,7 +179,7 @@ import SelectDropdown from './ui/SelectDropdown.vue'
|
||||
import TagsSelect from './ui/TagsSelect.vue'
|
||||
import FileUploader from './ui/FileUploader.vue'
|
||||
import ImagePreview from './ui/ImagePreview.vue'
|
||||
import { usersApi, taskImageApi, getFullUrl } from '../api'
|
||||
import { taskImageApi, getFullUrl } from '../api'
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
@@ -194,14 +192,16 @@ const props = defineProps({
|
||||
labels: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
users: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'save', 'delete', 'archive'])
|
||||
|
||||
const isNew = ref(true)
|
||||
const users = ref([])
|
||||
const usersLoading = ref(false)
|
||||
const isSaving = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
@@ -237,7 +237,7 @@ const labelOptions = computed(() => {
|
||||
|
||||
// Преобразование users в формат для SelectDropdown
|
||||
const userOptions = computed(() => {
|
||||
return users.value.map(user => ({
|
||||
return props.users.map(user => ({
|
||||
value: user.id,
|
||||
label: user.name,
|
||||
subtitle: user.telegram,
|
||||
@@ -309,23 +309,9 @@ const cancelClose = () => {
|
||||
showUnsavedDialog.value = false
|
||||
}
|
||||
|
||||
const fetchUsers = async () => {
|
||||
usersLoading.value = true
|
||||
try {
|
||||
const data = await usersApi.getAll()
|
||||
if (data.success) {
|
||||
users.value = data.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки пользователей:', error)
|
||||
} finally {
|
||||
usersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getAvatarByUserId = (userId) => {
|
||||
if (!userId) return null
|
||||
const user = users.value.find(u => u.id === userId)
|
||||
const user = props.users.find(u => u.id === userId)
|
||||
return user ? user.avatar_url : null
|
||||
}
|
||||
|
||||
@@ -356,8 +342,6 @@ watch(() => props.show, async (newVal) => {
|
||||
isNew.value = !props.card
|
||||
clearFiles()
|
||||
|
||||
await fetchUsers()
|
||||
|
||||
if (props.card) {
|
||||
form.title = props.card.title || ''
|
||||
form.description = props.card.description || ''
|
||||
@@ -456,9 +440,9 @@ const handleDelete = () => {
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
// Можно ли архивировать (только если колонка 4)
|
||||
// Можно ли архивировать (только если колонка "Готово")
|
||||
const canArchive = computed(() => {
|
||||
return Number(props.columnId) === 4
|
||||
return Number(props.columnId) === window.APP_CONFIG.COLUMN_DONE_ID
|
||||
})
|
||||
|
||||
const handleArchive = () => {
|
||||
@@ -593,11 +577,6 @@ onUpdated(refreshIcons)
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.users-loading {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.btn-icon.btn-delete {
|
||||
border: 1px solid var(--red);
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
ref="searchInputRef"
|
||||
@click.stop
|
||||
>
|
||||
<div class="dropdown-list">
|
||||
<div class="dropdown-list" ref="listRef">
|
||||
<!-- Опция "не выбрано" -->
|
||||
<button
|
||||
v-if="allowEmpty"
|
||||
@@ -114,6 +114,7 @@ const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const dropdownRef = ref(null)
|
||||
const searchInputRef = ref(null)
|
||||
const listRef = ref(null)
|
||||
const isOpen = ref(false)
|
||||
const openUp = ref(false)
|
||||
const searchQuery = ref('')
|
||||
@@ -134,12 +135,23 @@ const filteredOptions = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// Прокрутка к активному элементу
|
||||
const scrollToActive = () => {
|
||||
if (listRef.value && props.modelValue) {
|
||||
const activeItem = listRef.value.querySelector('.dropdown-item.active')
|
||||
if (activeItem) {
|
||||
activeItem.scrollIntoView({ block: 'center', behavior: 'instant' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Переключение dropdown
|
||||
const toggleDropdown = async () => {
|
||||
isOpen.value = !isOpen.value
|
||||
if (isOpen.value) {
|
||||
await nextTick()
|
||||
updatePosition()
|
||||
scrollToActive()
|
||||
searchInputRef.value?.focus()
|
||||
refreshIcons()
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import MainApp from './views/MainApp.vue'
|
||||
import LoginPage from './views/LoginPage.vue'
|
||||
import TeamPage from './views/TeamPage.vue'
|
||||
import { authApi } from './api'
|
||||
import { authApi, loadServerConfig } from './api'
|
||||
|
||||
// Флаг загрузки конфига (один раз за сессию)
|
||||
let configLoaded = false
|
||||
|
||||
// Проверка авторизации
|
||||
const checkAuth = async () => {
|
||||
@@ -50,6 +53,11 @@ router.beforeEach(async (to, from, next) => {
|
||||
// Уже авторизован — на главную
|
||||
next('/')
|
||||
} else {
|
||||
// Загружаем конфиг с сервера один раз для защищённых страниц
|
||||
if (to.meta.requiresAuth && isAuth && !configLoaded) {
|
||||
await loadServerConfig()
|
||||
configLoaded = true
|
||||
}
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
:column-id="editingColumnId"
|
||||
:departments="departments"
|
||||
:labels="labels"
|
||||
:users="users"
|
||||
@close="closePanel"
|
||||
@save="handleSaveTask"
|
||||
@delete="handleDeleteTask"
|
||||
@@ -84,7 +85,7 @@ import Sidebar from '../components/Sidebar.vue'
|
||||
import Header from '../components/Header.vue'
|
||||
import Board from '../components/Board.vue'
|
||||
import TaskPanel from '../components/TaskPanel.vue'
|
||||
import { departmentsApi, labelsApi, columnsApi, cardsApi } from '../api'
|
||||
import { departmentsApi, labelsApi, columnsApi, cardsApi, usersApi } from '../api'
|
||||
|
||||
// Активный фильтр по отделу (null = все)
|
||||
// Восстанавливаем из localStorage
|
||||
@@ -106,21 +107,24 @@ const departments = ref([])
|
||||
const labels = ref([])
|
||||
const columns = ref([])
|
||||
const cards = ref([])
|
||||
const users = ref([])
|
||||
|
||||
// Загрузка всех данных из API параллельно
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [departmentsData, labelsData, columnsData, cardsData] = await Promise.all([
|
||||
const [departmentsData, labelsData, columnsData, cardsData, usersData] = await Promise.all([
|
||||
departmentsApi.getAll(),
|
||||
labelsApi.getAll(),
|
||||
columnsApi.getAll(),
|
||||
cardsApi.getAll()
|
||||
cardsApi.getAll(),
|
||||
usersApi.getAll()
|
||||
])
|
||||
|
||||
if (departmentsData.success) departments.value = departmentsData.data
|
||||
if (labelsData.success) labels.value = labelsData.data
|
||||
if (columnsData.success) columns.value = columnsData.data
|
||||
if (cardsData.success) cards.value = cardsData.data
|
||||
if (usersData.success) users.value = usersData.data
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки данных:', error)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user