feat: implement cross-canvas linking, enhanced node type selection, and canvas management
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user