mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-07-31 13:55:34 +08:00
merge
This commit is contained in:
@@ -31,7 +31,7 @@
|
||||
<div
|
||||
v-for="node in preparedNodes"
|
||||
: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="[
|
||||
node.selected ? 'ring-2 ring-cyan-400 shadow-xl shadow-cyan-500/20' : 'shadow-lg shadow-black/40',
|
||||
node.dimmed ? 'opacity-30' : 'opacity-100',
|
||||
@@ -41,6 +41,12 @@
|
||||
@pointerdown.stop="(event) => onNodePointerDown(event, node)"
|
||||
@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--output" />
|
||||
<div class="flex h-full flex-col">
|
||||
@@ -97,11 +103,44 @@
|
||||
<v-icon icon="mdi-crosshairs-gps" size="18" />
|
||||
</button>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import type {
|
||||
DecisionNodeModel,
|
||||
DecisionNodeTransition,
|
||||
@@ -160,6 +199,9 @@ const props = withDefaults(
|
||||
const emit = defineEmits<{
|
||||
(event: 'select', 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-drop', payload: { stateId: string; x: number; y: number }): void
|
||||
(event: 'update:pan', value: CanvasPan): void
|
||||
@@ -169,6 +211,14 @@ const emit = defineEmits<{
|
||||
const canvasRef = ref<HTMLDivElement | null>(null)
|
||||
const currentPan = ref<CanvasPan>({ ...props.pan })
|
||||
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(
|
||||
() => 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(() => ({
|
||||
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(() =>
|
||||
props.nodes.map((node) => {
|
||||
const width = node.layout?.width ?? NODE_WIDTH
|
||||
@@ -301,6 +416,26 @@ const gridStyle = computed(() => ({
|
||||
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) {
|
||||
switch (transition.type) {
|
||||
case 'ok':
|
||||
@@ -510,6 +645,7 @@ let dragState: DragState | null = null
|
||||
function onNodePointerDown(event: PointerEvent, node: ReturnType<typeof preparedNodes.value[number]>) {
|
||||
if (event.button !== 0) return
|
||||
emit('select', node.id)
|
||||
emit('node-drag-start', node.id)
|
||||
const rect = canvasRef.value?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const canvasX = (event.clientX - rect.left - currentPan.value.x) / currentZoom.value
|
||||
@@ -580,6 +716,23 @@ function resetView() {
|
||||
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) {
|
||||
const rect = canvasRef.value?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
@@ -613,6 +766,7 @@ function nodeStyle(node: ReturnType<typeof preparedNodes.value[number]>) {
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('pointermove', onNodePointerMove)
|
||||
window.removeEventListener('pointermove', onPanPointerMove)
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
</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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
@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