feat: implement cross-canvas linking, enhanced node type selection, and canvas management

This commit is contained in:
2026-04-28 23:16:11 +05:00
parent 1f87f0d6e9
commit 89b3340253
5 changed files with 107 additions and 15 deletions
+81 -7
View File
@@ -29,6 +29,11 @@
</span>
<button @click="addNodeAtCenter" class="px-2 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded text-xs">+ Узел</button>
<button @click="saveCanvas" class="px-2 py-1 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-xs">Сохранить</button>
<button @click="deleteCanvas" class="p-2 text-slate-400 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition" title="Удалить карту">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<button @click="exportObsidian" class="px-2 py-1 bg-purple-600 hover:bg-purple-500 text-white rounded text-xs">Экспорт</button>
<button @click="deleteSelected" :disabled="!selectedNode" class="px-2 py-1 bg-red-600 hover:bg-red-500 disabled:bg-slate-700 disabled:text-slate-500 text-white rounded text-xs">Удалить</button>
<div class="flex-1"></div>
@@ -78,7 +83,14 @@
</div>
<div class="flex-1 overflow-y-auto p-3">
<div v-if="selectedNode" class="space-y-3">
<!-- Hidden internal ID & Type for cleaner UI -->
<div>
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Тип узла</label>
<select v-model="selectedNode.type" @change="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
<option v-for="nt in nodeTypes" :key="nt.type" :value="nt.type">
{{ nt.icon }} {{ nt.name }}
</option>
</select>
</div>
<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" />
@@ -91,7 +103,7 @@
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Файл</label>
<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">
<div v-if="selectedNode.type === 'task'">
<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="todo">📋 К выполнению (To Do)</option>
@@ -101,7 +113,7 @@
<option value="blocked">⛔ Заблокировано (Blocked)</option>
</select>
</div>
<div v-if="selectedNode.data.priority !== undefined">
<div v-if="selectedNode.type === 'task'">
<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>
@@ -109,7 +121,7 @@
<option value="high">🔥 Высокий</option>
</select>
</div>
<div v-if="selectedNode.data.assigned_to !== undefined">
<div v-if="selectedNode.type === 'task'">
<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>
@@ -118,7 +130,7 @@
</option>
</select>
</div>
<div v-if="selectedNode.data.alarm !== undefined">
<div v-if="selectedNode.type === 'task'">
<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>
@@ -126,6 +138,15 @@
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Скрипт</label>
<input v-model="selectedNode.data.script_id" @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 font-mono" />
</div>
<div v-if="selectedNode.type === 'file' || selectedNode.data.file !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Ссылка на Карту</label>
<select v-model="selectedNode.data.file" @change="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">
<option value="">Не выбрано</option>
<option v-for="c in canvasesList" :key="c.id" :value="c.id">
🗺️ {{ c.name || c.id }}
</option>
</select>
</div>
<div v-if="selectedNode.data.prompt !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">AI Запрос</label>
<textarea v-model="selectedNode.data.prompt" @blur="commitNodeChange" rows="3" 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 resize-none font-mono"></textarea>
@@ -166,7 +187,7 @@ const auth = useAuth()
const authHeaders = computed(() => ({ Authorization: `Bearer ${auth.token.value}` }))
const elements = ref([])
const selectedNode = ref(null)
const canvasName = computed(() => props.canvasId)
const canvasName = ref(props.canvasId)
const nodeTypes = [
{ type: 'text', name: 'Текст', icon: '📝', description: 'Markdown заметка' },
@@ -181,6 +202,7 @@ const nodeTypes = [
const loadData = async () => {
const data = await loadCanvas(props.canvasId)
elements.value = transformToFlowElements(data)
canvasName.value = data.name || props.canvasId
// Mark initial state as saved
markAsSaved(data)
}
@@ -405,6 +427,23 @@ const onNodeDragStop = (event) => {
const onNodeDoubleClick = (event) => {
const node = event.node
// 1. Cross-Canvas Navigation Logic
// Check if it's a file node or has a URL/file reference to another canvas
const targetId = node.data?.file || node.data?.url || ''
if (targetId && (node.type === 'file' || targetId.includes('.canvas') || targetId.startsWith('/canvas/'))) {
// Clean up ID (e.g. "project.canvas" -> "project", "/canvas/project" -> "project")
const cleanId = targetId.replace('.canvas', '').replace('/canvas/', '')
console.log('[Navigation] Opening cross-linked canvas:', cleanId)
navigateTo(`/canvas/${cleanId}`)
return // Stop further processing
} else if (node.type === 'file' && !targetId) {
alert('У этого узла еще нет ссылки на другую карту. Выберите карту в панели свойств (справа).')
return
}
// 2. Task Toggle Logic (fallback)
const currentState = node.data?.state || 'todo'
// Quick toggle workflow: todo -> in_progress -> review -> completed -> todo
@@ -413,7 +452,7 @@ const onNodeDoubleClick = (event) => {
'in_progress': 'review',
'review': 'completed',
'completed': 'todo',
'blocked': 'todo' // From blocked back to todo
'blocked': 'todo'
}
const nextState = nextStateMap[currentState] || 'todo'
@@ -446,6 +485,27 @@ const exportObsidian = () => {
URL.revokeObjectURL(url)
}
const deleteCanvas = async () => {
if (!confirm('Вы уверены, что хотите полностью удалить эту карту? Это действие необратимо.')) return
try {
const res = await fetch(`/api/canvas/${props.canvasId}`, {
method: 'DELETE',
headers: authHeaders.value
})
if (res.ok) {
navigateTo('/canvases')
} else {
const err = await res.json()
alert('Ошибка при удалении: ' + (err.message || 'неизвестная ошибка'))
}
} catch (err) {
console.error('Delete error:', err)
alert('Не удалось удалить карту')
}
}
const fitView = () => {
const padding = 40
const cols = 3
@@ -551,6 +611,7 @@ const saveCanvas = async () => {
}
const usersList = ref([])
const canvasesList = ref([])
// Load data on mount
onMounted(async () => {
@@ -569,6 +630,19 @@ onMounted(async () => {
} catch (e) {
console.error('Failed to load users', e)
}
// Load canvases for cross-link dropdown
try {
const res = await fetch('/api/canvas', {
headers: authHeaders.value
})
if (res.ok) {
const data = await res.json()
canvasesList.value = data.canvases || []
}
} catch (e) {
console.error('Failed to load canvases', e)
}
})
</script>
+2 -2
View File
@@ -15,10 +15,10 @@
<div class="font-semibold text-white truncate">{{ data.label || getNodeTitle() }}</div>
<div class="text-xs text-slate-400 uppercase tracking-wider">{{ data.type || type }}</div>
</div>
<div v-if="data.state" :class="stateBadgeClass">
<div v-if="type === 'task' && data.state" :class="stateBadgeClass">
{{ stateLabel }}
</div>
<div v-if="data.priority" :class="priorityBadgeClass">
<div v-if="type === 'task' && data.priority" :class="priorityBadgeClass">
{{ priorityLabel }}
</div>
</div>