mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-08 18:45:33 +08:00
Refine editor toolbar and edge routing
This commit is contained in:
@@ -20,8 +20,8 @@
|
||||
:stroke="edge.color"
|
||||
:stroke-width="edge.highlighted ? 3 : 1.75"
|
||||
:stroke-dasharray="edge.dashed ? '6 6' : undefined"
|
||||
stroke-linecap="'round'"
|
||||
stroke-linejoin="'round'"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="transition-all duration-150"
|
||||
opacity="0.9"
|
||||
/>
|
||||
@@ -238,8 +238,16 @@ interface EdgeDefinition {
|
||||
highlighted: boolean
|
||||
}
|
||||
|
||||
interface NodePosition {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
highlighted?: boolean
|
||||
}
|
||||
|
||||
const edges = computed<EdgeDefinition[]>(() => {
|
||||
const positions = new Map(
|
||||
const positions = new Map<string, NodePosition>(
|
||||
preparedNodes.value.map((node) => [
|
||||
node.id,
|
||||
{
|
||||
@@ -261,7 +269,9 @@ const edges = computed<EdgeDefinition[]>(() => {
|
||||
for (const transition of node.model.transitions || []) {
|
||||
const target = positions.get(transition.target)
|
||||
if (!target) continue
|
||||
const path = computeEdgePath(source, target)
|
||||
const path = computeEdgePath(source, target, {
|
||||
selfLoop: transition.target === node.id,
|
||||
})
|
||||
const color = transitionColor(transition)
|
||||
const dashed = Boolean(transition.autoTrigger || transition.type === 'auto')
|
||||
const highlighted = node.selected || source.highlighted
|
||||
@@ -330,17 +340,129 @@ function transitionColor(transition: DecisionNodeTransition) {
|
||||
}
|
||||
}
|
||||
|
||||
function computeEdgePath(source: { x: number; y: number; width: number; height: number }, target: { x: number; y: number; width: number; height: number }) {
|
||||
type PathPoint = { x: number; y: number }
|
||||
|
||||
function computeEdgePath(
|
||||
source: NodePosition,
|
||||
target: NodePosition,
|
||||
options: { selfLoop?: boolean } = {}
|
||||
) {
|
||||
if (options.selfLoop) {
|
||||
return computeSelfLoopPath(source)
|
||||
}
|
||||
|
||||
const startX = source.x + source.width / 2
|
||||
const startY = source.y + source.height
|
||||
const endX = target.x + target.width / 2
|
||||
const endY = target.y
|
||||
const deltaY = Math.max(80, Math.abs(endY - startY) / 1.5)
|
||||
const control1Y = startY + deltaY
|
||||
const control2Y = endY - deltaY
|
||||
return `M ${startX} ${startY} C ${startX} ${control1Y}, ${endX} ${control2Y}, ${endX} ${endY}`
|
||||
|
||||
if (endY >= startY) {
|
||||
const verticalDistance = endY - startY
|
||||
const deltaY = Math.max(80, verticalDistance / 1.5)
|
||||
const control1Y = startY + deltaY
|
||||
const control2Y = endY - Math.min(deltaY, verticalDistance / 2)
|
||||
return `M ${startX} ${startY} C ${startX} ${control1Y}, ${endX} ${control2Y}, ${endX} ${endY}`
|
||||
}
|
||||
|
||||
const exitOffset = Math.max(24, Math.min(72, source.height * 0.25))
|
||||
const horizontalGap = Math.abs(endX - startX)
|
||||
const horizontalExtra = Math.max(180, horizontalGap * 0.6)
|
||||
const outerRight = Math.max(source.x + source.width, target.x + target.width)
|
||||
const outerLeft = Math.min(source.x, target.x)
|
||||
const direction = endX >= startX ? 1 : -1
|
||||
const sideX = direction === 1 ? outerRight + horizontalExtra : outerLeft - horizontalExtra
|
||||
const dropY = startY + exitOffset
|
||||
const topCandidate = Math.min(source.y, target.y) - 100
|
||||
const topLimit = Math.min(startY - 60, endY - 60)
|
||||
let loopTop = Math.min(topCandidate, topLimit)
|
||||
|
||||
if (!Number.isFinite(loopTop)) {
|
||||
loopTop = topLimit
|
||||
}
|
||||
if (!Number.isFinite(loopTop)) {
|
||||
loopTop = startY - 80
|
||||
}
|
||||
if (loopTop >= dropY - 20) {
|
||||
loopTop = dropY - 60
|
||||
}
|
||||
|
||||
const points: PathPoint[] = [
|
||||
{ x: startX, y: startY },
|
||||
{ x: startX, y: dropY },
|
||||
{ x: sideX, y: dropY },
|
||||
{ x: sideX, y: loopTop },
|
||||
{ x: endX, y: loopTop },
|
||||
{ x: endX, y: endY },
|
||||
]
|
||||
|
||||
return buildSmoothPath(points, 48)
|
||||
}
|
||||
|
||||
function computeSelfLoopPath(node: NodePosition) {
|
||||
const startX = node.x + node.width / 2
|
||||
const startY = node.y + node.height
|
||||
const exitOffset = Math.max(28, Math.min(72, node.height * 0.25))
|
||||
const sideOffset = Math.max(200, node.width * 0.75)
|
||||
const sideX = node.x + node.width + sideOffset
|
||||
const loopTop = node.y - 120
|
||||
|
||||
const points: PathPoint[] = [
|
||||
{ x: startX, y: startY },
|
||||
{ x: startX, y: startY + exitOffset },
|
||||
{ x: sideX, y: startY + exitOffset },
|
||||
{ x: sideX, y: loopTop },
|
||||
{ x: startX, y: loopTop },
|
||||
{ x: startX, y: node.y },
|
||||
]
|
||||
|
||||
return buildSmoothPath(points, 44)
|
||||
}
|
||||
|
||||
function buildSmoothPath(points: PathPoint[], radius = 32) {
|
||||
if (points.length < 2) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const commands: string[] = [`M ${points[0].x} ${points[0].y}`]
|
||||
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
const prev = points[i - 1]
|
||||
const curr = points[i]
|
||||
const next = points[i + 1]
|
||||
|
||||
if (!next) {
|
||||
commands.push(`L ${curr.x} ${curr.y}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const prevVectorX = curr.x - prev.x
|
||||
const prevVectorY = curr.y - prev.y
|
||||
const nextVectorX = next.x - curr.x
|
||||
const nextVectorY = next.y - curr.y
|
||||
const prevLength = Math.hypot(prevVectorX, prevVectorY)
|
||||
const nextLength = Math.hypot(nextVectorX, nextVectorY)
|
||||
|
||||
if (prevLength === 0 || nextLength === 0) {
|
||||
commands.push(`L ${curr.x} ${curr.y}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const startOffset = Math.min(radius, prevLength / 2)
|
||||
const endOffset = Math.min(radius, nextLength / 2)
|
||||
|
||||
const startX = curr.x - (prevVectorX / prevLength) * startOffset
|
||||
const startY = curr.y - (prevVectorY / prevLength) * startOffset
|
||||
const endX = curr.x + (nextVectorX / nextLength) * endOffset
|
||||
const endY = curr.y + (nextVectorY / nextLength) * endOffset
|
||||
|
||||
commands.push(`L ${startX} ${startY}`)
|
||||
commands.push(`Q ${curr.x} ${curr.y} ${endX} ${endY}`)
|
||||
}
|
||||
|
||||
return commands.join(' ')
|
||||
}
|
||||
|
||||
|
||||
let panPointerId: number | null = null
|
||||
let panStart: CanvasPan = { x: 0, y: 0 }
|
||||
let pointerOrigin = { x: 0, y: 0 }
|
||||
|
||||
@@ -6,127 +6,214 @@
|
||||
<v-app-bar
|
||||
flat
|
||||
density="comfortable"
|
||||
color="rgba(11, 18, 36, 0.92)"
|
||||
class="shrink-0 border-b border-white/10 backdrop-blur-md"
|
||||
height="76"
|
||||
color="rgba(7, 11, 21, 0.9)"
|
||||
class="shrink-0 border-b border-white/10 backdrop-blur-xl"
|
||||
height="64"
|
||||
>
|
||||
<div class="flex w-full items-center gap-4 overflow-hidden px-1">
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<v-icon icon="mdi-radar" size="24" color="cyan" />
|
||||
<div class="leading-tight">
|
||||
<p class="text-[11px] uppercase tracking-[0.35em] text-cyan-300/70">OpenSquawk</p>
|
||||
<p class="text-sm font-semibold text-white/90">Decision Flow Studio</p>
|
||||
<div class="flex w-full flex-wrap items-center gap-4 px-3 lg:flex-nowrap lg:gap-6 lg:px-5">
|
||||
<div class="flex shrink-0 items-center gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl border border-cyan-400/40 bg-cyan-500/10"
|
||||
>
|
||||
<v-icon icon="mdi-radar" size="22" color="cyan" />
|
||||
</div>
|
||||
<div class="min-w-0 leading-tight">
|
||||
<p class="text-[11px] uppercase tracking-[0.45em] text-cyan-200/70">OpenSquawk</p>
|
||||
<p class="text-sm font-semibold text-white/90">Decision Flow Studio</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden h-10 w-px bg-white/10 lg:block" />
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<v-autocomplete
|
||||
v-model="selectedFlowSlug"
|
||||
v-model:search="flowSearch"
|
||||
:items="filteredFlows"
|
||||
item-title="name"
|
||||
item-value="slug"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
class="w-[220px] md:w-[260px]"
|
||||
prepend-inner-icon="mdi-file-tree"
|
||||
:loading="flowsLoading"
|
||||
label="Flow auswählen"
|
||||
:custom-filter="() => true"
|
||||
@update:model-value="(value) => value && selectFlow(value)"
|
||||
>
|
||||
<template #item="{ props, item }">
|
||||
<v-list-item v-bind="props">
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="font-medium">{{ item?.raw?.name }}</span>
|
||||
<v-chip v-if="item?.raw?.nodeCount" size="x-small" color="cyan" variant="tonal">
|
||||
{{ item.raw.nodeCount }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</template>
|
||||
<template #subtitle>
|
||||
<span class="text-xs text-white/60">Start: {{ item?.raw?.startState }}</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<v-tooltip text="Neuen Flow anlegen">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="cyan"
|
||||
class="rounded-lg border border-white/10 bg-white/5 text-white/80 hover:border-cyan-300"
|
||||
@click="openCreateFlow"
|
||||
>
|
||||
<v-icon icon="mdi-plus" />
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-tooltip text="Legacy ATC Import">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="purple"
|
||||
class="rounded-lg border border-white/10 bg-white/5 text-white/80 hover:border-purple-300"
|
||||
:loading="importLoading"
|
||||
@click="runImport"
|
||||
>
|
||||
<v-icon icon="mdi-database-import" />
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<v-divider vertical class="h-9 border-white/10" />
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<v-autocomplete
|
||||
v-model="selectedFlowSlug"
|
||||
v-model:search="flowSearch"
|
||||
:items="filteredFlows"
|
||||
item-title="name"
|
||||
item-value="slug"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
class="w-[240px]"
|
||||
prepend-inner-icon="mdi-file-tree"
|
||||
:loading="flowsLoading"
|
||||
label="Flow auswählen"
|
||||
:custom-filter="() => true"
|
||||
@update:model-value="(value) => value && selectFlow(value)"
|
||||
>
|
||||
<template #item="{ props, item }">
|
||||
<v-list-item v-bind="props">
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="font-medium">{{ item?.raw?.name }}</span>
|
||||
<v-chip v-if="item?.raw?.nodeCount" size="x-small" color="cyan" variant="tonal">
|
||||
{{ item.raw.nodeCount }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</template>
|
||||
<template #subtitle>
|
||||
<span class="text-xs text-white/60">Start: {{ item?.raw?.startState }}</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<v-tooltip text="Neuen Flow anlegen">
|
||||
<template #activator="{ props }">
|
||||
<v-btn v-bind="props" icon size="small" variant="text" color="cyan" @click="openCreateFlow">
|
||||
<v-icon icon="mdi-plus" />
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-tooltip text="Legacy ATC Import">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="purple"
|
||||
:loading="importLoading"
|
||||
@click="runImport"
|
||||
>
|
||||
<v-icon icon="mdi-database-import" />
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<v-divider vertical class="h-9 border-white/10" />
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<v-text-field
|
||||
v-model="nodeFilter.search"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
variant="solo"
|
||||
hide-details
|
||||
clearable
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
placeholder="Node suchen"
|
||||
class="w-[200px]"
|
||||
placeholder="Nodes durchsuchen"
|
||||
class="max-w-[260px] rounded-xl bg-white/5 text-sm text-white/80"
|
||||
/>
|
||||
<v-select
|
||||
v-model="nodeFilter.role"
|
||||
:items="roleFilterOptions"
|
||||
density="compact"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
class="w-[140px]"
|
||||
label="Rolle"
|
||||
/>
|
||||
<v-select
|
||||
v-model="nodeFilter.phase"
|
||||
:items="phaseFilterOptions"
|
||||
density="compact"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
class="w-[160px]"
|
||||
label="Phase"
|
||||
/>
|
||||
<v-tooltip text="Nur Auto-Trigger anzeigen">
|
||||
<v-menu v-model="filtersMenuOpen" transition="scale-transition" :close-on-content-click="false" offset-y>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
:color="nodeFilter.autopOnly ? 'amber' : undefined"
|
||||
class="text-white/70 hover:text-white"
|
||||
@click="toggleAutopOnly"
|
||||
<v-badge
|
||||
:content="activeFilterCount"
|
||||
color="cyan"
|
||||
offset-x="6"
|
||||
offset-y="6"
|
||||
:model-value="activeFilterCount > 0"
|
||||
>
|
||||
<v-icon icon="mdi-auto-fix" />
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="cyan"
|
||||
prepend-icon="mdi-tune"
|
||||
class="rounded-lg border-white/20 text-xs font-semibold uppercase tracking-[0.3em]"
|
||||
>
|
||||
Filter
|
||||
</v-btn>
|
||||
</v-badge>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-card class="w-[320px] border border-white/10 bg-[#050910]/95 backdrop-blur">
|
||||
<v-card-title class="flex items-center justify-between text-sm font-semibold text-white/90">
|
||||
Node-Filter
|
||||
<v-chip v-if="activeFilterCount > 0" color="cyan" size="x-small" variant="flat">
|
||||
{{ activeFilterCount }}
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
<v-divider class="border-white/10" />
|
||||
<v-card-text class="space-y-4">
|
||||
<v-select
|
||||
v-model="nodeFilter.role"
|
||||
:items="roleFilterOptions"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
label="Rolle"
|
||||
color="cyan"
|
||||
/>
|
||||
<v-select
|
||||
v-model="nodeFilter.phase"
|
||||
:items="phaseFilterOptions"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
label="Phase"
|
||||
color="cyan"
|
||||
/>
|
||||
<v-switch
|
||||
v-model="nodeFilter.autopOnly"
|
||||
hide-details
|
||||
inset
|
||||
color="amber"
|
||||
class="text-sm text-white/70"
|
||||
label="Nur Auto-Trigger anzeigen"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-divider class="border-white/10" />
|
||||
<v-card-actions class="justify-between">
|
||||
<v-btn variant="text" color="white" @click="resetNodeFilters">Zurücksetzen</v-btn>
|
||||
<v-btn color="cyan" variant="flat" @click="filtersMenuOpen = false">Fertig</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</div>
|
||||
<v-divider vertical class="h-9 border-white/10" />
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3 overflow-hidden">
|
||||
<div class="flex shrink-0 items-center gap-2 text-xs uppercase tracking-widest text-white/60">
|
||||
<span>Nodes</span>
|
||||
<div class="flex shrink-0 items-center gap-3">
|
||||
<div class="hidden min-w-0 text-right leading-tight sm:block">
|
||||
<p class="text-[11px] uppercase tracking-[0.35em] text-white/40">Aktiver Flow</p>
|
||||
<p class="truncate text-sm font-semibold text-white/90">
|
||||
{{ flowForm.name || 'Kein Flow ausgewählt' }}
|
||||
</p>
|
||||
</div>
|
||||
<v-chip
|
||||
v-if="flowDetail"
|
||||
color="cyan"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
class="font-mono text-xs uppercase tracking-widest text-cyan-100"
|
||||
>
|
||||
{{ flowDetail.flow.slug }}
|
||||
</v-chip>
|
||||
<v-btn
|
||||
color="cyan"
|
||||
variant="flat"
|
||||
size="small"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="rounded-lg px-4 font-semibold tracking-wide"
|
||||
:disabled="!flowDirty"
|
||||
:loading="flowSaveLoading"
|
||||
@click="saveFlow"
|
||||
>
|
||||
Speichern
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="white"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
prepend-icon="mdi-auto-fix"
|
||||
class="rounded-lg px-4 font-semibold tracking-wide text-white"
|
||||
:disabled="!flowDetail"
|
||||
@click="autoLayoutNodes"
|
||||
>
|
||||
Auto-Layout
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-app-bar>
|
||||
<div class="border-b border-white/10 bg-[#070d1a]/70 px-4 py-2 backdrop-blur">
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-center md:gap-4">
|
||||
<div class="flex flex-1 items-center gap-3">
|
||||
<div class="flex shrink-0 items-center gap-2 text-[11px] uppercase tracking-[0.4em] text-white/40">
|
||||
<span class="text-white/70">Nodes</span>
|
||||
<v-chip v-if="flowDetail" size="x-small" color="cyan" variant="flat">
|
||||
{{ nodeSelectorItems.length }}
|
||||
</v-chip>
|
||||
@@ -145,10 +232,7 @@
|
||||
:value="node.id"
|
||||
v-slot="{ isSelected, toggle }"
|
||||
>
|
||||
<v-tooltip
|
||||
:text="`${node.id} — ${node.title || 'Ohne Titel'}`"
|
||||
location="bottom"
|
||||
>
|
||||
<v-tooltip :text="`${node.id} — ${node.title || 'Ohne Titel'}`" location="bottom">
|
||||
<template #activator="{ props }">
|
||||
<span class="inline-flex">
|
||||
<v-badge
|
||||
@@ -204,41 +288,23 @@
|
||||
<p v-else class="truncate text-xs text-white/50">Kein Flow ausgewählt.</p>
|
||||
</div>
|
||||
</div>
|
||||
<v-divider vertical class="h-9 border-white/10" />
|
||||
<div class="flex shrink-0 items-center gap-3 pl-2 min-w-0">
|
||||
<div class="min-w-0 text-right leading-tight">
|
||||
<p class="text-[11px] uppercase tracking-wider text-white/50">Aktueller Flow</p>
|
||||
<p class="truncate text-sm font-semibold">
|
||||
{{ flowForm.name || 'Kein Flow ausgewählt' }}
|
||||
</p>
|
||||
</div>
|
||||
<v-chip v-if="flowDetail" color="cyan" size="small" variant="outlined">
|
||||
{{ flowDetail.flow.slug }}
|
||||
</v-chip>
|
||||
<v-btn
|
||||
color="cyan"
|
||||
variant="flat"
|
||||
<div
|
||||
v-if="activeFilterBadges.length"
|
||||
class="flex shrink-0 flex-wrap items-center gap-1 text-[11px] uppercase tracking-widest text-white/50 md:justify-end"
|
||||
>
|
||||
<v-chip
|
||||
v-for="badge in activeFilterBadges"
|
||||
:key="badge.label"
|
||||
:color="badge.color"
|
||||
size="small"
|
||||
prepend-icon="mdi-content-save"
|
||||
:disabled="!flowDirty"
|
||||
:loading="flowSaveLoading"
|
||||
@click="saveFlow"
|
||||
>
|
||||
Speichern
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="cyan"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
prepend-icon="mdi-auto-fix"
|
||||
@click="autoLayoutNodes"
|
||||
:disabled="!flowDetail"
|
||||
class="bg-opacity-20 text-white/80"
|
||||
>
|
||||
Auto-Layout
|
||||
</v-btn>
|
||||
{{ badge.label }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</v-app-bar>
|
||||
</div>
|
||||
<div
|
||||
v-if="flowsLoading || flowsError"
|
||||
class="border-b border-white/10 bg-[#0b1224]/75 px-4 py-2 backdrop-blur"
|
||||
@@ -712,6 +778,30 @@ const nodeFilter = reactive({
|
||||
autopOnly: false,
|
||||
})
|
||||
|
||||
const filtersMenuOpen = ref(false)
|
||||
|
||||
const activeFilterCount = computed(() => {
|
||||
let count = 0
|
||||
if (nodeFilter.role !== 'all') count += 1
|
||||
if (nodeFilter.phase !== 'all') count += 1
|
||||
if (nodeFilter.autopOnly) count += 1
|
||||
return count
|
||||
})
|
||||
|
||||
const activeFilterBadges = computed(() => {
|
||||
const badges: { label: string; color: string }[] = []
|
||||
if (nodeFilter.role !== 'all') {
|
||||
badges.push({ label: `Rolle: ${nodeFilter.role}`, color: 'cyan' })
|
||||
}
|
||||
if (nodeFilter.phase !== 'all') {
|
||||
badges.push({ label: `Phase: ${nodeFilter.phase}`, color: 'purple' })
|
||||
}
|
||||
if (nodeFilter.autopOnly) {
|
||||
badges.push({ label: 'Auto-Trigger', color: 'amber' })
|
||||
}
|
||||
return badges
|
||||
})
|
||||
|
||||
const selectedNodeId = ref<string | null>(null)
|
||||
const inspectorTab = ref<'general' | 'transitions' | 'llm' | 'metadata'>('general')
|
||||
const nodeForm = ref<DecisionNodeModel | null>(null)
|
||||
@@ -1048,8 +1138,10 @@ async function runImport() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAutopOnly() {
|
||||
nodeFilter.autopOnly = !nodeFilter.autopOnly
|
||||
function resetNodeFilters() {
|
||||
nodeFilter.role = 'all'
|
||||
nodeFilter.phase = 'all'
|
||||
nodeFilter.autopOnly = false
|
||||
}
|
||||
|
||||
function selectNode(stateId: string) {
|
||||
|
||||
Reference in New Issue
Block a user