feat: enhance task persistence and implement centralized kanban board
This commit is contained in:
+103
-33
@@ -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>
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
<div v-if="data.state" :class="stateBadgeClass">
|
||||
{{ stateLabel }}
|
||||
</div>
|
||||
<div v-if="data.priority" :class="priorityBadgeClass">
|
||||
{{ priorityLabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content preview for measurement -->
|
||||
@@ -99,9 +102,15 @@ const nodeStyle = computed(() => {
|
||||
}
|
||||
|
||||
if (props.data.state === 'completed') {
|
||||
baseStyle.borderColor = '#10b981'
|
||||
baseStyle.borderColor = '#10b981' // Green
|
||||
} 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
|
||||
@@ -124,22 +133,44 @@ const nodeIcon = computed(() => {
|
||||
const nodeStateClass = computed(() => {
|
||||
const state = props.data.state
|
||||
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 ''
|
||||
})
|
||||
|
||||
const stateBadgeClass = computed(() => {
|
||||
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 === 'in_progress') return 'px-1.5 py-0.5 bg-yellow-800/50 text-yellow-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 === 'in_progress') return 'px-1.5 py-0.5 bg-blue-800/50 text-blue-300 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 state = props.data.state
|
||||
if (state === 'completed') return '✓'
|
||||
if (state === 'in_progress') return '⟳'
|
||||
return '○'
|
||||
if (state === 'completed') return '✅ Done'
|
||||
if (state === 'in_progress') return '⏳ In Progress'
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user