feat: restructure into XFlow monorepo with Nuxt 4, Modular Onion Monolith, Android client, and Desktop CLI

This commit is contained in:
XFlow Deploy
2026-09-28 08:57:32 +05:00
parent edcd30c829
commit 0837b3f612
204 changed files with 24616 additions and 39 deletions
@@ -0,0 +1,318 @@
<template>
<div
class="fixed bottom-0 left-0 right-0 z-30 transition-all duration-300 ease-in-out font-sans flex flex-col"
:class="[isOpen ? 'h-80' : 'h-10']"
>
<!-- Handle / Toggle Bar -->
<div
@click="isOpen = !isOpen"
class="h-10 bg-slate-900 border-t border-slate-700/80 hover:bg-slate-800 cursor-pointer flex items-center px-4 justify-between select-none transition"
>
<div class="flex items-center gap-2 text-slate-200 font-bold text-xs">
<span>📅</span>
<span>Диаграмма Ганта</span>
<span class="px-1.5 py-0.5 bg-slate-800 text-[10px] text-slate-400 rounded-full border border-slate-700 font-medium">
Задач: {{ tasks.length }}
</span>
</div>
<div class="text-slate-400 text-xs">
{{ isOpen ? '▼ Свернуть' : '▲ Развернуть' }}
</div>
</div>
<!-- Timeline Body -->
<div
v-if="isOpen"
class="flex-1 bg-slate-950/95 backdrop-blur-md border-t border-slate-800 flex overflow-hidden min-w-0"
>
<!-- Task Titles Sidebar (Left) -->
<div class="w-64 border-r border-slate-800 flex flex-col shrink-0">
<div class="h-8 border-b border-slate-800 bg-slate-900/40 px-3 flex items-center text-[10px] font-bold text-slate-500 uppercase tracking-wider">
Задача
</div>
<div class="flex-1 overflow-y-auto divide-y divide-slate-900/60 scrollbar-thin">
<div
v-for="task in tasks"
:key="task.id"
@click="$emit('select-node', task.id)"
class="h-10 px-3 flex items-center text-xs text-slate-300 hover:text-white hover:bg-slate-800/30 cursor-pointer transition select-none truncate font-medium"
title="Кликните для фокусировки на холсте"
>
<span class="mr-1.5">{{ getStatusIcon(task.data.state) }}</span>
<span class="truncate">{{ task.data.label || 'Без названия' }}</span>
</div>
<div v-if="tasks.length === 0" class="p-6 text-center text-xs text-slate-600">
Нет задач на холсте
</div>
</div>
</div>
<!-- Timeline Chart (Right) -->
<div class="flex-1 flex flex-col overflow-hidden min-w-0">
<!-- Date Columns Header -->
<div
class="h-8 border-b border-slate-800 bg-slate-900/40 flex overflow-x-auto overflow-y-hidden select-none scrollbar-none"
ref="timelineHeader"
@scroll="syncScrollLeft"
>
<div
v-for="(day, idx) in timelineDays"
:key="idx"
class="h-full border-r border-slate-900/40 shrink-0 flex flex-col items-center justify-center text-[9px] font-semibold text-slate-400"
:style="{ width: `${dayWidth}px` }"
>
<span>{{ day.dayOfWeek }}</span>
<span class="text-slate-500 font-normal">{{ day.dateStr }}</span>
</div>
</div>
<!-- Task Bars Body -->
<div
class="flex-1 overflow-auto divide-y divide-slate-900/60 scrollbar-thin"
ref="timelineBody"
@scroll="syncScrollRight"
>
<div
v-for="task in tasks"
:key="task.id"
class="h-10 relative flex items-center bg-slate-950/20 hover:bg-slate-900/30 transition group"
>
<!-- Timeline Grid Background Lines -->
<div class="absolute inset-0 flex pointer-events-none">
<div
v-for="idx in timelineDays.length"
:key="idx"
class="h-full border-r border-slate-900/40 shrink-0"
:style="{ width: `${dayWidth}px` }"
></div>
</div>
<!-- Task Gantt Bar -->
<div
v-if="hasValidDates(task)"
@click="$emit('select-node', task.id)"
class="absolute h-5 rounded-md border text-[10px] font-bold text-white px-2 flex items-center truncate cursor-pointer transition shadow-md hover:scale-[1.01] z-10"
:class="getBarClasses(task)"
:style="getBarStyles(task)"
>
<span class="truncate">{{ task.data.label }}</span>
<!-- Tooltip on hover -->
<div class="absolute bottom-6 left-0 hidden group-hover:block bg-slate-900 border border-slate-700 text-[10px] rounded p-2 text-slate-300 w-48 shadow-xl pointer-events-none whitespace-normal leading-relaxed z-20">
<div class="font-bold text-white truncate mb-1">{{ task.data.label }}</div>
<div>Статус: <span class="font-medium text-slate-200">{{ getStatusLabel(task.data.state) }}</span></div>
<div>Приоритет: <span class="font-medium text-slate-200">{{ getPriorityLabel(task.data.priority) }}</span></div>
<div v-if="task.data.assigned_to">Исполнитель: <span class="font-medium text-slate-200">{{ task.data.assigned_to }}</span></div>
<div class="text-[9px] text-slate-500 mt-1 border-t border-slate-800 pt-1">
С: {{ formatDate(task.data.start_date) }}<br/>
По: {{ formatDate(task.data.alarm) }}
</div>
</div>
</div>
</div>
<div v-if="tasks.length === 0" class="h-full flex items-center justify-center text-xs text-slate-600">
Создайте задачи и задайте им даты начала/дедлайны
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
elements: { type: Array, default: () => [] }
})
defineEmits(['select-node'])
const isOpen = ref(false)
const dayWidth = 60 // Width of one day column in pixels
const timelineHeader = ref(null)
const timelineBody = ref(null)
// Scroll synchronization between header and body
const syncScrollLeft = (e) => {
if (timelineBody.value) {
timelineBody.value.scrollLeft = e.target.scrollLeft
}
}
const syncScrollRight = (e) => {
if (timelineHeader.value) {
timelineHeader.value.scrollLeft = e.target.scrollLeft
}
}
// Filter tasks
const tasks = computed(() => {
return props.elements.filter(el => el.type === 'task')
})
// Calculate overall min/max dates
const dateRange = computed(() => {
let minDate = new Date()
let maxDate = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000) // Default 14 days from now
const taskDates = []
tasks.value.forEach(t => {
if (t.data.start_date) taskDates.push(new Date(t.data.start_date))
if (t.data.alarm) taskDates.push(new Date(t.data.alarm))
})
if (taskDates.length > 0) {
minDate = new Date(Math.min(...taskDates))
maxDate = new Date(Math.max(...taskDates))
}
// Add 2 days padding to sides
minDate.setDate(minDate.getDate() - 2)
maxDate.setDate(maxDate.getDate() + 5)
// Cap timeline length at 30 days to prevent rendering overflow
const diffTime = Math.abs(maxDate - minDate)
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
if (diffDays > 30) {
maxDate = new Date(minDate.getTime() + 30 * 24 * 60 * 60 * 1000)
}
// Reset to midnight
minDate.setHours(0, 0, 0, 0)
maxDate.setHours(23, 59, 59, 999)
return { minDate, maxDate }
})
// Generate list of days for timeline header
const timelineDays = computed(() => {
const days = []
const current = new Date(dateRange.value.minDate)
const end = dateRange.value.maxDate
const ruDays = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']
while (current <= end) {
days.push({
date: new Date(current),
dateStr: `${current.getDate().toString().padStart(2, '0')}.${(current.getMonth() + 1).toString().padStart(2, '0')}`,
dayOfWeek: ruDays[current.getDay()]
})
current.setDate(current.getDate() + 1)
}
return days
})
// Check if task has valid start and end date
const hasValidDates = (task) => {
return !!(task.data.start_date || task.data.alarm)
}
// Format single date for tooltips
const formatDate = (dateStr) => {
if (!dateStr) return 'Не указано'
const date = new Date(dateStr)
return date.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
}
// Calculate absolute left offset and width in percent or pixels for Gantt Bar
const getBarStyles = (task) => {
const minTime = dateRange.value.minDate.getTime()
const maxTime = dateRange.value.maxDate.getTime()
const totalDuration = maxTime - minTime
let start = task.data.start_date ? new Date(task.data.start_date).getTime() : Date.now()
let end = task.data.alarm ? new Date(task.data.alarm).getTime() : start + 2 * 24 * 60 * 60 * 1000 // 2 days default
if (start > end) {
// Correct order if user reversed dates
const tmp = start
start = end
end = tmp
}
// Boundary checks
start = Math.max(start, minTime)
end = Math.min(end, maxTime)
const leftPercent = ((start - minTime) / totalDuration) * 100
const widthPercent = ((end - start) / totalDuration) * 100
// Calculate actual pixel offsets based on timeline width
const totalDays = timelineDays.value.length
const totalPixels = totalDays * dayWidth
const leftPx = (leftPercent / 100) * totalPixels
const widthPx = Math.max((widthPercent / 100) * totalPixels, 20) // min 20px width
return {
left: `${leftPx}px`,
width: `${widthPx}px`
}
}
// Style colors based on task status
const getBarClasses = (task) => {
const state = task.data.state || 'todo'
switch (state) {
case 'completed':
return 'bg-emerald-600/30 border-emerald-500 hover:bg-emerald-500/40 text-emerald-300'
case 'in_progress':
return 'bg-amber-600/30 border-amber-500 hover:bg-amber-500/40 text-amber-300'
case 'review':
return 'bg-indigo-600/30 border-indigo-500 hover:bg-indigo-500/40 text-indigo-300'
case 'blocked':
return 'bg-red-600/30 border-red-500 hover:bg-red-500/40 text-red-300'
default:
return 'bg-blue-600/30 border-blue-500 hover:bg-blue-500/40 text-blue-300'
}
}
const getStatusIcon = (state) => {
switch (state) {
case 'completed': return '✅'
case 'in_progress': return '⏳'
case 'review': return '👀'
case 'blocked': return '⛔'
default: return '📋'
}
}
const getStatusLabel = (state) => {
switch (state) {
case 'completed': return 'Завершено'
case 'in_progress': return 'В работе'
case 'review': return 'На проверке'
case 'blocked': return 'Заблокировано'
default: return 'К выполнению'
}
}
const getPriorityLabel = (priority) => {
switch (priority) {
case 'high': return 'Высокий'
case 'low': return 'Низкий'
default: return 'Средний'
}
}
</script>
<style scoped>
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: #475569;
}
.scrollbar-none::-webkit-scrollbar {
display: none;
}
</style>