mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-09 19:16:02 +08:00
merge
This commit is contained in:
@@ -31,7 +31,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="node in preparedNodes"
|
v-for="node in preparedNodes"
|
||||||
:key="node.id"
|
:key="node.id"
|
||||||
class="absolute rounded-2xl border backdrop-blur transition-all duration-150"
|
class="group absolute rounded-2xl border backdrop-blur transition-all duration-150"
|
||||||
:class="[
|
:class="[
|
||||||
node.selected ? 'ring-2 ring-cyan-400 shadow-xl shadow-cyan-500/20' : 'shadow-lg shadow-black/40',
|
node.selected ? 'ring-2 ring-cyan-400 shadow-xl shadow-cyan-500/20' : 'shadow-lg shadow-black/40',
|
||||||
node.dimmed ? 'opacity-30' : 'opacity-100',
|
node.dimmed ? 'opacity-30' : 'opacity-100',
|
||||||
@@ -41,6 +41,12 @@
|
|||||||
@pointerdown.stop="(event) => onNodePointerDown(event, node)"
|
@pointerdown.stop="(event) => onNodePointerDown(event, node)"
|
||||||
@dblclick.prevent="() => emit('select', node.id)"
|
@dblclick.prevent="() => emit('select', node.id)"
|
||||||
>
|
>
|
||||||
|
<button class="node-add node-add--top" @click.stop="emit('add-before', node.id)">
|
||||||
|
<v-icon icon="mdi-plus" size="16" />
|
||||||
|
</button>
|
||||||
|
<button class="node-add node-add--bottom" @click.stop="emit('add-after', node.id)">
|
||||||
|
<v-icon icon="mdi-plus" size="16" />
|
||||||
|
</button>
|
||||||
<span class="node-connector node-connector--input" />
|
<span class="node-connector node-connector--input" />
|
||||||
<span class="node-connector node-connector--output" />
|
<span class="node-connector node-connector--output" />
|
||||||
<div class="flex h-full flex-col">
|
<div class="flex h-full flex-col">
|
||||||
@@ -97,11 +103,44 @@
|
|||||||
<v-icon icon="mdi-crosshairs-gps" size="18" />
|
<v-icon icon="mdi-crosshairs-gps" size="18" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
ref="minimapRef"
|
||||||
|
class="pointer-events-auto absolute bottom-4 right-4 rounded-xl border border-white/10 bg-black/40 p-3 shadow-lg"
|
||||||
|
@click="onMinimapClick"
|
||||||
|
>
|
||||||
|
<svg :width="minimapSize.width" :height="minimapSize.height" class="text-white/70">
|
||||||
|
<g>
|
||||||
|
<rect
|
||||||
|
v-for="node in preparedNodes"
|
||||||
|
:key="node.id"
|
||||||
|
:x="(node.layout.x - minimapBounds.minX) * minimapScale"
|
||||||
|
:y="(node.layout.y - minimapBounds.minY) * minimapScale"
|
||||||
|
:width="Math.max(6, node.width * minimapScale)"
|
||||||
|
:height="Math.max(6, node.height * minimapScale)"
|
||||||
|
:fill="node.selected ? '#22d3ee' : 'rgba(148, 163, 184, 0.45)'"
|
||||||
|
:opacity="node.selected ? 0.9 : 0.5"
|
||||||
|
rx="4"
|
||||||
|
ry="4"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<rect
|
||||||
|
:x="viewportRect.x"
|
||||||
|
:y="viewportRect.y"
|
||||||
|
:width="viewportRect.width"
|
||||||
|
:height="viewportRect.height"
|
||||||
|
fill="rgba(34, 211, 238, 0.12)"
|
||||||
|
stroke="#22d3ee"
|
||||||
|
stroke-width="1.2"
|
||||||
|
rx="6"
|
||||||
|
ry="6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import type {
|
import type {
|
||||||
DecisionNodeModel,
|
DecisionNodeModel,
|
||||||
DecisionNodeTransition,
|
DecisionNodeTransition,
|
||||||
@@ -160,6 +199,9 @@ const props = withDefaults(
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'select', stateId: string): void
|
(event: 'select', stateId: string): void
|
||||||
(event: 'navigate', stateId: string): void
|
(event: 'navigate', stateId: string): void
|
||||||
|
(event: 'add-before', stateId: string): void
|
||||||
|
(event: 'add-after', stateId: string): void
|
||||||
|
(event: 'node-drag-start', stateId: string): void
|
||||||
(event: 'node-move', payload: { stateId: string; x: number; y: number }): void
|
(event: 'node-move', payload: { stateId: string; x: number; y: number }): void
|
||||||
(event: 'node-drop', payload: { stateId: string; x: number; y: number }): void
|
(event: 'node-drop', payload: { stateId: string; x: number; y: number }): void
|
||||||
(event: 'update:pan', value: CanvasPan): void
|
(event: 'update:pan', value: CanvasPan): void
|
||||||
@@ -169,6 +211,14 @@ const emit = defineEmits<{
|
|||||||
const canvasRef = ref<HTMLDivElement | null>(null)
|
const canvasRef = ref<HTMLDivElement | null>(null)
|
||||||
const currentPan = ref<CanvasPan>({ ...props.pan })
|
const currentPan = ref<CanvasPan>({ ...props.pan })
|
||||||
const currentZoom = ref(props.zoom)
|
const currentZoom = ref(props.zoom)
|
||||||
|
const viewportSize = ref({ width: 0, height: 0 })
|
||||||
|
const minimapRef = ref<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const MINIMAP_WIDTH = 220
|
||||||
|
const MINIMAP_HEIGHT = 160
|
||||||
|
const MINIMAP_PADDING = 200
|
||||||
|
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.pan,
|
() => props.pan,
|
||||||
@@ -185,10 +235,75 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!canvasRef.value) return
|
||||||
|
resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
viewportSize.value = {
|
||||||
|
width: entry.contentRect.width,
|
||||||
|
height: entry.contentRect.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
resizeObserver.observe(canvasRef.value)
|
||||||
|
const rect = canvasRef.value.getBoundingClientRect()
|
||||||
|
viewportSize.value = { width: rect.width, height: rect.height }
|
||||||
|
})
|
||||||
|
|
||||||
const transformStyle = computed(() => ({
|
const transformStyle = computed(() => ({
|
||||||
transform: `translate(${currentPan.value.x}px, ${currentPan.value.y}px) scale(${currentZoom.value})`,
|
transform: `translate(${currentPan.value.x}px, ${currentPan.value.y}px) scale(${currentZoom.value})`,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const minimapBounds = computed(() => {
|
||||||
|
if (!preparedNodes.value.length) {
|
||||||
|
return { minX: 0, minY: 0, width: MINIMAP_WIDTH, height: MINIMAP_HEIGHT }
|
||||||
|
}
|
||||||
|
const minX = Math.min(...preparedNodes.value.map((node) => node.layout.x))
|
||||||
|
const minY = Math.min(...preparedNodes.value.map((node) => node.layout.y))
|
||||||
|
const maxX = Math.max(...preparedNodes.value.map((node) => node.layout.x + node.width))
|
||||||
|
const maxY = Math.max(...preparedNodes.value.map((node) => node.layout.y + node.height))
|
||||||
|
const width = Math.max(1, maxX - minX + MINIMAP_PADDING * 2)
|
||||||
|
const height = Math.max(1, maxY - minY + MINIMAP_PADDING * 2)
|
||||||
|
return {
|
||||||
|
minX: minX - MINIMAP_PADDING,
|
||||||
|
minY: minY - MINIMAP_PADDING,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const minimapScale = computed(() => {
|
||||||
|
const bounds = minimapBounds.value
|
||||||
|
const scaleX = MINIMAP_WIDTH / bounds.width
|
||||||
|
const scaleY = MINIMAP_HEIGHT / bounds.height
|
||||||
|
return Math.min(scaleX, scaleY)
|
||||||
|
})
|
||||||
|
|
||||||
|
const minimapSize = computed(() => {
|
||||||
|
const bounds = minimapBounds.value
|
||||||
|
const scale = minimapScale.value
|
||||||
|
return {
|
||||||
|
width: Math.max(80, bounds.width * scale),
|
||||||
|
height: Math.max(60, bounds.height * scale),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const viewportRect = computed(() => {
|
||||||
|
const bounds = minimapBounds.value
|
||||||
|
const scale = minimapScale.value
|
||||||
|
const zoom = currentZoom.value || 1
|
||||||
|
const viewWidth = viewportSize.value.width / zoom
|
||||||
|
const viewHeight = viewportSize.value.height / zoom
|
||||||
|
const worldX = (-currentPan.value.x) / zoom
|
||||||
|
const worldY = (-currentPan.value.y) / zoom
|
||||||
|
return {
|
||||||
|
x: (worldX - bounds.minX) * scale,
|
||||||
|
y: (worldY - bounds.minY) * scale,
|
||||||
|
width: Math.max(12, viewWidth * scale),
|
||||||
|
height: Math.max(12, viewHeight * scale),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const preparedNodes = computed(() =>
|
const preparedNodes = computed(() =>
|
||||||
props.nodes.map((node) => {
|
props.nodes.map((node) => {
|
||||||
const width = node.layout?.width ?? NODE_WIDTH
|
const width = node.layout?.width ?? NODE_WIDTH
|
||||||
@@ -301,6 +416,26 @@ const gridStyle = computed(() => ({
|
|||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
function focusNode(stateId: string) {
|
||||||
|
const node = preparedNodes.value.find((entry) => entry.id === stateId)
|
||||||
|
if (!node) return
|
||||||
|
const width = viewportSize.value.width || canvasBounds.value.width
|
||||||
|
const height = viewportSize.value.height || canvasBounds.value.height
|
||||||
|
const zoomX = width / (node.width + 160)
|
||||||
|
const zoomY = height / (node.height + 200)
|
||||||
|
const targetZoom = Math.min(2.5, Math.max(0.4, Math.min(zoomX, zoomY)))
|
||||||
|
currentZoom.value = targetZoom
|
||||||
|
const centerX = node.layout.x + node.width / 2
|
||||||
|
const centerY = node.layout.y + node.height / 2
|
||||||
|
const panX = width / 2 - centerX * targetZoom
|
||||||
|
const panY = height / 2 - centerY * targetZoom
|
||||||
|
currentPan.value = { x: panX, y: panY }
|
||||||
|
emit('update:zoom', targetZoom)
|
||||||
|
emit('update:pan', { x: panX, y: panY })
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ focusNode })
|
||||||
|
|
||||||
function transitionClass(transition: DecisionNodeTransition) {
|
function transitionClass(transition: DecisionNodeTransition) {
|
||||||
switch (transition.type) {
|
switch (transition.type) {
|
||||||
case 'ok':
|
case 'ok':
|
||||||
@@ -510,6 +645,7 @@ let dragState: DragState | null = null
|
|||||||
function onNodePointerDown(event: PointerEvent, node: ReturnType<typeof preparedNodes.value[number]>) {
|
function onNodePointerDown(event: PointerEvent, node: ReturnType<typeof preparedNodes.value[number]>) {
|
||||||
if (event.button !== 0) return
|
if (event.button !== 0) return
|
||||||
emit('select', node.id)
|
emit('select', node.id)
|
||||||
|
emit('node-drag-start', node.id)
|
||||||
const rect = canvasRef.value?.getBoundingClientRect()
|
const rect = canvasRef.value?.getBoundingClientRect()
|
||||||
if (!rect) return
|
if (!rect) return
|
||||||
const canvasX = (event.clientX - rect.left - currentPan.value.x) / currentZoom.value
|
const canvasX = (event.clientX - rect.left - currentPan.value.x) / currentZoom.value
|
||||||
@@ -580,6 +716,23 @@ function resetView() {
|
|||||||
emit('update:pan', { x: 0, y: 0 })
|
emit('update:pan', { x: 0, y: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onMinimapClick(event: MouseEvent) {
|
||||||
|
const container = minimapRef.value
|
||||||
|
if (!container) return
|
||||||
|
const rect = container.getBoundingClientRect()
|
||||||
|
const offsetX = event.clientX - rect.left
|
||||||
|
const offsetY = event.clientY - rect.top
|
||||||
|
const bounds = minimapBounds.value
|
||||||
|
const scale = minimapScale.value || 1
|
||||||
|
const worldX = offsetX / scale + bounds.minX
|
||||||
|
const worldY = offsetY / scale + bounds.minY
|
||||||
|
const zoom = currentZoom.value || 1
|
||||||
|
const panX = viewportSize.value.width / 2 - worldX * zoom
|
||||||
|
const panY = viewportSize.value.height / 2 - worldY * zoom
|
||||||
|
currentPan.value = { x: panX, y: panY }
|
||||||
|
emit('update:pan', { x: panX, y: panY })
|
||||||
|
}
|
||||||
|
|
||||||
function onWheel(event: WheelEvent) {
|
function onWheel(event: WheelEvent) {
|
||||||
const rect = canvasRef.value?.getBoundingClientRect()
|
const rect = canvasRef.value?.getBoundingClientRect()
|
||||||
if (!rect) return
|
if (!rect) return
|
||||||
@@ -613,6 +766,7 @@ function nodeStyle(node: ReturnType<typeof preparedNodes.value[number]>) {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('pointermove', onNodePointerMove)
|
window.removeEventListener('pointermove', onNodePointerMove)
|
||||||
window.removeEventListener('pointermove', onPanPointerMove)
|
window.removeEventListener('pointermove', onPanPointerMove)
|
||||||
|
resizeObserver?.disconnect()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -621,6 +775,26 @@ onBeforeUnmount(() => {
|
|||||||
@apply pointer-events-auto flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-black/40 text-white/80 transition hover:border-cyan-400 hover:text-cyan-200;
|
@apply pointer-events-auto flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-black/40 text-white/80 transition hover:border-cyan-400 hover:text-cyan-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node-add {
|
||||||
|
@apply pointer-events-auto absolute z-10 flex h-6 w-6 items-center justify-center rounded-full border border-white/20 bg-black/70 text-white opacity-0 transition;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group:hover .node-add {
|
||||||
|
@apply opacity-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-add--top {
|
||||||
|
top: -18px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-add--bottom {
|
||||||
|
bottom: -18px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
.node-connector {
|
.node-connector {
|
||||||
@apply pointer-events-none absolute h-3 w-3 rounded-full border-2 border-white/60 bg-[#070d1a];
|
@apply pointer-events-none absolute h-3 w-3 rounded-full border-2 border-white/60 bg-[#070d1a];
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
|||||||
|
import { createError, readBody } from 'h3'
|
||||||
|
import { requireAdmin } from '../../../../../../utils/auth'
|
||||||
|
import { DecisionFlow } from '../../../../../../models/DecisionFlow'
|
||||||
|
import { DecisionNode } from '../../../../../../models/DecisionNode'
|
||||||
|
import { serializeNodeDocument } from '../../../../../../services/decisionFlowService'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAdmin(event)
|
||||||
|
const slugParam = event.context.params?.slug
|
||||||
|
const stateParam = event.context.params?.stateId
|
||||||
|
if (typeof slugParam !== 'string' || !slugParam.trim()) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' })
|
||||||
|
}
|
||||||
|
if (typeof stateParam !== 'string' || !stateParam.trim()) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Missing state identifier' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = slugParam.trim()
|
||||||
|
const currentStateId = stateParam.trim().toUpperCase()
|
||||||
|
|
||||||
|
const body = await readBody<{ stateId?: string }>(event)
|
||||||
|
const nextStateRaw = typeof body?.stateId === 'string' ? body.stateId.trim() : ''
|
||||||
|
if (!nextStateRaw) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Neuer Identifier ist erforderlich' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStateId = nextStateRaw.toUpperCase()
|
||||||
|
const flow = await DecisionFlow.findOne({ slug })
|
||||||
|
if (!flow) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingNode = await DecisionNode.findOne({ flow: flow._id, stateId: nextStateId })
|
||||||
|
if (existingNode) {
|
||||||
|
throw createError({ statusCode: 409, statusMessage: 'State identifier already exists' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = await DecisionNode.findOne({ flow: flow._id, stateId: currentStateId })
|
||||||
|
if (!node) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'State not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextStateId === currentStateId) {
|
||||||
|
const references = await DecisionNode.find({
|
||||||
|
flow: flow._id,
|
||||||
|
'transitions.target': currentStateId,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
node: serializeNodeDocument(node),
|
||||||
|
references: references.map((ref) => serializeNodeDocument(ref)),
|
||||||
|
flow: {
|
||||||
|
startState: flow.startState,
|
||||||
|
endStates: flow.endStates ?? [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const referencingNodes = await DecisionNode.find({
|
||||||
|
flow: flow._id,
|
||||||
|
'transitions.target': currentStateId,
|
||||||
|
}).select('_id')
|
||||||
|
|
||||||
|
const referencingIds = referencingNodes.map((entry) => entry._id)
|
||||||
|
|
||||||
|
node.stateId = nextStateId
|
||||||
|
await node.save()
|
||||||
|
|
||||||
|
if (referencingIds.length) {
|
||||||
|
await DecisionNode.updateMany(
|
||||||
|
{ flow: flow._id, _id: { $in: referencingIds } },
|
||||||
|
{ $set: { 'transitions.$[transition].target': nextStateId } },
|
||||||
|
{ arrayFilters: [{ 'transition.target': currentStateId }] }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flow.startState === currentStateId) {
|
||||||
|
flow.startState = nextStateId
|
||||||
|
}
|
||||||
|
if (Array.isArray(flow.endStates) && flow.endStates.length) {
|
||||||
|
flow.endStates = flow.endStates.map((entry: string) => (entry === currentStateId ? nextStateId : entry))
|
||||||
|
}
|
||||||
|
await flow.save()
|
||||||
|
|
||||||
|
const updatedReferences = referencingIds.length
|
||||||
|
? await DecisionNode.find({ _id: { $in: referencingIds } })
|
||||||
|
: []
|
||||||
|
|
||||||
|
return {
|
||||||
|
node: serializeNodeDocument(node),
|
||||||
|
references: updatedReferences.map((ref) => serializeNodeDocument(ref)),
|
||||||
|
flow: {
|
||||||
|
startState: flow.startState,
|
||||||
|
endStates: flow.endStates ?? [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user