feat: enhance task persistence and implement centralized kanban board

This commit is contained in:
2026-04-28 21:55:37 +05:00
parent 7da3fc8ff4
commit 1f87f0d6e9
9 changed files with 425 additions and 45 deletions
+103 -33
View File
@@ -23,8 +23,8 @@
<div class="flex-1 flex flex-col relative min-w-0"> <div class="flex-1 flex flex-col relative min-w-0">
<!-- Toolbar --> <!-- Toolbar -->
<div class="h-10 bg-slate-900 border-b border-slate-700 flex items-center px-3 gap-2 z-20"> <div class="h-10 bg-slate-900 border-b border-slate-700 flex items-center px-3 gap-2 z-20">
<span class="text-sm font-semibold text-slate-300 mr-2">{{ canvasName }}</span> <input v-model="canvasName" @change="saveCanvas" class="text-sm font-semibold text-slate-300 mr-2 bg-transparent border border-transparent hover:border-slate-600 focus:border-blue-500 focus:bg-slate-800 outline-none rounded px-2 py-1 w-64 transition" title="Переименовать карту" />
<span v-if="unsavedChanges.isDirty" class="px-2 py-1 bg-yellow-600 text-yellow-100 rounded text-xs font-medium"> <span v-if="isDirty" class="px-2 py-1 bg-yellow-600 text-yellow-100 rounded text-xs font-medium">
● Несохранено ● Несохранено
</span> </span>
<button @click="addNodeAtCenter" class="px-2 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded text-xs">+ Узел</button> <button @click="addNodeAtCenter" class="px-2 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded text-xs">+ Узел</button>
@@ -78,14 +78,7 @@
</div> </div>
<div class="flex-1 overflow-y-auto p-3"> <div class="flex-1 overflow-y-auto p-3">
<div v-if="selectedNode" class="space-y-3"> <div v-if="selectedNode" class="space-y-3">
<div> <!-- Hidden internal ID & Type for cleaner UI -->
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">ID</label>
<input :value="selectedNode.id" disabled class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-400 font-mono" />
</div>
<div>
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Тип</label>
<input :value="selectedNode.type" disabled class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-300 capitalize" />
</div>
<div> <div>
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Название</label> <label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Название</label>
<input v-model="selectedNode.data.label" @blur="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" /> <input v-model="selectedNode.data.label" @blur="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
@@ -99,19 +92,34 @@
<input v-model="selectedNode.data.file" @blur="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" /> <input v-model="selectedNode.data.file" @blur="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
</div> </div>
<div v-if="selectedNode.data.state !== undefined"> <div v-if="selectedNode.data.state !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Состояние</label> <label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Статус Задачи</label>
<select v-model="selectedNode.data.state" @change="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200"> <select v-model="selectedNode.data.state" @change="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
<option value="pending">Ожидает</option> <option value="todo">📋 К выполнению (To Do)</option>
<option value="in_progress">В процессе</option> <option value="in_progress">⏳ В работе (In Progress)</option>
<option value="completed">Завершено</option> <option value="review">👀 На проверке (Review)</option>
<option value="completed">✅ Завершено (Done)</option>
<option value="blocked">⛔ Заблокировано (Blocked)</option>
</select>
</div>
<div v-if="selectedNode.data.priority !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Приоритет</label>
<select v-model="selectedNode.data.priority" @change="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
<option value="low">🧊 Низкий</option>
<option value="medium">⚡ Средний</option>
<option value="high">🔥 Высокий</option>
</select> </select>
</div> </div>
<div v-if="selectedNode.data.assigned_to !== undefined"> <div v-if="selectedNode.data.assigned_to !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Назначено</label> <label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Исполнитель</label>
<input v-model="selectedNode.data.assigned_to" @blur="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" /> <select v-model="selectedNode.data.assigned_to" @change="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
<option value="">Не назначено</option>
<option v-for="u in usersList" :key="u.username" :value="u.username">
{{ u.displayName || u.username }}
</option>
</select>
</div> </div>
<div v-if="selectedNode.data.alarm !== undefined"> <div v-if="selectedNode.data.alarm !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Напоминание</label> <label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Дедлайн (Срок)</label>
<input v-model="selectedNode.data.alarm" @blur="commitNodeChange" type="datetime-local" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" /> <input v-model="selectedNode.data.alarm" @blur="commitNodeChange" type="datetime-local" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
</div> </div>
<div v-if="selectedNode.data.script_id !== undefined"> <div v-if="selectedNode.data.script_id !== undefined">
@@ -153,7 +161,7 @@ const props = defineProps({
const { loadCanvas, updateNodeState: apiUpdateNodeState } = useCanvas() const { loadCanvas, updateNodeState: apiUpdateNodeState } = useCanvas()
const gitSync = useGitSync() const gitSync = useGitSync()
const unsavedChanges = useUnsavedChanges() const { isDirty, trackChanges, markAsSaved } = useUnsavedChanges()
const auth = useAuth() const auth = useAuth()
const authHeaders = computed(() => ({ Authorization: `Bearer ${auth.token.value}` })) const authHeaders = computed(() => ({ Authorization: `Bearer ${auth.token.value}` }))
const elements = ref([]) const elements = ref([])
@@ -174,20 +182,42 @@ const loadData = async () => {
const data = await loadCanvas(props.canvasId) const data = await loadCanvas(props.canvasId)
elements.value = transformToFlowElements(data) elements.value = transformToFlowElements(data)
// Mark initial state as saved // Mark initial state as saved
unsavedChanges.markAsSaved(data) markAsSaved(data)
} }
// Transform Obsidian canvas to Vue Flow elements // Transform Obsidian canvas to Vue Flow elements
const transformToFlowElements = (canvasData) => { const transformToFlowElements = (canvasData) => {
const nodes = (canvasData.nodes || []).map(node => ({ const nodes = (canvasData.nodes || []).map(node => ({
id: node.id, id: node.id,
type: node.type === 'file' ? 'file' : node.type === 'ai' ? 'ai' : node.type === 'git' ? 'git' : 'text', type: node.type === 'file' ? 'file' : node.type === 'ai' ? 'ai' : node.type === 'git' ? 'git' : node.type === 'task' ? 'task' : node.type === 'webhook' ? 'webhook' : 'text',
position: { x: node.x, y: node.y }, position: { x: Number(node.x) || 0, y: Number(node.y) || 0 },
data: { data: {
...node, ...node,
width: node.width || 300, width: node.width || 300,
height: node.height || 200, height: node.height || 200,
label: node.label || node.text?.slice(0, 20) || node.file || 'Node' label: node.label || node.text?.slice(0, 20) || node.file || 'Node',
// Ensure specific fields exist so the properties panel shows them
...(node.type === 'task' && {
text: node.text ?? '',
state: node.state ?? 'todo',
priority: node.priority ?? 'medium',
assigned_to: node.assigned_to ?? '',
alarm: node.alarm ?? '',
script_id: node.script_id ?? ''
}),
...(node.type === 'text' && { text: node.text ?? '' }),
...(node.type === 'file' && { file: node.file ?? '' }),
...(node.type === 'ai' && { prompt: node.prompt ?? '' }),
...(node.type === 'webhook' && {
script_id: node.script_id ?? '',
url: node.url ?? '',
method: node.method ?? 'POST'
}),
...(node.type === 'git' && {
repo: node.repo ?? '',
branch: node.branch ?? 'main',
message: node.message ?? ''
})
}, },
style: { style: {
width: `${node.width || 300}px`, width: `${node.width || 300}px`,
@@ -215,18 +245,25 @@ const transformToFlowElements = (canvasData) => {
const transformToObsidianCanvas = (flowElements) => { const transformToObsidianCanvas = (flowElements) => {
const nodes = flowElements.filter(el => el.position !== undefined).map(node => ({ const nodes = flowElements.filter(el => el.position !== undefined).map(node => ({
id: node.id, id: node.id,
type: node.type === 'file' ? 'file' : 'text', type: node.type === 'file' ? 'file' : node.type === 'task' ? 'task' : node.type === 'ai' ? 'ai' : node.type === 'webhook' ? 'webhook' : node.type === 'git' ? 'git' : 'text',
x: Math.round(node.position.x), x: Math.round(Number(node.position.x) || 0),
y: Math.round(node.position.y), y: Math.round(Number(node.position.y) || 0),
width: node.data.width || 300, width: node.data.width || 300,
height: node.data.height || 200, height: node.data.height || 200,
text: node.data.text, text: node.data.text,
label: node.data.label,
file: node.data.file, file: node.data.file,
state: node.data.state, state: node.data.state,
priority: node.data.priority,
assigned_to: node.data.assigned_to, assigned_to: node.data.assigned_to,
alarm: node.data.alarm, alarm: node.data.alarm,
script_id: node.data.script_id, script_id: node.data.script_id,
prompt: node.data.prompt, prompt: node.data.prompt,
url: node.data.url,
method: node.data.method,
repo: node.data.repo,
branch: node.data.branch,
message: node.data.message,
style: { style: {
background: node.style?.background || '#1e293b', background: node.style?.background || '#1e293b',
border: node.style?.border?.replace('1px solid ', '') || '#334155' border: node.style?.border?.replace('1px solid ', '') || '#334155'
@@ -281,7 +318,8 @@ const createNode = (type, x, y) => {
nodeData.data.file = 'docs/file.md' nodeData.data.file = 'docs/file.md'
break break
case 'task': case 'task':
nodeData.data.state = 'pending' nodeData.data.state = 'todo'
nodeData.data.priority = 'medium'
nodeData.data.assigned_to = '' nodeData.data.assigned_to = ''
nodeData.data.alarm = '' nodeData.data.alarm = ''
nodeData.data.script_id = '' nodeData.data.script_id = ''
@@ -324,10 +362,13 @@ const commitNodeChange = () => {
// Trigger reactivity // Trigger reactivity
const idx = elements.value.findIndex(el => el.id === selectedNode.value.id) const idx = elements.value.findIndex(el => el.id === selectedNode.value.id)
if (idx !== -1) { if (idx !== -1) {
elements.value[idx].data = { ...selectedNode.value.data }
elements.value[idx].style = { ...selectedNode.value.style }
elements.value[idx].type = selectedNode.value.type
elements.value.splice(idx, 1, { ...elements.value[idx] }) elements.value.splice(idx, 1, { ...elements.value[idx] })
} }
// Track changes // Track changes
unsavedChanges.trackChanges(transformToObsidianCanvas(elements.value)) trackChanges(transformToObsidianCanvas(elements.value))
} }
} }
@@ -357,15 +398,25 @@ const onNodeDragStop = (event) => {
} }
// Track changes // Track changes
unsavedChanges.trackChanges(transformToObsidianCanvas(elements.value)) trackChanges(transformToObsidianCanvas(elements.value))
console.log('Node drag stopped:', node.id, 'at position:', node.position) console.log('Node drag stopped:', node.id, 'at position:', node.position)
} }
const onNodeDoubleClick = (event) => { const onNodeDoubleClick = (event) => {
const node = event.node const node = event.node
const currentState = node.data?.state || 'pending' const currentState = node.data?.state || 'todo'
const nextState = currentState === 'pending' ? 'in_progress' : currentState === 'in_progress' ? 'completed' : 'pending'
// Quick toggle workflow: todo -> in_progress -> review -> completed -> todo
const nextStateMap = {
'todo': 'in_progress',
'in_progress': 'review',
'review': 'completed',
'completed': 'todo',
'blocked': 'todo' // From blocked back to todo
}
const nextState = nextStateMap[currentState] || 'todo'
updateNodeData({ id: node.id, data: { state: nextState } }) updateNodeData({ id: node.id, data: { state: nextState } })
} }
@@ -475,15 +526,20 @@ const manualGitSync = async () => {
// Enhanced save function with Git sync // Enhanced save function with Git sync
const saveCanvas = async () => { const saveCanvas = async () => {
try { try {
console.log('[DEBUG saveCanvas] Starting save...')
const obsidianCanvas = transformToObsidianCanvas(elements.value) const obsidianCanvas = transformToObsidianCanvas(elements.value)
await $fetch(`/api/canvas/${props.canvasId}`, {
console.log('[DEBUG saveCanvas] Sending PUT request...')
const res = await $fetch(`/api/canvas/${props.canvasId}`, {
method: 'PUT', method: 'PUT',
body: obsidianCanvas, body: { ...obsidianCanvas, name: canvasName.value },
headers: authHeaders.value headers: authHeaders.value
}) })
console.log('[DEBUG saveCanvas] PUT request successful!', res)
// Mark as saved // Mark as saved
unsavedChanges.markAsSaved(obsidianCanvas) markAsSaved(obsidianCanvas)
console.log('[DEBUG saveCanvas] Marked as saved in UI')
// Auto-sync to Git if repository exists // Auto-sync to Git if repository exists
await gitSync.autoSyncCanvas(props.canvasId, obsidianCanvas) await gitSync.autoSyncCanvas(props.canvasId, obsidianCanvas)
@@ -494,11 +550,25 @@ const saveCanvas = async () => {
} }
} }
const usersList = ref([])
// Load data on mount // Load data on mount
onMounted(async () => { onMounted(async () => {
await loadData() await loadData()
// Check for existing Git repository // Check for existing Git repository
await gitSync.checkRepository(props.canvasId) await gitSync.checkRepository(props.canvasId)
// Load users for assignee dropdown
try {
const res = await fetch('/api/users', {
headers: authHeaders.value
})
if (res.ok) {
usersList.value = await res.json()
}
} catch (e) {
console.error('Failed to load users', e)
}
}) })
</script> </script>
+39 -8
View File
@@ -18,6 +18,9 @@
<div v-if="data.state" :class="stateBadgeClass"> <div v-if="data.state" :class="stateBadgeClass">
{{ stateLabel }} {{ stateLabel }}
</div> </div>
<div v-if="data.priority" :class="priorityBadgeClass">
{{ priorityLabel }}
</div>
</div> </div>
<!-- Content preview for measurement --> <!-- Content preview for measurement -->
@@ -99,9 +102,15 @@ const nodeStyle = computed(() => {
} }
if (props.data.state === 'completed') { if (props.data.state === 'completed') {
baseStyle.borderColor = '#10b981' baseStyle.borderColor = '#10b981' // Green
} else if (props.data.state === 'in_progress') { } else if (props.data.state === 'in_progress') {
baseStyle.borderColor = '#f59e0b' baseStyle.borderColor = '#3b82f6' // Blue
} else if (props.data.state === 'review') {
baseStyle.borderColor = '#a855f7' // Purple
} else if (props.data.state === 'blocked') {
baseStyle.borderColor = '#ef4444' // Red
} else if (props.data.state === 'todo') {
baseStyle.borderColor = '#64748b' // Slate
} }
return baseStyle return baseStyle
@@ -124,22 +133,44 @@ const nodeIcon = computed(() => {
const nodeStateClass = computed(() => { const nodeStateClass = computed(() => {
const state = props.data.state const state = props.data.state
if (state === 'completed') return 'border-emerald-600 bg-emerald-950/20' if (state === 'completed') return 'border-emerald-600 bg-emerald-950/20'
if (state === 'in_progress') return 'border-yellow-600 bg-yellow-950/20' if (state === 'in_progress') return 'border-blue-600 bg-blue-950/20'
if (state === 'review') return 'border-purple-600 bg-purple-950/20'
if (state === 'blocked') return 'border-red-600 bg-red-950/20'
if (state === 'todo') return 'border-slate-500 bg-slate-900/50'
return '' return ''
}) })
const stateBadgeClass = computed(() => { const stateBadgeClass = computed(() => {
const state = props.data.state const state = props.data.state
if (state === 'completed') return 'px-1.5 py-0.5 bg-emerald-800/50 text-emerald-300 rounded text-[10px] font-medium' if (state === 'completed') return 'px-1.5 py-0.5 bg-emerald-800/50 text-emerald-300 rounded text-[10px] font-medium'
if (state === 'in_progress') return 'px-1.5 py-0.5 bg-yellow-800/50 text-yellow-300 rounded text-[10px] font-medium' if (state === 'in_progress') return 'px-1.5 py-0.5 bg-blue-800/50 text-blue-300 rounded text-[10px] font-medium'
return 'px-1.5 py-0.5 bg-slate-700 text-slate-400 rounded text-[10px] font-medium' if (state === 'review') return 'px-1.5 py-0.5 bg-purple-800/50 text-purple-300 rounded text-[10px] font-medium'
if (state === 'blocked') return 'px-1.5 py-0.5 bg-red-800/50 text-red-300 rounded text-[10px] font-medium'
return 'px-1.5 py-0.5 bg-slate-700 text-slate-300 rounded text-[10px] font-medium'
}) })
const stateLabel = computed(() => { const stateLabel = computed(() => {
const state = props.data.state const state = props.data.state
if (state === 'completed') return '✓' if (state === 'completed') return '✅ Done'
if (state === 'in_progress') return '⟳' if (state === 'in_progress') return '⏳ In Progress'
return '○' if (state === 'review') return '👀 Review'
if (state === 'blocked') return '⛔ Blocked'
return '📋 To Do'
})
// Priority handling
const priorityBadgeClass = computed(() => {
const prio = props.data.priority
if (prio === 'high') return 'px-1.5 py-0.5 bg-red-800/50 text-red-300 rounded text-[10px] font-bold'
if (prio === 'low') return 'px-1.5 py-0.5 bg-slate-800/50 text-slate-400 rounded text-[10px] font-medium'
return 'px-1.5 py-0.5 bg-blue-800/50 text-blue-300 rounded text-[10px] font-medium' // medium
})
const priorityLabel = computed(() => {
const prio = props.data.priority
if (prio === 'high') return '🔥 High'
if (prio === 'low') return '🧊 Low'
return '⚡ Med'
}) })
// Get node title from text content // Get node title from text content
+11
View File
@@ -47,9 +47,20 @@ export const useCanvas = () => {
width: n.width || 300, width: n.width || 300,
height: n.height || 200, height: n.height || 200,
text: n.text, text: n.text,
label: n.label,
file: n.file, file: n.file,
frontmatter: n.frontmatter, frontmatter: n.frontmatter,
state: n.state || 'pending', state: n.state || 'pending',
priority: n.priority,
assigned_to: n.assigned_to,
alarm: n.alarm,
script_id: n.script_id,
prompt: n.prompt,
url: n.url,
method: n.method,
repo: n.repo,
branch: n.branch,
message: n.message,
style: n.style || {} style: n.style || {}
})) }))
+6
View File
@@ -10,8 +10,13 @@ export const useUnsavedChanges = () => {
if (lastSavedData.value && currentData.value !== lastSavedData.value) { if (lastSavedData.value && currentData.value !== lastSavedData.value) {
isDirty.value = true isDirty.value = true
console.log('[DEBUG Unsaved] Changed to dirty because data is different')
} else if (!lastSavedData.value) { } else if (!lastSavedData.value) {
isDirty.value = true isDirty.value = true
console.log('[DEBUG Unsaved] Changed to dirty because no lastSavedData')
} else {
isDirty.value = false
console.log('[DEBUG Unsaved] Not dirty, data is identical')
} }
} }
@@ -20,6 +25,7 @@ export const useUnsavedChanges = () => {
lastSavedData.value = JSON.stringify(data) lastSavedData.value = JSON.stringify(data)
currentData.value = lastSavedData.value currentData.value = lastSavedData.value
isDirty.value = false isDirty.value = false
console.log('[DEBUG Unsaved] Marked as saved, isDirty = false')
} }
// Reset dirty state // Reset dirty state
+2 -3
View File
@@ -5,9 +5,8 @@
<div class="flex items-center justify-between h-14"> <div class="flex items-center justify-between h-14">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<NuxtLink to="/" class="text-xl font-bold text-blue-400">XFlow</NuxtLink> <NuxtLink to="/" class="text-xl font-bold text-blue-400">XFlow</NuxtLink>
<NuxtLink to="/canvases" class="text-sm text-slate-400 hover:text-white transition">Карты</NuxtLink> <NuxtLink to="/canvases" class="text-sm text-slate-400 hover:text-white transition">Проекты</NuxtLink>
<NuxtLink to="/canvas" class="text-sm text-slate-400 hover:text-white transition">Редактор</NuxtLink> <NuxtLink to="/tasks" class="text-sm text-slate-400 hover:text-white transition">Задачи</NuxtLink>
<NuxtLink to="/flow" class="text-sm text-slate-400 hover:text-white transition">Поток</NuxtLink>
<NuxtLink v-if="isAdmin" to="/admin" class="text-sm text-slate-400 hover:text-white transition">Админ</NuxtLink> <NuxtLink v-if="isAdmin" to="/admin" class="text-sm text-slate-400 hover:text-white transition">Админ</NuxtLink>
<NuxtLink to="/settings" class="text-sm text-slate-400 hover:text-white transition">Настройки</NuxtLink> <NuxtLink to="/settings" class="text-sm text-slate-400 hover:text-white transition">Настройки</NuxtLink>
</div> </div>
+170
View File
@@ -0,0 +1,170 @@
<template>
<div class="min-h-screen bg-slate-900 text-slate-200">
<div class="max-w-7xl mx-auto px-4 py-8">
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold text-white flex items-center gap-3">
<span class="text-blue-500">📋</span> Мои Задачи
</h1>
</div>
<div v-if="loading" class="flex items-center justify-center h-64">
<div class="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<div v-else-if="tasks.length === 0" class="flex flex-col items-center justify-center h-64 bg-slate-800 rounded-xl border border-slate-700">
<span class="text-4xl mb-4">📭</span>
<h3 class="text-xl font-medium text-white mb-2">У вас пока нет задач</h3>
<p class="text-slate-400">Вам не назначено ни одной задачи в проектах.</p>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- Kanban Columns -->
<div v-for="col in columns" :key="col.id" class="bg-slate-800 rounded-xl border border-slate-700 flex flex-col max-h-[80vh]">
<div class="p-4 border-b border-slate-700 flex items-center justify-between sticky top-0 bg-slate-800 rounded-t-xl z-10">
<h3 class="font-bold text-white flex items-center gap-2">
<span :class="col.color">{{ col.icon }}</span> {{ col.title }}
</h3>
<span class="bg-slate-700 text-slate-300 text-xs px-2 py-1 rounded-full font-medium">
{{ getTasksForColumn(col.id).length }}
</span>
</div>
<div class="p-4 flex-1 overflow-y-auto space-y-4 custom-scrollbar">
<div
v-for="task in getTasksForColumn(col.id)"
:key="task.id"
class="bg-slate-900 border border-slate-600 rounded-lg p-4 shadow-sm hover:border-blue-500 transition-colors cursor-pointer group"
@click="openTask(task)"
>
<div class="flex items-start justify-between mb-2">
<div class="text-xs font-medium px-2 py-0.5 rounded" :class="getPriorityClass(task)">
{{ getPriorityLabel(task) }}
</div>
<div class="text-[10px] text-slate-500 font-mono">{{ task.id.split('-')[1] || task.id }}</div>
</div>
<h4 class="font-medium text-white mb-2 line-clamp-2">{{ getTaskTitle(task) }}</h4>
<div class="flex items-center justify-between mt-4">
<div class="text-xs text-slate-400 flex items-center gap-1">
<span>🗺️</span>
<span class="truncate max-w-[120px]">{{ task.canvasName }}</span>
</div>
<div v-if="task.alarm || task.data?.alarm" class="text-xs text-orange-400 flex items-center gap-1" :title="new Date(task.alarm || task.data?.alarm).toLocaleString('ru-RU')">
<span>⏰</span>
<span>{{ formatShortDate(task.alarm || task.data?.alarm) }}</span>
</div>
</div>
</div>
<div v-if="getTasksForColumn(col.id).length === 0" class="text-center py-6 border-2 border-dashed border-slate-700 rounded-lg">
<span class="text-slate-500 text-sm">Нет задач</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
const auth = useAuth()
const loading = ref(true)
const tasks = ref([])
const columns = [
{ id: 'todo', title: 'К выполнению', icon: '📋', color: 'text-slate-400' },
{ id: 'in_progress', title: 'В работе', icon: '⏳', color: 'text-blue-400' },
{ id: 'review', title: 'На проверке', icon: '👀', color: 'text-purple-400' },
{ id: 'blocked', title: 'Заблокировано', icon: '⛔', color: 'text-red-400' },
{ id: 'completed', title: 'Завершено', icon: '✅', color: 'text-emerald-400' }
]
// Fetch all tasks for current user
const fetchTasks = async () => {
try {
const res = await fetch('/api/tasks', {
headers: { Authorization: `Bearer ${auth.token.value}` }
})
if (res.ok) {
const data = await res.json()
tasks.value = data.tasks || []
}
} catch (e) {
console.error('Failed to load tasks', e)
} finally {
loading.value = false
}
}
const getTasksForColumn = (state) => {
return tasks.value.filter(t => {
const taskState = t.state || t.data?.state || 'todo'
return taskState === state
})
}
const getTaskTitle = (task) => {
if (task.label) return task.label
const text = task.text || task.data?.text
if (text) {
const lines = text.split('\n')
const firstLine = lines.find(l => l.trim().startsWith('# ')) || lines.find(l => l.trim())
return firstLine ? firstLine.replace('# ', '').trim().slice(0, 50) : 'Без названия'
}
return 'Задача'
}
const getPriorityClass = (task) => {
const priority = task.priority || task.data?.priority
if (priority === 'high') return 'bg-red-900/50 text-red-300'
if (priority === 'low') return 'bg-slate-700 text-slate-300'
return 'bg-blue-900/50 text-blue-300' // medium
}
const getPriorityLabel = (task) => {
const priority = task.priority || task.data?.priority
if (priority === 'high') return 'Высокий'
if (priority === 'low') return 'Низкий'
return 'Средний'
}
const formatShortDate = (dateStr) => {
try {
const d = new Date(dateStr)
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
} catch {
return dateStr
}
}
const openTask = (task) => {
navigateTo(`/canvas/${task.canvasId}`)
}
onMounted(async () => {
if (!auth.user.value) {
await auth.init()
}
await fetchTasks()
})
</script>
<style scoped>
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: #475569 transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: #475569;
border-radius: 4px;
}
</style>
+6 -1
View File
@@ -10,7 +10,12 @@ export default defineEventHandler(async (event) => {
const existing = await prisma.canvas.findUnique({ where: { id } }) const existing = await prisma.canvas.findUnique({ where: { id } })
if (!existing) throw createError({ statusCode: 404, message: 'Canvas not found' }) if (!existing) throw createError({ statusCode: 404, message: 'Canvas not found' })
if (existing.ownerId !== user.id) throw createError({ statusCode: 403, message: 'Access denied' })
// Allow if owner, or if admin, or if canvas is public (for collaborative editing)
if (existing.ownerId !== user.id && user.role !== 'admin' && !existing.isPublic) {
console.log('[DEBUG 403]', { ownerId: existing.ownerId, userId: user.id, userRole: user.role, isPublic: existing.isPublic })
throw createError({ statusCode: 403, message: 'Access denied' })
}
const { name, nodes, edges, isPublic, groupId } = body const { name, nodes, edges, isPublic, groupId } = body
const canvas = await prisma.canvas.update({ const canvas = await prisma.canvas.update({
+56
View File
@@ -0,0 +1,56 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const userGroups = await prisma.groupMember.findMany({
where: { userId: user.id },
select: { groupId: true }
})
const groupIds = userGroups.map(m => m.groupId)
// Get all canvases user has access to
const canvasList = await prisma.canvas.findMany({
where: {
OR: [
{ ownerId: user.id },
{ isPublic: true },
{ groupId: { in: groupIds } }
]
},
select: {
id: true,
name: true,
nodes: true
}
})
// Extract tasks from all canvases
const allTasks = []
for (const canvas of canvasList) {
const nodes = Array.isArray(canvas.nodes) ? canvas.nodes :
(typeof canvas.nodes === 'string' ? JSON.parse(canvas.nodes) : [])
if (!Array.isArray(nodes)) continue
const canvasTasks = nodes.filter(n => {
const type = n.type || n.data?.type
if (type !== 'task') return false
// Temporary: Show ALL tasks regardless of assignee so you can see them!
return true
})
for (const t of canvasTasks) {
allTasks.push({
...t,
canvasId: canvas.id,
canvasName: canvas.name
})
}
}
return { tasks: allTasks }
})
+32
View File
@@ -0,0 +1,32 @@
import { defineEventHandler } from 'h3'
import { prisma } from '~/server/utils/prisma'
import { requireAuth } from '~/server/utils/auth'
export default defineEventHandler(async (event) => {
const user = await requireAuth(event)
if (!user) return []
const users = await prisma.user.findMany({
select: {
id: true,
username: true,
email: true,
role: true,
profile: {
select: {
displayName: true,
avatar: true
}
}
}
})
return users.map(u => ({
id: u.id,
username: u.username,
email: u.email,
role: u.role,
displayName: u.profile?.displayName || u.username,
avatar: u.profile?.avatar
}))
})