feat: prisma schema, groups API, deploy workflow
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
<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="data.state" :class="stateBadgeClass">
|
||||
{{ stateLabel }}
|
||||
</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'
|
||||
} else if (props.data.state === 'in_progress') {
|
||||
baseStyle.borderColor = '#f59e0b'
|
||||
}
|
||||
|
||||
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-yellow-600 bg-yellow-950/20'
|
||||
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'
|
||||
})
|
||||
|
||||
const stateLabel = computed(() => {
|
||||
const state = props.data.state
|
||||
if (state === 'completed') return '✓'
|
||||
if (state === 'in_progress') return '⟳'
|
||||
return '○'
|
||||
})
|
||||
|
||||
// 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>
|
||||
Reference in New Issue
Block a user