Files
ordo-designer-vue/src/stores/designer.ts
T
2026-09-20 10:07:26 +08:00

805 lines
24 KiB
TypeScript

import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import type { Connection } from '@vue-flow/core'
import {
ACTION_TYPE,
APPROVAL_TYPE,
END_TYPE,
PARALLEL_TYPE,
END_NODE_ID,
edgeLabel,
fromDocument,
layoutFromNodes,
toDocument,
type DesignerEdge,
type DesignerNode,
validateGraph,
} from '@/mapper/graph'
import { dropCrossLaneEdges, expandLegacyParallel, fitParallelFrames, laneOf, materializeParallel, reflowLanes, settleLaneNodes, spawnLaneStep } from '@/mapper/parallel'
import { applyLayeredLayout, assignEdgeHandles, normalizeHandleId } from '@/mapper/layout'
import { defaultActionData, defaultApprovalData, defaultEndData } from '@/types/definition'
import type { DesignerEdgeData, DesignerNodeData, ProcessDefinitionDocument } from '@/types/definition'
let seq = 0
function nextId(prefix: string) {
seq += 1
return `${prefix}-${Date.now().toString(36)}-${seq}`
}
interface GraphSnapshot {
definitionId: string
name: string
startStepId: string
nodes: DesignerNode[]
edges: DesignerEdge[]
}
interface ClipboardPayload {
nodes: DesignerNode[]
edges: DesignerEdge[]
}
function slimNode(node: DesignerNode): DesignerNode {
return {
id: node.id,
type: node.type,
position: { x: node.position.x, y: node.position.y },
parentNode: node.parentNode,
extent: node.extent,
style: node.style ? { ...node.style } : undefined,
zIndex: node.zIndex,
data: JSON.parse(JSON.stringify(node.data)) as DesignerNodeData,
}
}
function slimEdge(edge: DesignerEdge): DesignerEdge {
return {
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
label: edge.label,
data: { ...(edge.data ?? { priority: 0 }) },
}
}
export const useDesignerStore = defineStore('designer', () => {
const definitionId = ref('')
const name = ref('')
const startStepId = ref('')
const publishedVersion = ref<number | null>(null)
const loadedFromDraft = ref(false)
const nodes = ref<DesignerNode[]>([])
const edges = ref<DesignerEdge[]>([])
const selectedNodeId = ref<string | null>(null)
const selectedEdgeId = ref<string | null>(null)
const selectedNodeIds = ref<string[]>([])
const selectedEdgeIds = ref<string[]>([])
const baseline = ref('')
const undoStack = ref<GraphSnapshot[]>([])
const redoStack = ref<GraphSnapshot[]>([])
let historyPaused = false
let clipboard: ClipboardPayload | null = null
const selectedNode = computed<DesignerNode | undefined>(() =>
nodes.value.find((n) => n.id === selectedNodeId.value),
)
const selectedEdge = computed<DesignerEdge | undefined>(() =>
edges.value.find((e) => e.id === selectedEdgeId.value),
)
const graphErrors = computed(() =>
validateGraph(definitionId.value, name.value, startStepId.value, nodes.value, edges.value),
)
const canUndo = computed(() => undoStack.value.length > 0)
const canRedo = computed(() => redoStack.value.length > 0)
const canPaste = computed(() => Boolean(clipboard?.nodes.length))
function snapshot() {
return JSON.stringify({
id: definitionId.value,
name: name.value,
startStepId: startStepId.value,
doc: toDocument(definitionId.value, name.value, startStepId.value, nodes.value, edges.value),
layout: layoutFromNodes(nodes.value),
})
}
function captureBaseline() {
baseline.value = snapshot()
}
const isDirty = computed(() => snapshot() !== baseline.value)
function cloneGraph(): GraphSnapshot {
return {
definitionId: definitionId.value,
name: name.value,
startStepId: startStepId.value,
nodes: nodes.value.map(slimNode),
edges: edges.value.map(slimEdge),
}
}
function resetHistory() {
undoStack.value = []
redoStack.value = []
}
function pushHistory() {
if (historyPaused) {
return
}
undoStack.value = [...undoStack.value, cloneGraph()].slice(-50)
redoStack.value = []
}
function applySnapshot(snap: GraphSnapshot) {
historyPaused = true
definitionId.value = snap.definitionId
name.value = snap.name
startStepId.value = snap.startStepId
nodes.value = snap.nodes.map(slimNode)
edges.value = snap.edges.map(slimEdge)
selectedNodeId.value = null
selectedEdgeId.value = null
selectedNodeIds.value = []
selectedEdgeIds.value = []
historyPaused = false
}
function undo() {
const prev = undoStack.value.at(-1)
if (!prev) {
return false
}
const current = cloneGraph()
undoStack.value = undoStack.value.slice(0, -1)
redoStack.value = [...redoStack.value, current]
applySnapshot(prev)
return true
}
function redo() {
const next = redoStack.value.at(-1)
if (!next) {
return false
}
const current = cloneGraph()
redoStack.value = redoStack.value.slice(0, -1)
undoStack.value = [...undoStack.value, current]
applySnapshot(next)
return true
}
function syncStartFlags() {
const current = nodes.value.find((node) => node.id === startStepId.value)
if (current?.parentNode) {
startStepId.value = current.parentNode
}
nodes.value = nodes.value.map((node) => {
if (!node.data || node.data.kind === 'END') {
return node
}
const isStart = !node.parentNode && node.id === startStepId.value
return {
...node,
data: { ...node.data, isStart },
}
})
}
function clearSelection() {
selectedNodeId.value = null
selectedEdgeId.value = null
selectedNodeIds.value = []
selectedEdgeIds.value = []
}
function clear() {
definitionId.value = ''
name.value = ''
startStepId.value = ''
publishedVersion.value = null
loadedFromDraft.value = false
nodes.value = []
edges.value = []
clearSelection()
resetHistory()
captureBaseline()
}
function resetEmpty(id = `process-${Date.now().toString(36)}`) {
definitionId.value = id
name.value = 'Untitled process'
startStepId.value = ''
publishedVersion.value = null
loadedFromDraft.value = false
nodes.value = []
edges.value = []
clearSelection()
resetHistory()
captureBaseline()
}
function loadDocument(doc: ProcessDefinitionDocument) {
const mapped = fromDocument(doc)
definitionId.value = doc.id
name.value = doc.name
startStepId.value = mapped.startStepId
publishedVersion.value = doc.version ?? null
loadedFromDraft.value = false
const laid = applyLayeredLayout(mapped.nodes, mapped.edges, mapped.startStepId)
nodes.value = laid.nodes
edges.value = laid.edges
clearSelection()
resetHistory()
captureBaseline()
}
function loadGraph(params: {
id: string
name: string
startStepId: string
nodes: DesignerNode[]
edges: DesignerEdge[]
version?: number | null
fromDraft?: boolean
}) {
definitionId.value = params.id
name.value = params.name
startStepId.value = params.startStepId
publishedVersion.value = params.version ?? null
loadedFromDraft.value = Boolean(params.fromDraft)
const expanded = expandLegacyParallel(params.nodes, params.edges)
const parallels = expanded.nodes.filter((node) => node.data?.kind === 'PARALLEL').map((node) => node.id)
nodes.value = parallels.reduce((current, id) => reflowLanes(current, id), expanded.nodes)
edges.value = assignEdgeHandles(nodes.value, expanded.edges)
syncStartFlags()
clearSelection()
resetHistory()
captureBaseline()
}
function relayout() {
pushHistory()
const laid = applyLayeredLayout(nodes.value, edges.value, startStepId.value)
nodes.value = laid.nodes
edges.value = laid.edges
}
function syncEdgeHandles() {
edges.value = assignEdgeHandles(nodes.value, edges.value)
}
function settleLanes() {
const settled = settleLaneNodes(nodes.value)
nodes.value = settled
edges.value = assignEdgeHandles(settled, dropCrossLaneEdges(settled, edges.value))
}
function addNode(type: string, position: { x: number; y: number }): 'ok' | 'duplicate-end' {
let data: DesignerNodeData
let nodeType = type
if (type === APPROVAL_TYPE) {
data = defaultApprovalData()
} else if (type === ACTION_TYPE) {
data = defaultActionData()
} else if (type === PARALLEL_TYPE) {
pushHistory()
const id = nextId(nodeType)
const built = materializeParallel(id, 'Parallel', position, [
{ id: 'legal', steps: [{ id: nextId('legal'), name: 'Legal', candidates: [], policy: 'ANY' }], transitions: [] },
{ id: 'finance', steps: [{ id: nextId('finance'), name: 'Finance', candidates: [], policy: 'ANY' }], transitions: [] },
])
nodes.value = fitParallelFrames([...nodes.value, built.parent, ...built.children])
if (!startStepId.value) {
startStepId.value = id
syncStartFlags()
}
selectedNodeId.value = id
selectedNodeIds.value = [id]
selectedEdgeId.value = null
selectedEdgeIds.value = []
return 'ok'
} else {
nodeType = END_TYPE
if (nodes.value.some((n) => n.type === END_TYPE)) {
return 'duplicate-end'
}
data = defaultEndData()
}
pushHistory()
const id = nodeType === END_TYPE ? END_NODE_ID : nextId(nodeType)
const node: DesignerNode = { id, type: nodeType, position, data }
nodes.value = [...nodes.value, node]
if (!startStepId.value && nodeType !== END_TYPE) {
startStepId.value = id
syncStartFlags()
}
selectedNodeId.value = id
selectedNodeIds.value = [id]
selectedEdgeId.value = null
selectedEdgeIds.value = []
return 'ok'
}
function setStart(id: string) {
const node = nodes.value.find((n) => n.id === id)
if (!node || node.type === END_TYPE || node.parentNode) {
return
}
pushHistory()
startStepId.value = id
syncStartFlags()
}
function renameNode(oldId: string, newId: string): boolean {
const trimmed = newId.trim()
if (!trimmed || trimmed === oldId) {
return true
}
if (nodes.value.some((n) => n.id === trimmed)) {
return false
}
pushHistory()
nodes.value = nodes.value.map((n) => (n.id === oldId ? { ...n, id: trimmed } : n))
edges.value = edges.value.map((e) => ({
...e,
source: e.source === oldId ? trimmed : e.source,
target: e.target === oldId ? trimmed : e.target,
}))
if (startStepId.value === oldId) {
startStepId.value = trimmed
}
if (selectedNodeId.value === oldId) {
selectedNodeId.value = trimmed
}
selectedNodeIds.value = selectedNodeIds.value.map((id) => (id === oldId ? trimmed : id))
return true
}
function patchNodeData(id: string, patch: Record<string, unknown>) {
pushHistory()
nodes.value = nodes.value.map((n) => {
if (n.id !== id) {
return n
}
return { ...n, data: { ...n.data, ...patch } as DesignerNodeData }
})
}
function patchEdgeData(id: string, patch: Partial<DesignerEdgeData>) {
pushHistory()
edges.value = edges.value.map((e) => {
if (e.id !== id) {
return e
}
const data = { ...(e.data ?? { priority: 0 }), ...patch }
if ('when' in patch && patch.when === undefined) {
delete data.when
}
return { ...e, data, label: edgeLabel(data) }
})
}
function onConnect(connection: Connection) {
if (!connection.source || !connection.target) {
return
}
const sourceNode = nodes.value.find((n) => n.id === connection.source)
const targetNode = nodes.value.find((n) => n.id === connection.target)
if (sourceNode?.type === END_TYPE || targetNode?.type === PARALLEL_TYPE) {
return
}
const inner = Boolean(sourceNode?.parentNode || targetNode?.parentNode)
if (inner && (sourceNode?.parentNode !== targetNode?.parentNode || laneOf(sourceNode?.data) !== laneOf(targetNode?.data))) {
return
}
pushHistory()
const siblings = edges.value.filter((e) => e.source === connection.source)
const used = new Set(siblings.map((e) => e.data?.priority ?? 0))
let priority = 0
while (used.has(priority)) {
priority += 1
}
const data: DesignerEdgeData = { priority }
const edge: DesignerEdge = {
id: nextId('e'),
source: connection.source,
target: connection.target,
sourceHandle: normalizeHandleId(connection.sourceHandle) ?? 'source-bottom',
targetHandle: normalizeHandleId(connection.targetHandle) ?? 'target-top',
label: edgeLabel(data),
data,
}
edges.value = [...edges.value, edge]
edges.value = assignEdgeHandles(nodes.value, edges.value)
}
function selectNode(id: string | null) {
selectedNodeId.value = id
selectedNodeIds.value = id ? [id] : []
if (id) {
selectedEdgeId.value = null
selectedEdgeIds.value = []
}
}
function selectEdge(id: string | null) {
selectedEdgeId.value = id
selectedEdgeIds.value = id ? [id] : []
if (id) {
selectedNodeId.value = null
selectedNodeIds.value = []
}
}
function setSelection(nodeIds: string[], edgeIds: string[]) {
selectedNodeIds.value = nodeIds
selectedEdgeIds.value = edgeIds
selectedNodeId.value = nodeIds.length === 1 ? (nodeIds[0] ?? null) : nodeIds[0] ?? null
if (nodeIds.length > 1) {
selectedNodeId.value = nodeIds[0] ?? null
}
selectedEdgeId.value = nodeIds.length ? null : (edgeIds[0] ?? null)
}
function hasOutgoing(id: string) {
return edges.value.some((e) => e.source === id)
}
function hasOutgoingAmong(ids: string[]) {
return ids.some((id) => hasOutgoing(id))
}
function removeNode(id: string) {
const node = nodes.value.find((n) => n.id === id)
if (!node) {
return
}
const drop = new Set([id, ...nodes.value.filter((n) => n.parentNode === id).map((n) => n.id)])
pushHistory()
nodes.value = nodes.value.filter((n) => !drop.has(n.id))
edges.value = edges.value.filter((e) => !drop.has(e.source) && !drop.has(e.target))
selectedNodeIds.value = selectedNodeIds.value.filter((n) => !drop.has(n))
if (selectedNodeId.value && drop.has(selectedNodeId.value)) {
selectedNodeId.value = selectedNodeIds.value[0] ?? null
}
if (drop.has(startStepId.value)) {
const next = nodes.value.find((n) => n.type !== END_TYPE && !n.parentNode)
startStepId.value = next?.id ?? ''
syncStartFlags()
}
}
function removeEdge(id: string) {
pushHistory()
edges.value = edges.value.filter((e) => e.id !== id)
selectedEdgeIds.value = selectedEdgeIds.value.filter((e) => e !== id)
if (selectedEdgeId.value === id) {
selectedEdgeId.value = selectedEdgeIds.value[0] ?? null
}
}
function removeNodes(ids: string[]) {
const set = new Set(ids)
for (const node of nodes.value) {
if (node.parentNode && set.has(node.parentNode)) {
set.add(node.id)
}
}
if (!set.size) {
return
}
pushHistory()
nodes.value = nodes.value.filter((n) => !set.has(n.id))
edges.value = edges.value.filter((e) => !set.has(e.source) && !set.has(e.target))
selectedNodeIds.value = []
selectedNodeId.value = null
if (set.has(startStepId.value)) {
const next = nodes.value.find((n) => n.type !== END_TYPE && !n.parentNode)
startStepId.value = next?.id ?? ''
syncStartFlags()
}
}
function removeEdges(ids: string[]) {
const set = new Set(ids)
if (!set.size) {
return
}
pushHistory()
edges.value = edges.value.filter((e) => !set.has(e.id))
selectedEdgeIds.value = []
selectedEdgeId.value = null
}
function removeSelected() {
if (selectedNodeIds.value.length) {
removeNodes(selectedNodeIds.value)
return
}
if (selectedNodeId.value) {
removeNode(selectedNodeId.value)
return
}
if (selectedEdgeIds.value.length) {
removeEdges(selectedEdgeIds.value)
return
}
if (selectedEdgeId.value) {
removeEdge(selectedEdgeId.value)
}
}
function withDescendants(ids: string[]) {
const set = new Set(ids)
let grew = true
while (grew) {
grew = false
for (const node of nodes.value) {
if (node.parentNode && set.has(node.parentNode) && !set.has(node.id)) {
set.add(node.id)
grew = true
}
}
}
return [...set]
}
function copySelection() {
const ids = selectedNodeIds.value.length
? selectedNodeIds.value
: selectedNodeId.value
? [selectedNodeId.value]
: []
if (!ids.length) {
clipboard = null
return false
}
const idSet = new Set(withDescendants(ids))
clipboard = {
nodes: nodes.value.filter((n) => idSet.has(n.id)).map(slimNode),
edges: edges.value
.filter((e) => idSet.has(e.source) && idSet.has(e.target))
.map(slimEdge),
}
return true
}
function pasteClipboard() {
if (!clipboard?.nodes.length) {
return false
}
pushHistory()
const hasEnd = nodes.value.some((n) => n.type === END_TYPE)
const idMap = new Map<string, string>()
const pastedNodes: DesignerNode[] = []
const ordered = [...clipboard.nodes].sort((a, b) => Number(Boolean(a.parentNode)) - Number(Boolean(b.parentNode)))
for (const node of ordered) {
if (node.type === END_TYPE && hasEnd) {
continue
}
const id = node.type === END_TYPE ? END_NODE_ID : nextId(node.type)
idMap.set(node.id, id)
const parentMoved = Boolean(node.parentNode && idMap.has(node.parentNode))
pastedNodes.push({
...slimNode(node),
id,
parentNode: node.parentNode ? (idMap.get(node.parentNode) ?? node.parentNode) : undefined,
position: parentMoved || !node.parentNode
? node.parentNode
? { ...node.position }
: { x: node.position.x + 40, y: node.position.y + 40 }
: { x: node.position.x + 40, y: node.position.y },
})
}
const pastedEdges: DesignerEdge[] = []
for (const edge of clipboard.edges) {
const source = idMap.get(edge.source)
const target = idMap.get(edge.target)
if (!source || !target) {
continue
}
const data = { ...(edge.data ?? { priority: 0 }) }
pastedEdges.push({
...slimEdge(edge),
id: nextId('e'),
source,
target,
data,
label: edgeLabel(data),
})
}
nodes.value = fitParallelFrames([...nodes.value, ...pastedNodes])
edges.value = [...edges.value, ...pastedEdges]
selectedNodeIds.value = pastedNodes.map((n) => n.id)
selectedNodeId.value = pastedNodes[0]?.id ?? null
selectedEdgeId.value = null
selectedEdgeIds.value = []
return pastedNodes.length > 0
}
function alignSelected(mode: 'left' | 'center' | 'top'): boolean {
const ids = selectedNodeIds.value.length >= 2 ? selectedNodeIds.value : []
if (ids.length < 2) {
return false
}
const set = new Set(ids)
const selected = nodes.value.filter((n) => set.has(n.id))
pushHistory()
if (mode === 'left') {
const x = Math.min(...selected.map((n) => n.position.x))
for (const n of selected) {
n.position = { ...n.position, x }
}
} else if (mode === 'center') {
const xs = selected.map((n) => n.position.x)
const x = (Math.min(...xs) + Math.max(...xs)) / 2
for (const n of selected) {
n.position = { ...n.position, x }
}
} else {
const y = Math.min(...selected.map((n) => n.position.y))
for (const n of selected) {
n.position = { ...n.position, y }
}
}
nodes.value = settleLaneNodes(nodes.value)
return true
}
function laneName(lanes: string[]) {
let index = lanes.length + 1
let id = `branch-${index}`
while (lanes.includes(id)) {
index += 1
id = `branch-${index}`
}
return id
}
function addLane(parallelId: string) {
const parent = nodes.value.find((node) => node.id === parallelId)
if (!parent || parent.data.kind !== 'PARALLEL') {
return
}
pushHistory()
const lane = laneName(parent.data.lanes)
const step = spawnLaneStep(parallelId, lane, parent.data.lanes.length, 0, nextId(lane))
nodes.value = fitParallelFrames([
...nodes.value.map((node) =>
node.id === parallelId && node.data.kind === 'PARALLEL'
? { ...node, data: { ...node.data, lanes: [...node.data.lanes, lane] } }
: node,
),
step,
])
}
function removeLane(parallelId: string, lane: string) {
const parent = nodes.value.find((node) => node.id === parallelId)
if (!parent || parent.data.kind !== 'PARALLEL' || parent.data.lanes.length <= 2) {
return
}
pushHistory()
const drop = new Set(
nodes.value.filter((node) => node.parentNode === parallelId && laneOf(node.data) === lane).map((node) => node.id),
)
nodes.value = reflowLanes(
nodes.value
.filter((node) => !drop.has(node.id))
.map((node) =>
node.id === parallelId && node.data.kind === 'PARALLEL'
? { ...node, data: { ...node.data, lanes: node.data.lanes.filter((item: string) => item !== lane) } }
: node,
),
parallelId,
)
edges.value = edges.value.filter((edge) => !drop.has(edge.source) && !drop.has(edge.target))
}
function addLaneStep(parallelId: string, lane: string, kind: 'approval' | 'action' = 'approval') {
const parent = nodes.value.find((node) => node.id === parallelId)
if (!parent || parent.data.kind !== 'PARALLEL' || !parent.data.lanes.includes(lane)) {
return
}
pushHistory()
const laneIndex = parent.data.lanes.indexOf(lane)
const stepIndex = nodes.value.filter((node) => node.parentNode === parallelId && laneOf(node.data) === lane).length
nodes.value = reflowLanes(
[...nodes.value, spawnLaneStep(parallelId, lane, laneIndex, stepIndex, nextId(lane), kind)],
parallelId,
)
}
function renameLane(parallelId: string, from: string, to: string): boolean {
const trimmed = to.trim()
const parent = nodes.value.find((node) => node.id === parallelId)
if (!parent || parent.data.kind !== 'PARALLEL' || !trimmed || trimmed === from) {
return trimmed === from
}
if (parent.data.lanes.includes(trimmed)) {
return false
}
pushHistory()
nodes.value = nodes.value.map((node) => {
if (node.id === parallelId && node.data.kind === 'PARALLEL') {
return { ...node, data: { ...node.data, lanes: node.data.lanes.map((lane: string) => (lane === from ? trimmed : lane)) } }
}
if (node.parentNode === parallelId && laneOf(node.data) === from && (node.data.kind === 'APPROVAL' || node.data.kind === 'ACTION')) {
return { ...node, data: { ...node.data, laneId: trimmed } }
}
return node
})
return true
}
function document(): ProcessDefinitionDocument {
return toDocument(definitionId.value, name.value, startStepId.value, nodes.value, edges.value)
}
function localErrors() {
return graphErrors.value
}
return {
definitionId,
name,
startStepId,
publishedVersion,
loadedFromDraft,
nodes,
edges,
selectedNodeId,
selectedEdgeId,
selectedNodeIds,
selectedEdgeIds,
selectedNode,
selectedEdge,
graphErrors,
isDirty,
canUndo,
canRedo,
canPaste,
clear,
resetEmpty,
loadDocument,
loadGraph,
addNode,
setStart,
renameNode,
patchNodeData,
patchEdgeData,
onConnect,
selectNode,
selectEdge,
setSelection,
removeNode,
removeEdge,
removeNodes,
removeEdges,
removeSelected,
hasOutgoing,
hasOutgoingAmong,
copySelection,
pasteClipboard,
undo,
redo,
pushHistory,
alignSelected,
document,
localErrors,
captureBaseline,
relayout,
syncEdgeHandles,
settleLanes,
syncStartFlags,
addLane,
removeLane,
addLaneStep,
renameLane,
}
})