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">
<!-- Toolbar -->
<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>
<span v-if="unsavedChanges.isDirty" class="px-2 py-1 bg-yellow-600 text-yellow-100 rounded text-xs font-medium">
<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="isDirty" class="px-2 py-1 bg-yellow-600 text-yellow-100 rounded text-xs font-medium">
● Несохранено
</span>
<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 class="flex-1 overflow-y-auto p-3">
<div v-if="selectedNode" class="space-y-3">
<div>
<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>
<!-- Hidden internal ID & Type for cleaner UI -->
<div>
<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" />
@@ -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" />
</div>
<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">
<option value="pending">Ожидает</option>
<option value="in_progress">В процессе</option>
<option value="completed">Завершено</option>
<option value="todo">📋 К выполнению (To Do)</option>
<option value="in_progress">⏳ В работе (In Progress)</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>
</div>
<div v-if="selectedNode.data.assigned_to !== undefined">
<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" />
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Исполнитель</label>
<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 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" />
</div>
<div v-if="selectedNode.data.script_id !== undefined">
@@ -153,7 +161,7 @@ const props = defineProps({
const { loadCanvas, updateNodeState: apiUpdateNodeState } = useCanvas()
const gitSync = useGitSync()
const unsavedChanges = useUnsavedChanges()
const { isDirty, trackChanges, markAsSaved } = useUnsavedChanges()
const auth = useAuth()
const authHeaders = computed(() => ({ Authorization: `Bearer ${auth.token.value}` }))
const elements = ref([])
@@ -174,20 +182,42 @@ const loadData = async () => {
const data = await loadCanvas(props.canvasId)
elements.value = transformToFlowElements(data)
// Mark initial state as saved
unsavedChanges.markAsSaved(data)
markAsSaved(data)
}
// Transform Obsidian canvas to Vue Flow elements
const transformToFlowElements = (canvasData) => {
const nodes = (canvasData.nodes || []).map(node => ({
id: node.id,
type: node.type === 'file' ? 'file' : node.type === 'ai' ? 'ai' : node.type === 'git' ? 'git' : 'text',
position: { x: node.x, y: node.y },
type: node.type === 'file' ? 'file' : node.type === 'ai' ? 'ai' : node.type === 'git' ? 'git' : node.type === 'task' ? 'task' : node.type === 'webhook' ? 'webhook' : 'text',
position: { x: Number(node.x) || 0, y: Number(node.y) || 0 },
data: {
...node,
width: node.width || 300,
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: {
width: `${node.width || 300}px`,
@@ -215,18 +245,25 @@ const transformToFlowElements = (canvasData) => {
const transformToObsidianCanvas = (flowElements) => {
const nodes = flowElements.filter(el => el.position !== undefined).map(node => ({
id: node.id,
type: node.type === 'file' ? 'file' : 'text',
x: Math.round(node.position.x),
y: Math.round(node.position.y),
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(Number(node.position.x) || 0),
y: Math.round(Number(node.position.y) || 0),
width: node.data.width || 300,
height: node.data.height || 200,
text: node.data.text,
label: node.data.label,
file: node.data.file,
state: node.data.state,
priority: node.data.priority,
assigned_to: node.data.assigned_to,
alarm: node.data.alarm,
script_id: node.data.script_id,
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: {
background: node.style?.background || '#1e293b',
border: node.style?.border?.replace('1px solid ', '') || '#334155'
@@ -281,7 +318,8 @@ const createNode = (type, x, y) => {
nodeData.data.file = 'docs/file.md'
break
case 'task':
nodeData.data.state = 'pending'
nodeData.data.state = 'todo'
nodeData.data.priority = 'medium'
nodeData.data.assigned_to = ''
nodeData.data.alarm = ''
nodeData.data.script_id = ''
@@ -324,10 +362,13 @@ const commitNodeChange = () => {
// Trigger reactivity
const idx = elements.value.findIndex(el => el.id === selectedNode.value.id)
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] })
}
// Track changes
unsavedChanges.trackChanges(transformToObsidianCanvas(elements.value))
trackChanges(transformToObsidianCanvas(elements.value))
}
}
@@ -357,15 +398,25 @@ const onNodeDragStop = (event) => {
}
// Track changes
unsavedChanges.trackChanges(transformToObsidianCanvas(elements.value))
trackChanges(transformToObsidianCanvas(elements.value))
console.log('Node drag stopped:', node.id, 'at position:', node.position)
}
const onNodeDoubleClick = (event) => {
const node = event.node
const currentState = node.data?.state || 'pending'
const nextState = currentState === 'pending' ? 'in_progress' : currentState === 'in_progress' ? 'completed' : 'pending'
const currentState = node.data?.state || 'todo'
// 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 } })
}
@@ -475,15 +526,20 @@ const manualGitSync = async () => {
// Enhanced save function with Git sync
const saveCanvas = async () => {
try {
console.log('[DEBUG saveCanvas] Starting save...')
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',
body: obsidianCanvas,
body: { ...obsidianCanvas, name: canvasName.value },
headers: authHeaders.value
})
console.log('[DEBUG saveCanvas] PUT request successful!', res)
// Mark as saved
unsavedChanges.markAsSaved(obsidianCanvas)
markAsSaved(obsidianCanvas)
console.log('[DEBUG saveCanvas] Marked as saved in UI')
// Auto-sync to Git if repository exists
await gitSync.autoSyncCanvas(props.canvasId, obsidianCanvas)
@@ -494,11 +550,25 @@ const saveCanvas = async () => {
}
}
const usersList = ref([])
// Load data on mount
onMounted(async () => {
await loadData()
// Check for existing Git repository
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>