281 lines
9.2 KiB
Vue
281 lines
9.2 KiB
Vue
<template>
|
|
<div
|
|
ref="rootEl"
|
|
:class="[
|
|
'bg-slate-800 border rounded-lg shadow-lg p-4 text-sm font-sans flex flex-col h-full w-full',
|
|
selected ? 'ring-2 ring-blue-500' : '',
|
|
nodeStateClass
|
|
]"
|
|
:style="nodeStyle"
|
|
>
|
|
<!-- Header with icon and label -->
|
|
<div class="flex items-center gap-2 mb-3 pb-2 border-b border-slate-600">
|
|
<span class="text-lg">{{ nodeIcon }}</span>
|
|
<div class="flex-1 min-w-0">
|
|
<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="type === 'task' && data.state" :class="stateBadgeClass">
|
|
{{ stateLabel }}
|
|
</div>
|
|
<div v-if="type === 'task' && data.priority" :class="priorityBadgeClass">
|
|
{{ priorityLabel }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Content preview for measurement -->
|
|
<div class="text-slate-100 space-y-2 flex-1">
|
|
<div v-if="data.text" class="text-slate-300 text-xs leading-relaxed whitespace-pre-wrap mb-3" v-html="formattedText"></div>
|
|
<div v-if="data.file" class="flex items-center gap-2">
|
|
<span class="text-sm">📄</span>
|
|
<span class="font-mono text-sm text-blue-400">{{ data.file }}</span>
|
|
</div>
|
|
<div v-if="data.prompt" class="text-sm text-purple-300">
|
|
<div class="font-medium mb-1">AI Prompt:</div>
|
|
<div>{{ data.prompt }}</div>
|
|
</div>
|
|
<div v-if="data.url" class="flex items-center gap-2">
|
|
<span class="text-sm">🔗</span>
|
|
<a :href="data.url" target="_blank" class="text-blue-400 hover:text-blue-300 text-sm break-all">{{ data.url }}</a>
|
|
</div>
|
|
<div v-if="data.script_id" class="flex items-center gap-2">
|
|
<span class="text-sm">🎭</span>
|
|
<span class="font-mono text-sm text-green-400">{{ data.script_id }}</span>
|
|
</div>
|
|
<div v-if="data.assigned_to" class="flex items-center gap-2">
|
|
<span class="text-sm">👤</span>
|
|
<span class="text-sm text-slate-300">{{ data.assigned_to }}</span>
|
|
</div>
|
|
<div v-if="data.alarm" class="flex items-center gap-2">
|
|
<span class="text-sm">⏰</span>
|
|
<span class="text-sm text-orange-400">{{ formatAlarm(data.alarm) }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Connection handles -->
|
|
<Handle type="target" :position="Position.Left" class="w-3 h-3 bg-slate-600 border-2 border-slate-700" />
|
|
<Handle type="source" :position="Position.Right" class="w-3 h-3 bg-slate-600 border-2 border-slate-700" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { Handle, Position, useVueFlow } from '@vue-flow/core'
|
|
import { computed, ref, onMounted, nextTick } from 'vue'
|
|
|
|
const props = defineProps({
|
|
id: { type: String, required: true },
|
|
data: { type: Object, required: true },
|
|
type: { type: String, default: 'text' },
|
|
selected: { type: Boolean, default: false }
|
|
})
|
|
|
|
const emit = defineEmits(['update'])
|
|
|
|
// Root element ref
|
|
const rootEl = ref(null)
|
|
|
|
// Access Vue Flow API to update node dimensions
|
|
const { updateNode } = useVueFlow()
|
|
|
|
// Measure and apply real height to Vue Flow node wrapper
|
|
const applyNodeSize = () => {
|
|
if (!rootEl.value) return
|
|
const h = rootEl.value.scrollHeight
|
|
if (h > 0) {
|
|
updateNode(props.id, {
|
|
style: { height: h + 'px' }
|
|
})
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
nextTick(() => setTimeout(applyNodeSize, 50))
|
|
setTimeout(applyNodeSize, 200)
|
|
})
|
|
|
|
// Node style: fixed width from data, height auto to fit content
|
|
const nodeStyle = computed(() => {
|
|
const baseStyle = {
|
|
backgroundColor: props.data.style?.background || '#1e293b',
|
|
borderColor: props.data.style?.border || '#475569',
|
|
width: `${props.data.width || 320}px`
|
|
}
|
|
|
|
if (props.data.state === 'completed') {
|
|
baseStyle.borderColor = '#10b981' // Green
|
|
} else if (props.data.state === 'in_progress') {
|
|
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
|
|
})
|
|
|
|
// Node type icon
|
|
const nodeIcon = computed(() => {
|
|
const icons = {
|
|
text: '📝',
|
|
file: '📄',
|
|
task: '✅',
|
|
ai: '🤖',
|
|
webhook: '🔗',
|
|
git: '🔄'
|
|
}
|
|
return icons[props.type] || '📝'
|
|
})
|
|
|
|
// State handling
|
|
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-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-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 '✅ 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
|
|
const getNodeTitle = () => {
|
|
if (props.data.text) {
|
|
const lines = props.data.text.split('\n')
|
|
for (const line of lines) {
|
|
const trimmed = line.trim()
|
|
if (trimmed.startsWith('# ')) {
|
|
return trimmed.replace('# ', '')
|
|
}
|
|
}
|
|
// If no h1 found, use first non-empty line
|
|
const firstLine = lines.find(l => l.trim())
|
|
return firstLine ? firstLine.trim().slice(0, 30) : 'Node'
|
|
}
|
|
if (props.data.file) return props.data.file
|
|
return 'Node'
|
|
}
|
|
|
|
// Format text with markdown support
|
|
const formattedText = computed(() => {
|
|
if (!props.data.text) return ''
|
|
|
|
let text = props.data.text
|
|
|
|
// Convert markdown headers
|
|
text = text.replace(/^# (.*$)/gim, '<h1 class="text-lg font-bold text-white mb-2">$1</h1>')
|
|
text = text.replace(/^## (.*$)/gim, '<h2 class="text-base font-semibold text-white mb-2">$1</h2>')
|
|
text = text.replace(/^### (.*$)/gim, '<h3 class="text-sm font-medium text-white mb-1">$1</h3>')
|
|
|
|
// Convert bold text
|
|
text = text.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold text-white">$1</strong>')
|
|
|
|
// Convert italic text
|
|
text = text.replace(/\*(.*?)\*/g, '<em class="italic text-slate-200">$1</em>')
|
|
|
|
// Convert code blocks
|
|
text = text.replace(/```(.*?)```/gs, '<pre class="bg-slate-900 p-2 rounded text-xs font-mono text-slate-300 mb-2 overflow-x-auto"><code>$1</code></pre>')
|
|
|
|
// Convert inline code
|
|
text = text.replace(/`(.*?)`/g, '<code class="bg-slate-700 px-1 rounded text-xs font-mono text-slate-300">$1</code>')
|
|
|
|
// Convert lists
|
|
text = text.replace(/^- (.*$)/gim, '<li class="ml-4 text-slate-200">• $1</li>')
|
|
text = text.replace(/^ - (.*$)/gim, '<li class="ml-8 text-slate-300">◦ $1</li>')
|
|
|
|
// Convert line breaks
|
|
text = text.replace(/\n\n/g, '</p><p class="mb-2 text-slate-200">')
|
|
text = text.replace(/\n/g, '<br>')
|
|
|
|
// Wrap in paragraphs
|
|
if (!text.includes('<p>')) {
|
|
text = '<p class="mb-2 text-slate-200">' + text + '</p>'
|
|
}
|
|
|
|
return text
|
|
})
|
|
|
|
// Format alarm time
|
|
const formatAlarm = (alarm) => {
|
|
try {
|
|
const date = new Date(alarm)
|
|
return date.toLocaleDateString('ru-RU', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
} catch {
|
|
return alarm
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.line-clamp-3 {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 3;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
.line-clamp-2 {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.custom-scrollbar {
|
|
scrollbar-width: thin;
|
|
scrollbar-color: #475569 #1e293b;
|
|
}
|
|
|
|
.custom-scrollbar::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
|
|
.custom-scrollbar::-webkit-scrollbar-track {
|
|
background: #1e293b;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
|
background: #475569;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
background: #64748b;
|
|
}
|
|
</style>
|