feat: bootstrap Ordo process designer with PARALLEL swimlanes

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0264408
2026-09-18 18:19:10 +08:00
co-authored by Cursor
commit 2c2dd8d0d2
46 changed files with 7801 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+42
View File
@@ -0,0 +1,42 @@
# ordo-designer-demo
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
pnpm install
```
### Compile and Hot-Reload for Development
```sh
pnpm dev
```
### Type-Check, Compile and Minify for Production
```sh
pnpm build
```
+33
View File
@@ -0,0 +1,33 @@
# Ordo Designer Roadmap
## P0 能用
- 分支边错开(同 from 多出边不再叠线)
- 加载后分层自动排布,分支横向错开
- 节点卡片展示候选人 / policy / action / due
- 校验错误挂到节点和边上,不只 toast 第一条
- 草稿 vs 已发布:可丢弃本地、对比后再打开
- 切定义不清空 Pinia,避免闪旧图
- 脏标记 + 离开未保存提示
## P1 好用
- 撤销 / 重做
- 框选、多选删除、复制粘贴
- due.after 用时长控件,禁止手填乱 ISO
- 改步骤 id:失焦提交 + 冲突提示
- 发布结果区分「新版本」和「图未变」
- catalog 失败时的空态与可手输
- 空画布引导;结束节点唯一性提示
- 历史版本只读或「另存为草稿」
- 删除确认(有出边时)
## P2 打磨
- 左右 Handle、回边、网格吸附、对齐
- @vue-flow/minimap、快捷键说明
- 引擎 JSON 预览 / 导出
- 列表里同一 id 的草稿与已发布关系
- 统一 loading / 网络错误
- 属性面板 n-form 校验态
- 自动保存草稿
Vendored
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ordo Designer</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
{
"name": "ordo-designer-demo",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build"
},
"dependencies": {
"@lucide/vue": "^1.46.0",
"@tailwindcss/vite": "^4.3.3",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.2",
"@vue-flow/minimap": "^1.5.4",
"naive-ui": "^2.45.3",
"pinia": "^4.0.2",
"tailwindcss": "^4.3.3",
"vue": "^3.5.40",
"vue-router": "^5.2.0"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
"@types/node": "^24.13.3",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1",
"npm-run-all2": "^9.0.2",
"typescript": "~6.0.0",
"vite": "^8.1.5",
"vite-plugin-vue-devtools": "^8.1.5",
"vue-tsc": "^3.3.7"
},
"engines": {
"node": "^22.18.0 || >=24.12.0"
}
}
+2625
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
vue-demi: false
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+11
View File
@@ -0,0 +1,11 @@
<script setup lang="ts"></script>
<template>
<n-config-provider>
<n-dialog-provider>
<n-message-provider>
<router-view />
</n-message-provider>
</n-dialog-provider>
</n-config-provider>
</template>
+75
View File
@@ -0,0 +1,75 @@
import type { CatalogItem, PageResponse, ProcessDefinitionDocument } from '@/types/definition'
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
...init,
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {}),
},
})
if (!res.ok) {
let message = res.statusText
try {
const body = (await res.json()) as { message?: string }
if (body.message) {
message = body.message
}
} catch {
/* ignore */
}
throw new Error(message)
}
if (res.status === 204) {
return undefined as T
}
return (await res.json()) as T
}
export function parseDefinition(doc: ProcessDefinitionDocument) {
return request<ProcessDefinitionDocument>('/ordo/definitions/parse', {
method: 'POST',
body: JSON.stringify(doc),
})
}
export function publishDefinition(doc: ProcessDefinitionDocument) {
return request<ProcessDefinitionDocument>('/ordo/definitions', {
method: 'POST',
body: JSON.stringify(doc),
})
}
export function listDefinitions(page = 0, size = 50) {
return request<PageResponse<ProcessDefinitionDocument>>(
`/ordo/definitions?page=${page}&size=${size}`,
)
}
export function getDefinition(id: string) {
return request<ProcessDefinitionDocument>(`/ordo/definitions/${encodeURIComponent(id)}`)
}
export function listDefinitionVersions(id: string, page = 0, size = 50) {
return request<PageResponse<ProcessDefinitionDocument>>(
`/ordo/definitions/${encodeURIComponent(id)}/versions?page=${page}&size=${size}`,
)
}
export function getDefinitionVersion(id: string, version: number) {
return request<ProcessDefinitionDocument>(
`/ordo/definitions/${encodeURIComponent(id)}/versions/${version}`,
)
}
export function listConditions() {
return request<CatalogItem[]>('/ordo/catalog/conditions')
}
export function listActions() {
return request<CatalogItem[]>('/ordo/catalog/actions')
}
export function listAssignees() {
return request<CatalogItem[]>('/ordo/catalog/assignees')
}
@@ -0,0 +1,235 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { CatalogItem } from '@/types/definition'
import PredicateBuilder from '@/components/designer/PredicateBuilder.vue'
import {
collapseTrivialGroups,
defaultLeaf,
formatWhen,
isRefWhen,
validatePredicate,
type RoutingWhen,
} from '@/mapper/when'
const props = defineProps<{
when?: RoutingWhen
conditions: CatalogItem[]
disabled?: boolean
}>()
const emit = defineEmits<{
'update:when': [value: RoutingWhen | undefined]
}>()
const open = ref(false)
const editorKind = ref<'predicate' | 'ref'>('predicate')
const draft = ref<RoutingWhen>({ and: [defaultLeaf()] })
const error = ref('')
const mode = ref<'none' | 'predicate' | 'ref'>(
!props.when ? 'none' : isRefWhen(props.when) ? 'ref' : 'predicate',
)
const preview = computed(() => formatWhen(props.when))
const draftPreview = computed(() => formatWhen(draft.value))
const modalTitle = computed(() => (editorKind.value === 'ref' ? 'Edit host condition' : 'Edit predicate'))
const conditionOptions = computed(() =>
props.conditions.map((i) => ({ label: `${i.label} (${i.key})`, value: i.key })),
)
const draftRef = computed(() => (isRefWhen(draft.value) ? draft.value.ref : null))
const draftItem = computed(() => props.conditions.find((c) => c.key === draftRef.value))
function setMode(next: 'none' | 'predicate' | 'ref') {
if (props.disabled) {
return
}
mode.value = next
if (next === 'none') {
emit('update:when', undefined)
return
}
if (next === 'ref') {
if (isRefWhen(props.when)) {
return
}
const first = props.conditions[0]
emit('update:when', first ? { ref: first.key, args: emptyArgs(first) } : undefined)
return
}
if (props.when && !isRefWhen(props.when)) {
return
}
emit('update:when', undefined)
}
function emptyArgs(item: CatalogItem): Record<string, unknown> {
const args: Record<string, unknown> = {}
for (const param of item.params ?? []) {
args[param.name] = param.type === 'boolean' ? false : param.type === 'number' ? 0 : ''
}
return args
}
function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T
}
function defaultRef(): RoutingWhen {
const first = props.conditions[0]
return first ? { ref: first.key, args: emptyArgs(first) } : { ref: '', args: {} }
}
function openEditor() {
if (props.disabled) {
return
}
error.value = ''
if (mode.value === 'ref') {
editorKind.value = 'ref'
draft.value = isRefWhen(props.when) ? cloneJson(props.when) : defaultRef()
} else {
editorKind.value = 'predicate'
draft.value = editableCopy(props.when)
}
open.value = true
}
function editableCopy(when: RoutingWhen | undefined): RoutingWhen {
if (!when || isRefWhen(when)) {
return { and: [defaultLeaf()] }
}
const copy = cloneJson(when)
if ('and' in copy || 'or' in copy || 'not' in copy) {
return copy
}
return { and: [copy] }
}
function onDraftRefKey(key: string | null) {
if (!key) {
draft.value = { ref: '', args: {} }
return
}
const item = props.conditions.find((c) => c.key === key)
draft.value = { ref: key, args: item ? emptyArgs(item) : {} }
}
function patchDraftArg(name: string, type: string, raw: unknown) {
if (!isRefWhen(draft.value)) {
return
}
let value: unknown = raw
if (type === 'number') {
value = typeof raw === 'number' ? raw : Number(raw)
}
if (type === 'boolean') {
value = Boolean(raw)
}
draft.value = { ref: draft.value.ref, args: { ...(draft.value.args ?? {}), [name]: value } }
}
function confirm() {
if (editorKind.value === 'ref') {
if (!isRefWhen(draft.value) || !draft.value.ref.trim()) {
error.value = 'Select a host condition'
return
}
emit('update:when', draft.value)
open.value = false
return
}
const normalized = collapseTrivialGroups(draft.value)
const message = validatePredicate(normalized)
if (message) {
error.value = message
return
}
emit('update:when', normalized)
open.value = false
}
</script>
<template>
<div class="flex flex-col gap-2">
<n-radio-group :value="mode" :disabled="disabled" size="small" @update:value="setMode">
<n-radio-button value="none">None</n-radio-button>
<n-radio-button value="predicate">Predicate</n-radio-button>
<n-radio-button value="ref">Host</n-radio-button>
</n-radio-group>
<template v-if="mode === 'predicate' || mode === 'ref'">
<button
type="button"
class="cursor-pointer rounded-md border border-slate-200 bg-slate-50 px-2 py-2 text-left text-xs leading-5 text-slate-700 hover:border-sky-300 hover:bg-white disabled:cursor-not-allowed disabled:opacity-60"
:disabled="disabled"
@click="openEditor"
>
{{ preview || (mode === 'ref' ? 'No host condition' : 'No predicate') }}
</button>
<n-button size="small" secondary :disabled="disabled" @click="openEditor">Edit</n-button>
</template>
<n-modal v-model:show="open" :mask-closable="false">
<n-card :title="modalTitle" style="width: 720px" closable @close="open = false">
<n-alert type="info" :bordered="false" class="mb-3">
<template v-if="editorKind === 'ref'">
Host conditions are resolved by the application. Arguments are passed to the selected ref.
</template>
<template v-else>
Variables come from the process context, dotted paths included.
AND requires every child, OR any child, NOT inverts. IN is comma-separated.
A missing variable is false. Conditional edges are matched before the default edge.
</template>
</n-alert>
<div class="max-h-[60vh] overflow-auto pr-1">
<template v-if="editorKind === 'ref'">
<n-form-item label="condition" :show-feedback="false">
<n-select
filterable
tag
:disabled="disabled"
:value="draftRef"
:options="conditionOptions"
@update:value="onDraftRefKey"
/>
</n-form-item>
<n-form-item
v-for="param in draftItem?.params ?? []"
:key="param.name"
:label="param.label || param.name"
:show-feedback="false"
>
<n-input-number
v-if="param.type === 'number'"
:disabled="disabled"
:value="Number(isRefWhen(draft) ? (draft.args?.[param.name] ?? 0) : 0)"
@update:value="(v: number) => patchDraftArg(param.name, param.type, v ?? 0)"
/>
<n-switch
v-else-if="param.type === 'boolean'"
:disabled="disabled"
:value="Boolean(isRefWhen(draft) ? draft.args?.[param.name] : false)"
@update:value="(v: boolean) => patchDraftArg(param.name, param.type, v)"
/>
<n-input
v-else
:disabled="disabled"
:value="String(isRefWhen(draft) ? (draft.args?.[param.name] ?? '') : '')"
@update:value="(v: string) => patchDraftArg(param.name, param.type, v)"
/>
</n-form-item>
</template>
<PredicateBuilder v-else :node="draft" :disabled="disabled" @update:node="(value) => (draft = value)" />
</div>
<p v-if="draftPreview" class="mt-3 text-xs leading-5 text-slate-500">Preview: {{ draftPreview }}</p>
<p v-if="error" class="mt-2 text-xs text-red-600">{{ error }}</p>
<n-space justify="end" class="mt-4">
<n-button @click="open = false">Cancel</n-button>
<n-button type="primary" @click="confirm">OK</n-button>
</n-space>
</n-card>
</n-modal>
</div>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
const show = defineModel<boolean>('show', { default: false })
defineProps<{
draftText: string
publishedText: string
}>()
const emit = defineEmits<{
openDraft: []
discard: []
}>()
const comparing = ref(false)
watch(show, (value) => {
if (!value) {
comparing.value = false
}
})
</script>
<template>
<n-modal v-model:show="show">
<n-card title="Local draft vs published" style="width: 720px" closable @close="show = false">
<p class="mb-3 text-sm text-slate-600">This process has both a local draft and a published version.</p>
<div v-if="comparing" class="mb-3 grid max-h-90 grid-cols-2 gap-3 overflow-auto">
<div>
<div class="mb-1 text-xs font-medium text-slate-500">Local draft</div>
<pre class="overflow-auto rounded bg-slate-50 p-2 text-[11px] leading-4">{{ draftText }}</pre>
</div>
<div>
<div class="mb-1 text-xs font-medium text-slate-500">Published</div>
<pre class="overflow-auto rounded bg-slate-50 p-2 text-[11px] leading-4">{{ publishedText }}</pre>
</div>
</div>
<n-space justify="end">
<n-button @click="comparing = !comparing">{{ comparing ? 'Hide diff' : 'Compare' }}</n-button>
<n-button @click="emit('openDraft')">Open draft</n-button>
<n-button type="warning" @click="emit('discard')">Discard local & open published</n-button>
</n-space>
</n-card>
</n-modal>
</template>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
import { parseIsoDuration, toIsoDuration, type DurationUnit } from '@/mapper/duration'
const props = defineProps<{
iso: string
disabled?: boolean
}>()
const emit = defineEmits<{
'update:iso': [value: string]
}>()
const parsed = computed(() => parseIsoDuration(props.iso))
const unitOptions = [
{ label: 'minutes', value: 'minutes' },
{ label: 'hours', value: 'hours' },
{ label: 'days', value: 'days' },
]
function emitIso(value: number, unit: DurationUnit) {
emit('update:iso', toIsoDuration(value, unit))
}
</script>
<template>
<div class="flex w-full flex-col gap-2">
<n-input-number
:style="{ width: '100%' }"
:min="1"
:disabled="disabled"
:value="parsed.value"
@update:value="(v: number | null) => emitIso(v ?? 1, parsed.unit)"
/>
<n-select
:style="{ width: '100%' }"
:disabled="disabled"
:value="parsed.unit"
:options="unitOptions"
@update:value="(v: DurationUnit) => emitIso(parsed.value, v)"
/>
</div>
</template>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { useDesignerStore } from '@/stores/designer'
const designer = useDesignerStore()
function jump(nodeId?: string, edgeId?: string) {
if (nodeId) {
designer.selectNode(nodeId)
return
}
if (edgeId) {
designer.selectEdge(edgeId)
}
}
</script>
<template>
<div v-if="designer.graphErrors.length" class="border-b border-red-100 bg-red-50 px-4 py-2">
<div class="text-xs font-medium text-red-700">Validation errors {{ designer.graphErrors.length }}</div>
<button
v-for="(err, index) in designer.graphErrors"
:key="`${err.message}-${index}`"
class="block w-full truncate text-left text-xs text-red-600 hover:underline"
type="button"
@click="jump(err.nodeId, err.edgeId)"
>
{{ err.message }}
</button>
</div>
</template>
+82
View File
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { Bolt, Flag, GitBranch, ShieldCheck } from '@lucide/vue'
import { ACTION_TYPE, APPROVAL_TYPE, END_TYPE, PARALLEL_TYPE } from '@/mapper/graph'
const items = [
{
type: APPROVAL_TYPE,
label: 'Approval',
hint: 'Human review step',
accent: 'bg-sky-500',
badge: 'bg-sky-50 text-sky-700',
border: 'border-slate-200 hover:border-sky-300',
icon: ShieldCheck,
},
{
type: ACTION_TYPE,
label: 'Action',
hint: 'System action step',
accent: 'bg-amber-500',
badge: 'bg-amber-50 text-amber-700',
border: 'border-slate-200 hover:border-amber-300',
icon: Bolt,
},
{
type: PARALLEL_TYPE,
label: 'Parallel',
hint: 'Join all branches',
accent: 'bg-violet-500',
badge: 'bg-violet-50 text-violet-700',
border: 'border-slate-200 hover:border-violet-300',
icon: GitBranch,
},
{
type: END_TYPE,
label: 'End',
hint: 'Process terminal',
accent: 'bg-slate-500',
badge: 'bg-slate-100 text-slate-600',
border: 'border-slate-200 hover:border-slate-400',
icon: Flag,
},
]
function onDragStart(event: DragEvent, type: string) {
if (!event.dataTransfer) {
return
}
event.dataTransfer.setData('application/vueflow', type)
event.dataTransfer.effectAllowed = 'move'
}
</script>
<template>
<aside class="flex h-full w-56 shrink-0 flex-col gap-3 border-r border-slate-200 bg-white p-3">
<div class="text-xs font-semibold tracking-wide text-slate-500 uppercase">Nodes</div>
<div class="flex flex-col gap-2">
<div
v-for="item in items"
:key="item.type"
class="group relative cursor-grab overflow-hidden rounded-xl border bg-white shadow-[0_1px_2px_rgba(15,23,42,0.05)] transition-all hover:shadow-[0_2px_8px_rgba(15,23,42,0.1)] active:cursor-grabbing active:scale-[0.98]"
:class="item.border"
draggable="true"
@dragstart="onDragStart($event, item.type)"
>
<div class="absolute inset-y-0 left-0 w-1" :class="item.accent" />
<div class="flex items-start gap-2.5 px-3 py-2.5 pl-3.5">
<span
class="mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
:class="item.badge"
>
<component :is="item.icon" :size="14" />
</span>
<div class="min-w-0 flex-1">
<div class="text-[13px] font-semibold text-slate-800">{{ item.label }}</div>
<div class="mt-0.5 text-[11px] text-slate-500">{{ item.hint }}</div>
</div>
</div>
</div>
</div>
<p class="mt-auto text-[10px] leading-relaxed text-slate-400">Drag a node onto the canvas</p>
</aside>
</template>
@@ -0,0 +1,175 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
MAX_PREDICATE_DEPTH,
MAX_PREDICATE_LEAVES,
countLeaves,
defaultLeaf,
groupChildren,
isLogicGroup,
isNotWhen,
isRefWhen,
leafOp,
leafValueText,
leafVar,
makeLeaf,
type CompareOp,
type RoutingWhen,
} from '@/mapper/when'
const props = defineProps<{
node: RoutingWhen
depth?: number
removable?: boolean
disabled?: boolean
}>()
const emit = defineEmits<{
'update:node': [value: RoutingWhen]
remove: []
}>()
const depth = computed(() => props.depth ?? 1)
const atLeafLimit = computed(() => countLeaves(props.node) >= MAX_PREDICATE_LEAVES)
const atDepthLimit = computed(() => depth.value >= MAX_PREDICATE_DEPTH)
const opOptions = [
{ label: '=', value: 'eq' },
{ label: '≠', value: 'ne' },
{ label: '>', value: 'gt' },
{ label: '≥', value: 'gte' },
{ label: '<', value: 'lt' },
{ label: '≤', value: 'lte' },
{ label: 'in', value: 'in' },
]
const groupOp = computed(() => ('or' in props.node ? 'or' : 'and'))
const children = computed(() => (isLogicGroup(props.node) ? groupChildren(props.node) : []))
function setGroupOp(op: 'and' | 'or') {
emit('update:node', op === 'and' ? { and: children.value } : { or: children.value })
}
function replaceChild(index: number, child: RoutingWhen) {
const next = [...children.value]
next[index] = child
setChildren(next)
}
function setChildren(next: RoutingWhen[]) {
emit('update:node', groupOp.value === 'and' ? { and: next } : { or: next })
}
function addLeaf() {
setChildren([...children.value, defaultLeaf()])
}
function addGroup() {
setChildren([...children.value, { and: [defaultLeaf()] }])
}
function removeChild(index: number) {
setChildren(children.value.filter((_, i) => i !== index))
}
function patchLeaf(patch: { op?: CompareOp; variable?: string; value?: string }) {
emit(
'update:node',
makeLeaf(patch.op ?? leafOp(props.node), patch.variable ?? leafVar(props.node), patch.value ?? leafValueText(props.node)),
)
}
function wrapNot() {
emit('update:node', { not: props.node })
}
function unwrapNot() {
if (isNotWhen(props.node)) {
emit('update:node', props.node.not)
}
}
</script>
<template>
<div
v-if="isLogicGroup(node)"
class="rounded-lg border bg-white p-3"
:class="groupOp === 'and' ? 'border-sky-200' : 'border-amber-200'"
>
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-slate-500">Group</span>
<n-radio-group :value="groupOp" size="small" :disabled="disabled" @update:value="setGroupOp">
<n-radio-button value="and">AND</n-radio-button>
<n-radio-button value="or">OR</n-radio-button>
</n-radio-group>
</div>
<n-button v-if="removable" size="tiny" quaternary type="error" :disabled="disabled" @click="emit('remove')">
Remove group
</n-button>
</div>
<div class="flex flex-col gap-2 border-l-2 pl-3" :class="groupOp === 'and' ? 'border-sky-300' : 'border-amber-300'">
<p v-if="children.length === 0" class="text-xs text-slate-400">No conditions yet.</p>
<PredicateBuilder
v-for="(child, index) in children"
:key="index"
:node="child"
:depth="depth + 1"
removable
:disabled="disabled"
@update:node="(value) => replaceChild(index, value)"
@remove="removeChild(index)"
/>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<n-button size="small" secondary :disabled="disabled || atLeafLimit" @click="addLeaf">
Add condition
</n-button>
<n-button size="small" secondary :disabled="disabled || atDepthLimit || atLeafLimit" @click="addGroup">
Add group
</n-button>
</div>
</div>
<div v-else-if="isNotWhen(node)" class="rounded-lg border border-dashed border-rose-300 bg-rose-50/70 p-3">
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-rose-700">NOT</span>
<n-button size="tiny" quaternary :disabled="disabled" @click="unwrapNot">Remove NOT</n-button>
</div>
<PredicateBuilder
:node="node.not"
:depth="depth + 1"
:disabled="disabled"
@update:node="(value) => emit('update:node', { not: value })"
/>
</div>
<div v-else-if="!isRefWhen(node)" class="flex flex-wrap items-center gap-2 rounded-md bg-slate-50 px-2 py-2 ring-1 ring-slate-200">
<n-input
class="min-w-36 flex-1"
size="small"
placeholder="variable"
:disabled="disabled"
:value="leafVar(node)"
@update:value="(v: string) => patchLeaf({ variable: v })"
/>
<n-select
class="w-36"
size="small"
:disabled="disabled"
:value="leafOp(node)"
:options="opOptions"
@update:value="(v: CompareOp) => patchLeaf({ op: v })"
/>
<n-input
class="min-w-36 flex-1"
size="small"
:placeholder="leafOp(node) === 'in' ? 'comma-separated' : 'value'"
:disabled="disabled"
:value="leafValueText(node)"
@update:value="(v: string) => patchLeaf({ value: v })"
/>
<n-button size="small" quaternary :disabled="disabled || atDepthLimit" @click="wrapNot">NOT</n-button>
<n-button v-if="removable" size="small" quaternary type="error" :disabled="disabled" @click="emit('remove')">Remove</n-button>
</div>
</template>
+388
View File
@@ -0,0 +1,388 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import type { FormRules } from 'naive-ui'
import { useDialog } from 'naive-ui'
import { useCatalogStore } from '@/stores/catalog'
import { useDesignerStore } from '@/stores/designer'
import type { ActionNodeData, ApprovalNodeData, ParallelNodeData, ApprovalPolicy, DueThen } from '@/types/definition'
import { END_TYPE } from '@/mapper/graph'
import DurationAfter from '@/components/designer/DurationAfter.vue'
import ConditionWhenEditor from '@/components/designer/ConditionWhenEditor.vue'
import type { RoutingWhen } from '@/mapper/when'
const props = defineProps<{
readOnly?: boolean
}>()
const designer = useDesignerStore()
const catalog = useCatalogStore()
const dialog = useDialog()
const { definitionId, name, selectedNode, selectedEdge } = storeToRefs(designer)
const draftId = ref('')
const idError = ref('')
watch(
() => selectedNode.value?.id,
(id) => {
draftId.value = id ?? ''
idError.value = ''
},
{ immediate: true },
)
const actionOptions = computed(() =>
catalog.actions.map((i) => ({ label: `${i.label} (${i.key})`, value: i.key })),
)
const assigneeOptions = computed(() =>
catalog.assignees.map((i) => ({ label: `${i.label} (${i.key})`, value: i.key })),
)
const policyOptions = [
{ label: 'ANY', value: 'ANY' },
{ label: 'ALL', value: 'ALL' },
]
const thenOptions = [
{ label: 'reassign', value: 'reassign' },
{ label: 'notify', value: 'notify' },
{ label: 'goto', value: 'goto' },
]
const approval = computed(() =>
selectedNode.value?.data.kind === 'APPROVAL' ? (selectedNode.value.data as ApprovalNodeData) : null,
)
const action = computed(() =>
selectedNode.value?.data.kind === 'ACTION' ? (selectedNode.value.data as ActionNodeData) : null,
)
const parallel = computed(() =>
selectedNode.value?.data.kind === 'PARALLEL' ? (selectedNode.value.data as ParallelNodeData) : null,
)
const isStart = computed(() =>
Boolean(selectedNode.value && selectedNode.value.data.kind !== 'END' && selectedNode.value.data.isStart),
)
const processModel = computed(() => ({
id: definitionId.value,
name: name.value,
}))
const processRules: FormRules = {
id: { required: true, message: 'id is required', trigger: ['input', 'blur'] },
name: { required: true, message: 'name is required', trigger: ['input', 'blur'] },
}
const stepRules = computed<FormRules>(() => ({
candidates: {
trigger: ['change', 'blur'],
validator() {
if (!approval.value) {
return true
}
if (approval.value.candidates.length < 1) {
return new Error('Select at least one candidate')
}
return true
},
},
after: {
trigger: ['change', 'blur'],
validator() {
if (!approval.value?.dueEnabled) {
return true
}
if (!approval.value.due.after?.trim()) {
return new Error('after is required when due is on')
}
return true
},
},
action: {
trigger: ['change', 'blur'],
validator() {
if (!action.value) {
return true
}
if (!action.value.action?.trim()) {
return new Error('action is required')
}
return true
},
},
}))
function commitId() {
const node = selectedNode.value
if (!node || props.readOnly) {
return
}
const trimmed = draftId.value.trim()
if (!trimmed || trimmed === node.id) {
draftId.value = node.id
idError.value = ''
return
}
if (!designer.renameNode(node.id, trimmed)) {
idError.value = 'Step id already exists'
draftId.value = node.id
return
}
idError.value = ''
}
function onStartChange(checked: boolean) {
const node = selectedNode.value
if (checked && node && !props.readOnly) {
designer.setStart(node.id)
}
}
function patchSelected(patch: Record<string, unknown>) {
const node = selectedNode.value
if (!node || props.readOnly) {
return
}
designer.patchNodeData(node.id, patch)
}
function patchDue(patch: Partial<ApprovalNodeData['due']>) {
const current = approval.value
if (!current || props.readOnly) {
return
}
patchSelected({ due: { ...current.due, ...patch } })
}
function patchSelectedEdge(patch: { when?: RoutingWhen; priority?: number }) {
const edge = selectedEdge.value
if (!edge || props.readOnly) {
return
}
designer.patchEdgeData(edge.id, patch)
}
async function confirmOutgoing(ids: string[]): Promise<boolean> {
if (!designer.hasOutgoingAmong(ids)) {
return true
}
return new Promise((resolve) => {
dialog.warning({
title: 'Delete node',
content: 'This node has outgoing edges. Delete it and connected edges?',
positiveText: 'Delete',
negativeText: 'Cancel',
onPositiveClick: () => resolve(true),
onNegativeClick: () => resolve(false),
onClose: () => resolve(false),
})
})
}
async function deleteSelectedNode() {
const node = selectedNode.value
if (!node || props.readOnly) {
return
}
const ok = await confirmOutgoing([node.id])
if (!ok) {
return
}
designer.removeNode(node.id)
}
function deleteSelectedEdge() {
const edge = selectedEdge.value
if (!edge || props.readOnly) {
return
}
designer.removeEdge(edge.id)
}
</script>
<template>
<aside class="h-full w-80 shrink-0 overflow-y-auto border-l border-slate-200 bg-white p-3">
<n-alert v-if="catalog.error" class="mb-3" type="warning" :bordered="false">
Catalog unavailable, type keys manually
</n-alert>
<n-alert v-if="readOnly" class="mb-3" type="info" :bordered="false">Read-only historical version</n-alert>
<div v-if="selectedNode && selectedNode.type === END_TYPE" class="flex flex-col gap-3">
<div class="text-xs font-semibold text-slate-500">End</div>
<n-button type="error" secondary :disabled="readOnly" @click="deleteSelectedNode">Delete node</n-button>
</div>
<n-form
v-else-if="selectedNode"
:model="selectedNode.data"
:rules="stepRules"
:disabled="readOnly"
:show-feedback="true"
>
<div class="flex items-center justify-between">
<div class="text-xs font-semibold text-slate-500">Step</div>
<n-button size="tiny" type="error" quaternary :disabled="readOnly" @click="deleteSelectedNode">Delete</n-button>
</div>
<n-form-item
label="id"
:validation-status="idError ? 'error' : undefined"
:feedback="idError"
>
<n-input
v-model:value="draftId"
:disabled="readOnly"
@blur="commitId"
@keydown.enter.prevent="commitId"
/>
</n-form-item>
<n-form-item label="name">
<n-input
:disabled="readOnly"
:value="selectedNode.data.name"
@update:value="(v: string) => patchSelected({ name: v })"
/>
</n-form-item>
<n-form-item label="start step">
<n-switch :disabled="readOnly" :value="isStart" @update:value="onStartChange" />
</n-form-item>
<template v-if="approval">
<n-form-item label="policy">
<n-select
:disabled="readOnly"
:value="approval.policy"
:options="policyOptions"
@update:value="(v: ApprovalPolicy) => patchSelected({ policy: v })"
/>
</n-form-item>
<n-form-item label="candidates" path="candidates">
<n-select
multiple
filterable
tag
:disabled="readOnly"
:value="approval.candidates"
:options="assigneeOptions"
@update:value="(v: string[]) => patchSelected({ candidates: v })"
/>
</n-form-item>
<n-form-item label="due">
<n-switch
:disabled="readOnly"
:value="approval.dueEnabled"
@update:value="(v: boolean) => patchSelected({ dueEnabled: v })"
/>
</n-form-item>
<template v-if="approval.dueEnabled">
<n-form-item label="after" path="after">
<DurationAfter :iso="approval.due.after" :disabled="readOnly" @update:iso="(v) => patchDue({ after: v })" />
</n-form-item>
<n-form-item label="then">
<n-select
:disabled="readOnly"
:value="approval.due.then"
:options="thenOptions"
@update:value="(v: DueThen) => patchDue({ then: v })"
/>
</n-form-item>
<n-form-item v-if="approval.due.then === 'reassign'" label="to">
<n-select
filterable
tag
:disabled="readOnly"
:value="approval.due.to || null"
:options="assigneeOptions"
@update:value="(v: string | null) => patchDue({ to: v ?? undefined })"
/>
</n-form-item>
<n-form-item v-if="approval.due.then === 'goto'" label="to">
<n-input :disabled="readOnly" :value="approval.due.to ?? ''" @update:value="(v: string) => patchDue({ to: v })" />
</n-form-item>
<n-form-item v-if="approval.due.then === 'notify'" label="action">
<n-select
filterable
tag
clearable
:disabled="readOnly"
:value="approval.due.action ?? null"
:options="actionOptions"
@update:value="(v: string | null) => patchDue({ action: v ?? undefined })"
/>
</n-form-item>
</template>
</template>
<template v-if="action">
<n-form-item label="action" path="action">
<n-select
filterable
tag
:disabled="readOnly"
:value="action.action || null"
:options="actionOptions"
@update:value="(v: string) => patchSelected({ action: v ?? '' })"
/>
</n-form-item>
</template>
<template v-if="parallel && selectedNode">
<n-form-item label="branches">
<div class="flex w-full flex-col gap-2">
<div v-for="lane in parallel.lanes" :key="lane" class="flex items-center gap-2">
<n-input
:disabled="readOnly"
:value="lane"
@change="(v: string) => designer.renameLane(selectedNode!.id, lane, v)"
/>
<n-button
size="small"
quaternary
type="error"
:disabled="readOnly || parallel.lanes.length <= 2"
@click="designer.removeLane(selectedNode.id, lane)"
>
Remove
</n-button>
</div>
<n-button size="small" :disabled="readOnly" @click="designer.addLane(selectedNode.id)">Add branch</n-button>
</div>
</n-form-item>
</template>
</n-form>
<div v-else-if="selectedEdge" class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<div class="text-xs font-semibold text-slate-500">Edge</div>
<n-button size="tiny" type="error" quaternary :disabled="readOnly" @click="deleteSelectedEdge">Delete</n-button>
</div>
<n-form :show-feedback="true" :disabled="readOnly">
<n-form-item label="when">
<ConditionWhenEditor
:key="selectedEdge.id"
:when="selectedEdge.data?.when"
:conditions="catalog.conditions"
:disabled="readOnly"
@update:when="(v) => patchSelectedEdge({ when: v })"
/>
</n-form-item>
<n-form-item label="priority">
<n-input-number
:disabled="readOnly"
:value="selectedEdge.data?.priority ?? 0"
:min="0"
@update:value="(v: number | null) => patchSelectedEdge({ priority: v ?? 0 })"
/>
</n-form-item>
</n-form>
</div>
<n-form v-else :model="processModel" :rules="processRules" :show-feedback="true" :disabled="readOnly">
<div class="text-xs font-semibold text-slate-500">Process</div>
<n-form-item label="id" path="id">
<n-input :disabled="readOnly" v-model:value="definitionId" />
</n-form-item>
<n-form-item label="name" path="name">
<n-input :disabled="readOnly" v-model:value="name" />
</n-form-item>
</n-form>
</aside>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue'
import { BaseEdge, EdgeLabelRenderer, getBezierPath, type EdgeProps } from '@vue-flow/core'
import type { DesignerEdgeData } from '@/types/definition'
import { useDesignerStore } from '@/stores/designer'
const props = defineProps<EdgeProps<DesignerEdgeData>>()
const designer = useDesignerStore()
const route = computed(() => {
const [path, labelX, labelY] = getBezierPath({
sourceX: props.sourceX,
sourceY: props.sourceY,
sourcePosition: props.sourcePosition,
targetX: props.targetX,
targetY: props.targetY,
targetPosition: props.targetPosition,
})
return { path, labelX, labelY }
})
const hasError = computed(() => designer.graphErrors.some((e) => e.edgeId === props.id))
const edgeStyle = computed(() => ({
stroke: hasError.value ? '#ef4444' : '#94a3b8',
strokeWidth: hasError.value ? 2 : 1.75,
}))
</script>
<template>
<BaseEdge :id="id" :path="route.path" :marker-end="markerEnd" :style="edgeStyle" />
<EdgeLabelRenderer>
<div
class="nodrag nopan pointer-events-all absolute rounded-md border border-slate-200 bg-white/95 px-1.5 py-0.5 text-[10px] font-medium text-slate-600 shadow-sm"
:class="hasError ? 'border-red-200 text-red-600' : ''"
:style="{
transform: `translate(-50%, -50%) translate(${route.labelX}px, ${route.labelY}px)`,
}"
>
{{ label }}
</div>
</EdgeLabelRenderer>
</template>
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Bolt, CirclePlay } from '@lucide/vue'
import type { ActionNodeData } from '@/types/definition'
import { useCatalogLabels } from '@/composables/useCatalogLabels'
import { useDesignerStore } from '@/stores/designer'
import NodeHandles from '@/components/designer/nodes/NodeHandles.vue'
const props = defineProps<{
id: string
data: ActionNodeData
}>()
const designer = useDesignerStore()
const { action } = useCatalogLabels()
const hasError = computed(() => designer.graphErrors.some((e) => e.nodeId === props.id))
</script>
<template>
<div
class="group relative min-w-55 overflow-visible rounded-xl border bg-white shadow-[0_1px_2px_rgba(15,23,42,0.06),0_8px_20px_rgba(15,23,42,0.06)] transition-shadow"
:class="
hasError
? 'border-red-300 ring-2 ring-red-100'
: data.isStart
? 'border-amber-300 ring-2 ring-amber-100'
: 'border-slate-200 hover:shadow-[0_2px_8px_rgba(15,23,42,0.1)]'
"
>
<div class="absolute inset-y-0 left-0 w-1 bg-amber-500" />
<NodeHandles color-class="bg-amber-500!" />
<div class="px-3.5 py-3 pl-4">
<div class="mb-1.5 flex items-center gap-1.5">
<span class="inline-flex items-center gap-1 rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-amber-700">
<Bolt :size="12" />
ACTION
</span>
<span
v-if="data.isStart"
class="rounded-md bg-amber-500 px-1.5 py-0.5 text-[10px] font-medium text-white"
>
START
</span>
</div>
<div class="text-[13px] font-semibold text-slate-800">{{ data.name }}</div>
<div class="mt-2 flex items-center gap-1.5 text-[11px] text-slate-500">
<CirclePlay :size="12" class="shrink-0 text-slate-400" />
<span class="truncate">{{ data.action ? action(data.action) : 'Unset' }}</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Clock3, ShieldCheck, Users } from '@lucide/vue'
import type { ApprovalNodeData } from '@/types/definition'
import { useCatalogLabels } from '@/composables/useCatalogLabels'
import { useDesignerStore } from '@/stores/designer'
import NodeHandles from '@/components/designer/nodes/NodeHandles.vue'
const props = defineProps<{
id: string
data: ApprovalNodeData
}>()
const designer = useDesignerStore()
const { assignees } = useCatalogLabels()
const hasError = computed(() => designer.graphErrors.some((e) => e.nodeId === props.id))
</script>
<template>
<div
class="group relative min-w-55 overflow-visible rounded-xl border bg-white shadow-[0_1px_2px_rgba(15,23,42,0.06),0_8px_20px_rgba(15,23,42,0.06)] transition-shadow"
:class="
hasError
? 'border-red-300 ring-2 ring-red-100'
: data.isStart
? 'border-sky-300 ring-2 ring-sky-100'
: 'border-slate-200 hover:shadow-[0_2px_8px_rgba(15,23,42,0.1)]'
"
>
<div class="absolute inset-y-0 left-0 w-1 bg-sky-500" />
<NodeHandles color-class="bg-sky-500!" />
<div class="px-3.5 py-3 pl-4">
<div class="mb-1.5 flex items-center gap-1.5">
<span class="inline-flex items-center gap-1 rounded-md bg-sky-50 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-sky-700">
<ShieldCheck :size="12" />
APPROVAL
</span>
<span
v-if="data.isStart"
class="rounded-md bg-sky-500 px-1.5 py-0.5 text-[10px] font-medium text-white"
>
START
</span>
</div>
<div class="text-[13px] font-semibold text-slate-800">{{ data.name }}</div>
<div class="mt-2 space-y-1">
<div class="flex items-center gap-1.5 text-[11px] text-slate-500">
<Users :size="12" class="shrink-0 text-slate-400" />
<span class="truncate">{{ assignees(data.candidates) }}</span>
</div>
<div class="flex items-center gap-1.5 text-[11px] text-slate-500">
<span class="rounded bg-slate-100 px-1 py-px font-medium text-slate-600">{{ data.policy }}</span>
</div>
<div v-if="data.dueEnabled" class="flex items-center gap-1.5 text-[11px] text-slate-500">
<Clock3 :size="12" class="shrink-0 text-slate-400" />
<span class="truncate">{{ data.due.after }}</span>
</div>
</div>
</div>
</div>
</template>
+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Flag } from '@lucide/vue'
import type { EndNodeData } from '@/types/definition'
import { useDesignerStore } from '@/stores/designer'
import NodeHandles from '@/components/designer/nodes/NodeHandles.vue'
const props = defineProps<{
id: string
data: EndNodeData
}>()
const designer = useDesignerStore()
const hasError = computed(() => designer.graphErrors.some((e) => e.nodeId === props.id))
</script>
<template>
<div
class="relative flex h-19 w-19 flex-col items-center justify-center rounded-full border bg-linear-to-b from-slate-50 to-slate-100 shadow-[0_1px_2px_rgba(15,23,42,0.06),0_8px_20px_rgba(15,23,42,0.08)]"
:class="hasError ? 'border-red-300 ring-2 ring-red-100' : 'border-slate-300'"
>
<NodeHandles color-class="bg-slate-500!" :sources="false" />
<Flag :size="16" class="mb-0.5 text-slate-500" />
<span class="text-[11px] font-semibold text-slate-700">{{ data.name }}</span>
</div>
</template>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
withDefaults(
defineProps<{
colorClass: string
sources?: boolean
targets?: boolean
}>(),
{ sources: true, targets: true },
)
</script>
<template>
<!-- One handle per side: top=in, bottom=out, left/right shared for back-edges (Loose mode). -->
<Handle
v-if="targets"
id="target-top"
class="h-2.5! w-2.5! border-2! border-white!"
:class="colorClass"
type="target"
:position="Position.Top"
/>
<Handle
id="left"
class="h-2.5! w-2.5! border-2! border-white!"
:class="colorClass"
:type="sources ? 'source' : 'target'"
:position="Position.Left"
/>
<Handle
id="right"
class="h-2.5! w-2.5! border-2! border-white!"
:class="colorClass"
:type="sources ? 'source' : 'target'"
:position="Position.Right"
/>
<Handle
v-if="sources"
id="source-bottom"
class="h-2.5! w-2.5! border-2! border-white!"
:class="colorClass"
type="source"
:position="Position.Bottom"
/>
</template>
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { Handle, Position } from '@vue-flow/core'
import { GitBranch, Plus } from '@lucide/vue'
import type { ParallelNodeData } from '@/types/definition'
import { LANE_H, LANE_HEADER, LANE_LABEL_W } from '@/mapper/parallel'
import { useDesignerStore } from '@/stores/designer'
const props = defineProps<{
id: string
data: ParallelNodeData
}>()
const designer = useDesignerStore()
const route = useRoute()
const readOnly = computed(() => {
const versionRaw = route.query.version
return typeof versionRaw === 'string' && Number.isFinite(Number(versionRaw))
})
const hasError = computed(() => designer.graphErrors.some((e) => e.nodeId === props.id))
</script>
<template>
<div
class="relative h-full w-full overflow-visible rounded-xl border bg-violet-50/40"
:class="
hasError
? 'border-red-300 ring-2 ring-red-100'
: data.isStart
? 'border-violet-400 ring-2 ring-violet-100'
: 'border-violet-200'
"
>
<Handle
id="target-top"
class="h-2.5! w-2.5! border-2! border-white! bg-violet-500!"
type="target"
:position="Position.Top"
/>
<Handle
id="source-bottom"
class="h-2.5! w-2.5! border-2! border-white! bg-violet-500!"
type="source"
:position="Position.Bottom"
/>
<div class="flex h-11 items-center gap-2 border-b border-violet-100 px-3">
<span class="inline-flex items-center gap-1 rounded-md bg-violet-100 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-violet-700">
<GitBranch :size="12" />
PARALLEL
</span>
<span v-if="data.isStart" class="rounded-md bg-violet-500 px-1.5 py-0.5 text-[10px] font-medium text-white">START</span>
<span class="min-w-0 flex-1 truncate text-[13px] font-semibold text-slate-800">{{ data.name }}</span>
<button
type="button"
class="nodrag nopan inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium text-violet-700 hover:bg-violet-100 disabled:opacity-40"
:disabled="readOnly"
@click.stop="designer.addLane(id)"
>
<Plus :size="12" />
Branch
</button>
</div>
<div
v-for="(lane, index) in data.lanes"
:key="lane"
class="pointer-events-none absolute right-2 left-0 flex items-start border-b border-violet-100/80"
:style="{ top: `${LANE_HEADER + index * LANE_H}px`, height: `${LANE_H}px` }"
>
<div class="pointer-events-auto flex h-full flex-col justify-center gap-1 px-2" :style="{ width: `${LANE_LABEL_W}px` }">
<div class="truncate text-[11px] font-semibold text-violet-800">{{ lane }}</div>
<button
type="button"
class="nodrag nopan inline-flex items-center gap-0.5 text-[10px] font-medium text-slate-500 hover:text-violet-700 disabled:opacity-40"
:disabled="readOnly"
@click.stop="designer.addLaneStep(id, lane)"
>
<Plus :size="11" />
Step
</button>
</div>
</div>
</div>
</template>
+21
View File
@@ -0,0 +1,21 @@
import { ref } from 'vue'
export function useAsync() {
const loading = ref(false)
const error = ref('')
async function run<T>(fn: () => Promise<T>): Promise<T> {
loading.value = true
error.value = ''
try {
return await fn()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Request failed'
throw e
} finally {
loading.value = false
}
}
return { loading, error, run }
}
+31
View File
@@ -0,0 +1,31 @@
import { useCatalogStore } from '@/stores/catalog'
import type { CatalogItem } from '@/types/definition'
function labelOf(items: CatalogItem[], key: string) {
return items.find((i) => i.key === key)?.label ?? key
}
export function useCatalogLabels() {
const catalog = useCatalogStore()
function assignee(key: string) {
return labelOf(catalog.assignees, key)
}
function action(key: string) {
return labelOf(catalog.actions, key)
}
function assignees(keys: string[]) {
if (keys.length === 0) {
return 'Unset'
}
const labels = keys.map(assignee)
if (labels.length <= 2) {
return labels.join('、')
}
return `${labels.slice(0, 2).join('、')} +${labels.length - 2}`
}
return { assignee, action, assignees }
}
+19
View File
@@ -0,0 +1,19 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import naive from 'naive-ui'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import '@vue-flow/controls/dist/style.css'
import '@vue-flow/minimap/dist/style.css'
import './styles/main.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(naive)
app.mount('#app')
+29
View File
@@ -0,0 +1,29 @@
export type DurationUnit = 'minutes' | 'hours' | 'days'
export function parseIsoDuration(iso: string | undefined): { value: number; unit: DurationUnit } {
const raw = iso?.trim() ?? ''
const day = /^P(\d+)D$/i.exec(raw)
if (day) {
return { value: Number(day[1]), unit: 'days' }
}
const hour = /^PT(\d+)H$/i.exec(raw)
if (hour) {
return { value: Number(hour[1]), unit: 'hours' }
}
const min = /^PT(\d+)M$/i.exec(raw)
if (min) {
return { value: Number(min[1]), unit: 'minutes' }
}
return { value: 48, unit: 'hours' }
}
export function toIsoDuration(value: number, unit: DurationUnit): string {
const n = Math.max(1, Math.floor(value) || 1)
if (unit === 'days') {
return `P${n}D`
}
if (unit === 'hours') {
return `PT${n}H`
}
return `PT${n}M`
}
+227
View File
@@ -0,0 +1,227 @@
export type HandleSide = 'top' | 'bottom' | 'left' | 'right'
export interface ObstacleRect {
x: number
y: number
width: number
height: number
}
export interface OrthogonalPathParams {
sourceX: number
sourceY: number
targetX: number
targetY: number
sourceSide?: HandleSide
targetSide?: HandleSide
/** Stagger when several edges share a corridor. */
corridorOffset?: number
obstacles?: ObstacleRect[]
}
export type OrthogonalPathResult = {
path: string
labelX: number
labelY: number
}
export function sideFromHandleId(handleId?: string | null): HandleSide | undefined {
if (!handleId) {
return undefined
}
if (handleId.endsWith('-top') || handleId === 'top') {
return 'top'
}
if (handleId.endsWith('-bottom') || handleId === 'bottom') {
return 'bottom'
}
if (handleId.endsWith('-left') || handleId === 'left') {
return 'left'
}
if (handleId.endsWith('-right') || handleId === 'right') {
return 'right'
}
return undefined
}
function isHorizontal(side: HandleSide) {
return side === 'left' || side === 'right'
}
const NODE_CLEAR = 56
function hitsVertical(x: number, y0: number, y1: number, rects: ObstacleRect[], margin = 0) {
const top = Math.min(y0, y1) + 3
const bot = Math.max(y0, y1) - 3
if (bot <= top) {
return false
}
return rects.some(
(r) =>
x > r.x - margin &&
x < r.x + r.width + margin &&
bot > r.y &&
top < r.y + r.height,
)
}
function hitsHorizontal(y: number, x0: number, x1: number, rects: ObstacleRect[], margin = 0) {
const left = Math.min(x0, x1) + 3
const right = Math.max(x0, x1) - 3
if (right <= left) {
return false
}
return rects.some(
(r) =>
y > r.y - margin &&
y < r.y + r.height + margin &&
right > r.x &&
left < r.x + r.width,
)
}
function routeBottomTopAround(
sx: number,
sy: number,
tx: number,
ty: number,
corridor: number,
obstacles: ObstacleRect[],
): OrthogonalPathResult {
const preferRight = tx >= sx
const blocked = obstacles.filter(
(r) => hitsVertical(sx, sy, ty, [r]) || hitsVertical(tx, sy, ty, [r]),
)
const basis = blocked.length ? blocked : obstacles
let lane = preferRight
? Math.max(sx, ...basis.map((r) => r.x + r.width)) + NODE_CLEAR
: Math.min(sx, ...basis.map((r) => r.x)) - NODE_CLEAR
lane += preferRight ? corridor : -corridor
const y0 = Math.min(sy, ty)
const y1Span = Math.max(sy, ty)
for (let i = 0; i < 16 && hitsVertical(lane, y0, y1Span, obstacles, NODE_CLEAR); i += 1) {
lane += preferRight ? 18 : -18
}
const below = basis.map((r) => r.y).filter((y) => y > sy + 4)
const firstTop = below.length ? Math.min(...below) : sy + 64
const yTurn = Math.max(sy + 14, Math.min(sy + 32, firstTop - 16))
let yEnter = ty - 28
if (hitsHorizontal(yEnter, lane, tx, obstacles)) {
yEnter = ty - 14
}
if (yEnter < yTurn + 16) {
yEnter = yTurn + 16
}
return {
path: `M ${sx},${sy} L ${sx},${yTurn} L ${lane},${yTurn} L ${lane},${yEnter} L ${tx},${yEnter} L ${tx},${ty}`,
labelX: (sx + lane) / 2,
labelY: yTurn,
}
}
export function getOrthogonalPath(params: OrthogonalPathParams): OrthogonalPathResult {
const sx = params.sourceX
const sy = params.sourceY
const tx = params.targetX
const ty = params.targetY
const corridor = params.corridorOffset ?? 0
const sourceSide = params.sourceSide ?? (ty + 8 < sy ? 'left' : 'bottom')
const targetSide = params.targetSide ?? (ty + 8 < sy ? 'left' : 'top')
if (Math.abs(sx - tx) < 2 && Math.abs(sy - ty) < 2) {
return { path: `M ${sx},${sy}`, labelX: sx, labelY: sy }
}
// Side → side (back-edge / loop): out horizontally, vertical, in horizontally.
if (isHorizontal(sourceSide) && isHorizontal(targetSide)) {
const pad = 48 + Math.abs(corridor)
const outLeft = sourceSide === 'left'
const inLeft = targetSide === 'left'
let hx1 = outLeft ? sx - pad : sx + pad
let hx2 = inLeft ? tx - pad : tx + pad
if (outLeft === inLeft) {
const shared = outLeft ? Math.min(sx, tx) - pad : Math.max(sx, tx) + pad
hx1 = shared + corridor
hx2 = hx1
return {
path: `M ${sx},${sy} L ${hx1},${sy} L ${hx1},${ty} L ${tx},${ty}`,
labelX: hx1,
labelY: (sy + ty) / 2,
}
}
hx1 += corridor
hx2 += corridor
const midY = (sy + ty) / 2
return {
path: `M ${sx},${sy} L ${hx1},${sy} L ${hx1},${midY} L ${hx2},${midY} L ${hx2},${ty} L ${tx},${ty}`,
labelX: (hx1 + hx2) / 2,
labelY: midY,
}
}
// Bottom → Top (default downward flow).
if (sourceSide === 'bottom' && targetSide === 'top') {
const obstacles = params.obstacles ?? []
if (Math.abs(sx - tx) < 2) {
if (!hitsVertical(sx, sy, ty, obstacles)) {
return {
path: `M ${sx},${sy} L ${tx},${ty}`,
labelX: (sx + tx) / 2,
labelY: (sy + ty) / 2,
}
}
return routeBottomTopAround(sx, sy, tx, ty, corridor, obstacles)
}
const dy = ty - sy
const minDrop = 36
let midY = sy + Math.min(Math.max(dy / 2, minDrop), Math.max(minDrop, dy - minDrop))
midY += corridor
const lo = sy + 20
const hi = ty - 20
if (hi > lo) {
midY = Math.min(hi, Math.max(lo, midY))
}
const blocked =
hitsVertical(sx, sy, midY, obstacles) ||
hitsHorizontal(midY, sx, tx, obstacles) ||
hitsVertical(tx, midY, ty, obstacles)
if (blocked) {
return routeBottomTopAround(sx, sy, tx, ty, corridor, obstacles)
}
return {
path: `M ${sx},${sy} L ${sx},${midY} L ${tx},${midY} L ${tx},${ty}`,
labelX: (sx + tx) / 2,
labelY: midY,
}
}
// Bottom → side
if (sourceSide === 'bottom' && isHorizontal(targetSide)) {
const midY = sy + 36 + corridor
return {
path: `M ${sx},${sy} L ${sx},${midY} L ${tx},${midY} L ${tx},${ty}`,
labelX: (sx + tx) / 2,
labelY: midY,
}
}
// Side → top
if (isHorizontal(sourceSide) && targetSide === 'top') {
const midY = ty - 36 - Math.abs(corridor)
return {
path: `M ${sx},${sy} L ${sx},${midY} L ${tx},${midY} L ${tx},${ty}`,
labelX: (sx + tx) / 2,
labelY: midY,
}
}
// Fallback: simple elbow
const midY = (sy + ty) / 2 + corridor
return {
path: `M ${sx},${sy} L ${sx},${midY} L ${tx},${midY} L ${tx},${ty}`,
labelX: (sx + tx) / 2,
labelY: midY,
}
}
+292
View File
@@ -0,0 +1,292 @@
import type {
ActionNodeData,
ApprovalNodeData,
DesignerEdgeData,
DesignerNodeData,
ProcessDefinitionDocument,
StepDocument,
} from '@/types/definition'
import { defaultActionData, defaultApprovalData, defaultEndData } from '@/types/definition'
import { formatWhen } from '@/mapper/when'
import { approvalStepDocument, branchesOf, laneOf, materializeParallel } from '@/mapper/parallel'
export const APPROVAL_TYPE = 'approval'
export const ACTION_TYPE = 'action'
export const PARALLEL_TYPE = 'parallel'
export const END_TYPE = 'end'
export const END_NODE_ID = '__end__'
export interface DesignerNode {
id: string
type: string
position: { x: number; y: number }
data: DesignerNodeData
parentNode?: string
extent?: 'parent'
style?: { width: string; height: string }
zIndex?: number
sourceHandle?: string
targetHandle?: string
}
export interface DesignerEdge {
id: string
source: string
target: string
sourceHandle?: string
targetHandle?: string
label?: string
data: DesignerEdgeData
}
export function edgeLabel(data: DesignerEdgeData | undefined): string {
const when = formatWhen(data?.when)
return when || 'default'
}
export function toDocument(
id: string,
name: string,
startStepId: string,
nodes: DesignerNode[],
edges: DesignerEdge[],
): ProcessDefinitionDocument {
const top = nodes.filter((node) => node.type !== END_TYPE && !node.parentNode)
const start = top.find((node) => node.id === startStepId) ?? top[0]
const rest = top.filter((node) => node.id !== start?.id)
const ordered = start ? [start, ...rest] : rest
const steps: StepDocument[] = ordered.map((node) => {
const data = node.data
if (data?.kind === 'ACTION') {
return { id: node.id, name: data.name, kind: 'ACTION', action: data.action }
}
if (data?.kind === 'PARALLEL') {
return {
id: node.id,
name: data.name,
kind: 'PARALLEL',
branches: branchesOf(node.id, data.lanes, nodes, edges),
}
}
return approvalStepDocument(data as ApprovalNodeData, node.id)
})
const transitions = edges
.filter((edge) => {
const source = nodes.find((node) => node.id === edge.source)
return source && !source.parentNode
})
.map((edge) => {
const target = nodes.find((node) => node.id === edge.target)
const data = edge.data ?? { priority: 0 }
return {
from: edge.source,
to: target?.type === END_TYPE || target?.parentNode ? null : edge.target,
...(data.when ? { when: data.when } : {}),
priority: data.priority ?? 0,
}
})
return {
id,
name,
startStep: start?.id,
steps,
transitions,
}
}
export function fromDocument(
doc: ProcessDefinitionDocument,
layout: Record<string, { x: number; y: number }> = {},
): { nodes: DesignerNode[]; edges: DesignerEdge[]; startStepId: string } {
const nodes: DesignerNode[] = []
const innerEdges: DesignerEdge[] = []
doc.steps.forEach((step, index) => {
const position = layout[step.id] ?? { x: 220, y: 40 + index * 140 }
if (step.kind === 'PARALLEL') {
const built = materializeParallel(step.id, step.name, position, step.branches ?? [])
nodes.push(built.parent, ...built.children)
innerEdges.push(...built.edges)
return
}
if (step.kind === 'ACTION') {
const data: ActionNodeData = {
...defaultActionData(),
name: step.name,
action: step.action ?? '',
isStart: false,
}
nodes.push({ id: step.id, type: ACTION_TYPE, position, data })
return
}
const data: ApprovalNodeData = {
...defaultApprovalData(),
name: step.name,
candidates: step.candidates ?? [],
policy: step.policy ?? 'ANY',
dueEnabled: Boolean(step.due),
due: step.due
? {
after: step.due.after,
then: step.due.then,
to: step.due.to ?? undefined,
action: step.due.action ?? undefined,
}
: { after: 'PT48H', then: 'notify' },
isStart: false,
}
nodes.push({ id: step.id, type: APPROVAL_TYPE, position, data })
})
const hasEnd = doc.transitions.some((t) => t.to == null)
if (hasEnd) {
nodes.push({
id: END_NODE_ID,
type: END_TYPE,
position: layout[END_NODE_ID] ?? { x: 220, y: 40 + doc.steps.length * 140 },
data: defaultEndData(),
})
}
const startStepId = doc.startStep ?? doc.steps[0]?.id ?? ''
for (const node of nodes) {
if (node.data && node.data.kind !== 'END') {
node.data.isStart = node.id === startStepId
}
}
const edges: DesignerEdge[] = [
...innerEdges,
...doc.transitions.map((t, i) => {
const data: DesignerEdgeData = {
when: t.when,
priority: t.priority ?? 0,
}
return {
id: `e-${i}-${t.from}-${t.to ?? 'end'}`,
source: t.from,
target: t.to ?? END_NODE_ID,
label: edgeLabel(data),
data,
}
}),
]
return { nodes, edges, startStepId }
}
export function layoutFromNodes(nodes: DesignerNode[]): Record<string, { x: number; y: number }> {
const layout: Record<string, { x: number; y: number }> = {}
for (const node of nodes) {
layout[node.id] = { x: node.position.x, y: node.position.y }
}
return layout
}
export interface GraphError {
message: string
nodeId?: string
edgeId?: string
}
export function comparableDocument(doc: ProcessDefinitionDocument): string {
return JSON.stringify(
{
id: doc.id,
name: doc.name,
startStep: doc.startStep,
steps: doc.steps,
transitions: doc.transitions,
},
null,
2,
)
}
export function validateGraph(
id: string,
name: string,
startStepId: string,
nodes: DesignerNode[],
edges: DesignerEdge[],
): GraphError[] {
const errors: GraphError[] = []
if (!id.trim()) {
errors.push({ message: 'Process id is required' })
}
if (!name.trim()) {
errors.push({ message: 'Process name is required' })
}
const stepNodes = nodes.filter((n) => n.type !== END_TYPE)
if (stepNodes.length === 0) {
errors.push({ message: 'At least one step is required' })
}
const ids = new Set<string>()
for (const node of stepNodes) {
if (ids.has(node.id)) {
errors.push({ message: `Duplicate step id: ${node.id}`, nodeId: node.id })
}
ids.add(node.id)
const data = node.data
if (data?.kind === 'APPROVAL' && data.candidates.length === 0) {
errors.push({ message: `Approval step ${node.id} needs candidates`, nodeId: node.id })
}
if (data?.kind === 'ACTION' && !data.action.trim()) {
errors.push({ message: `ACTION step ${node.id} needs action`, nodeId: node.id })
}
if (data?.kind === 'PARALLEL') {
if (data.lanes.length < 2) {
errors.push({ message: `PARALLEL ${node.id} needs at least two branches`, nodeId: node.id })
}
const seenLanes = new Set<string>()
for (const lane of data.lanes) {
if (!lane.trim() || seenLanes.has(lane)) {
errors.push({ message: `PARALLEL ${node.id} has a bad branch id: ${lane}`, nodeId: node.id })
}
seenLanes.add(lane)
if (!nodes.some((child) => child.parentNode === node.id && laneOf(child.data) === lane)) {
errors.push({ message: `Branch ${lane} needs a step`, nodeId: node.id })
}
}
}
const outgoing = edges.filter((e) => e.source === node.id)
if (!node.parentNode && outgoing.length === 0) {
errors.push({ message: `Step ${node.id} has no outgoing edge`, nodeId: node.id })
}
}
if (startStepId && !ids.has(startStepId)) {
errors.push({ message: `Unknown startStep: ${startStepId}` })
}
if (nodes.some((node) => node.id === startStepId && node.parentNode)) {
errors.push({ message: 'startStep must not be inside a parallel branch' })
}
const seenPriority = new Set<string>()
for (const edge of edges) {
const source = nodes.find((n) => n.id === edge.source)
const target = nodes.find((n) => n.id === edge.target)
if (!target) {
errors.push({ message: `Edge target missing: ${edge.target}`, edgeId: edge.id })
} else if (target.type !== END_TYPE && !ids.has(edge.target)) {
errors.push({ message: `Edge target is not a step: ${edge.target}`, edgeId: edge.id })
}
if (!source || !ids.has(edge.source)) {
errors.push({ message: `Edge source is not a step: ${edge.source}`, edgeId: edge.id })
}
if (source?.parentNode || target?.parentNode) {
if (source?.parentNode !== target?.parentNode || laneOf(source?.data) !== laneOf(target?.data)) {
errors.push({ message: `Branch edge must stay in the same branch`, edgeId: edge.id })
}
}
const key = `${edge.source}:${edge.data?.priority ?? 0}`
if (seenPriority.has(key)) {
errors.push({
message: `Duplicate outgoing priority: ${edge.source} / ${edge.data?.priority ?? 0}`,
edgeId: edge.id,
})
}
seenPriority.add(key)
}
return errors
}
+265
View File
@@ -0,0 +1,265 @@
import { END_TYPE, type DesignerEdge, type DesignerNode } from '@/mapper/graph'
import { fitParallelFrames } from '@/mapper/parallel'
const RANK_Y = 220
const NODE_GAP_X = 320
const ORIGIN_X = 80
const ORIGIN_Y = 40
function buildOutgoing(edges: DesignerEdge[]) {
const outgoing = new Map<string, DesignerEdge[]>()
for (const edge of edges) {
const list = outgoing.get(edge.source) ?? []
list.push(edge)
outgoing.set(edge.source, list)
}
for (const list of outgoing.values()) {
list.sort((a, b) => (a.data?.priority ?? 0) - (b.data?.priority ?? 0) || a.target.localeCompare(b.target))
}
return outgoing
}
/** Edges that close a cycle (feedback arcs); excluded from rank / column inheritance. */
function findBackEdgeIds(nodeIds: string[], edges: DesignerEdge[], startId?: string): Set<string> {
const ids = new Set(nodeIds)
const back = new Set<string>()
const visited = new Set<string>()
const stack = new Set<string>()
const outgoing = buildOutgoing(edges)
function dfs(u: string) {
visited.add(u)
stack.add(u)
for (const edge of outgoing.get(u) ?? []) {
if (!ids.has(edge.target)) {
continue
}
if (stack.has(edge.target)) {
back.add(edge.id)
continue
}
if (!visited.has(edge.target)) {
dfs(edge.target)
}
}
stack.delete(u)
}
const roots = startId && ids.has(startId) ? [startId] : nodeIds
for (const root of roots) {
if (!visited.has(root)) {
dfs(root)
}
}
for (const id of nodeIds) {
if (!visited.has(id)) {
dfs(id)
}
}
return back
}
/** Longest-path ranks on the DAG after removing feedback arcs. */
function computeRanks(
nodeIds: string[],
edges: DesignerEdge[],
backEdgeIds: Set<string>,
): Map<string, number> {
const rank = new Map<string, number>()
for (const id of nodeIds) {
rank.set(id, 0)
}
const forward = edges.filter((e) => !backEdgeIds.has(e.id))
let changed = true
let guard = 0
while (changed && guard < nodeIds.length + 4) {
changed = false
guard += 1
for (const edge of forward) {
if (!rank.has(edge.source) || !rank.has(edge.target)) {
continue
}
const next = (rank.get(edge.source) ?? 0) + 1
if (next > (rank.get(edge.target) ?? 0)) {
rank.set(edge.target, next)
changed = true
}
}
}
return rank
}
function assignColumns(
nodeIds: string[],
startId: string | undefined,
rank: Map<string, number>,
outgoing: Map<string, DesignerEdge[]>,
backEdgeIds: Set<string>,
): Map<string, number> {
const col = new Map<string, number>()
let nextCol = 0
const ordered = [...nodeIds].sort(
(a, b) => (rank.get(a) ?? 0) - (rank.get(b) ?? 0) || a.localeCompare(b),
)
if (startId && rank.has(startId)) {
col.set(startId, 0)
nextCol = 1
}
for (const id of ordered) {
if (!col.has(id)) {
col.set(id, nextCol)
nextCol += 1
}
const parentCol = col.get(id) ?? 0
let inherited = false
const seen = new Set<string>()
for (const edge of outgoing.get(id) ?? []) {
if (backEdgeIds.has(edge.id) || seen.has(edge.target) || !rank.has(edge.target)) {
continue
}
seen.add(edge.target)
if (col.has(edge.target)) {
continue
}
if (!inherited) {
col.set(edge.target, parentCol)
inherited = true
} else {
col.set(edge.target, nextCol)
nextCol += 1
}
}
}
for (const id of nodeIds) {
if (!col.has(id)) {
col.set(id, nextCol)
nextCol += 1
}
}
return col
}
/** Map legacy dual side-handle ids onto the single left/right handles. */
export function normalizeHandleId(handleId?: string | null): string | undefined {
if (!handleId) {
return undefined
}
if (handleId === 'source-left' || handleId === 'target-left' || handleId === 'left') {
return 'left'
}
if (handleId === 'source-right' || handleId === 'target-right' || handleId === 'right') {
return 'right'
}
if (handleId === 'source-bottom' || handleId === 'bottom') {
return 'source-bottom'
}
if (handleId === 'target-top' || handleId === 'top') {
return 'target-top'
}
return handleId
}
/** Pick handles from node positions only (avoids rank ties marking every edge as a back-edge). */
export function pickHandlesForEdge(
source: DesignerNode,
target: DesignerNode,
): { sourceHandle: string; targetHandle: string } {
const dy = target.position.y - source.position.y
const dx = target.position.x - source.position.x
const down = 64
const up = -64
if (dy >= down) {
return { sourceHandle: 'source-bottom', targetHandle: 'target-top' }
}
if (dy <= up) {
const side = dx > 0 ? 'right' : 'left'
return { sourceHandle: side, targetHandle: side }
}
// Same row: exit toward the target, enter from the facing side.
if (dx >= 0) {
return { sourceHandle: 'right', targetHandle: 'left' }
}
return { sourceHandle: 'left', targetHandle: 'right' }
}
/** Recompute every edge's handles from current node positions. */
export function assignEdgeHandles(nodes: DesignerNode[], edges: DesignerEdge[]): DesignerEdge[] {
const byId = new Map(nodes.map((n) => [n.id, n]))
return edges.map((edge) => {
const source = byId.get(edge.source)
const target = byId.get(edge.target)
if (!source || !target) {
return {
...edge,
sourceHandle: normalizeHandleId(edge.sourceHandle) ?? 'source-bottom',
targetHandle: normalizeHandleId(edge.targetHandle) ?? 'target-top',
}
}
return { ...edge, ...pickHandlesForEdge(source, target) }
})
}
/** @deprecated use assignEdgeHandles — kept name for draft reopen path */
export function fillMissingEdgeHandles(nodes: DesignerNode[], edges: DesignerEdge[]): DesignerEdge[] {
return assignEdgeHandles(nodes, edges)
}
export function applyLayeredLayout(
nodes: DesignerNode[],
edges: DesignerEdge[],
startStepId: string,
): { nodes: DesignerNode[]; edges: DesignerEdge[] } {
const parked = nodes.filter((node) => node.parentNode)
if (parked.length > 0) {
const top = nodes.filter((node) => !node.parentNode)
const topIds = new Set(top.map((node) => node.id))
const topEdges = edges.filter((edge) => topIds.has(edge.source) && topIds.has(edge.target))
const laid = applyLayeredLayout(top, topEdges, startStepId)
const merged = fitParallelFrames([...laid.nodes, ...parked])
return { nodes: merged, edges: assignEdgeHandles(merged, edges) }
}
if (nodes.length === 0) {
return { nodes, edges }
}
const byId = new Map(nodes.map((n) => [n.id, n]))
const nodeIds = nodes.map((n) => n.id)
const outgoing = buildOutgoing(edges)
const start = byId.has(startStepId)
? startStepId
: (nodes.find((n) => n.type !== END_TYPE)?.id ?? nodes[0]?.id)
const backEdgeIds = findBackEdgeIds(nodeIds, edges, start)
const rank = computeRanks(nodeIds, edges, backEdgeIds)
if (start && (rank.get(start) ?? 0) > 0) {
const shift = rank.get(start) ?? 0
for (const id of nodeIds) {
rank.set(id, Math.max(0, (rank.get(id) ?? 0) - shift))
}
}
const col = assignColumns(nodeIds, start, rank, outgoing, backEdgeIds)
const laidOut = nodes.map((node) => {
const r = rank.get(node.id) ?? 0
const c = col.get(node.id) ?? 0
return {
...node,
position: {
x: ORIGIN_X + c * NODE_GAP_X,
y: ORIGIN_Y + r * RANK_Y,
},
}
})
return {
nodes: laidOut,
edges: assignEdgeHandles(laidOut, edges),
}
}
export { RANK_Y, NODE_GAP_X }
+327
View File
@@ -0,0 +1,327 @@
import type {
ActionNodeData,
ApprovalNodeData,
BranchDocument,
DesignerNodeData,
ParallelNodeData,
StepDocument,
TransitionDocument,
} from '@/types/definition'
import { defaultActionData, defaultApprovalData } from '@/types/definition'
import { formatWhen } from '@/mapper/when'
import type { RoutingWhen } from '@/mapper/when'
export interface CanvasNode {
id: string
type: string
position: { x: number; y: number }
data: DesignerNodeData
parentNode?: string
extent?: 'parent'
style?: { width: string; height: string }
zIndex?: number
}
export interface CanvasEdge {
id: string
source: string
target: string
label?: string
data: { when?: RoutingWhen; priority: number }
}
export const LANE_HEADER = 44
export const LANE_H = 128
export const LANE_LABEL_W = 76
export const LANE_STEP_W = 220
export const LANE_STEP_GAP = 28
export function laneOf(data: DesignerNodeData | undefined): string | undefined {
if (!data || (data.kind !== 'APPROVAL' && data.kind !== 'ACTION')) {
return undefined
}
return data.laneId
}
export function parallelSize(laneCount: number, maxSteps: number): { width: number; height: number } {
return {
width: LANE_LABEL_W + 16 + Math.max(1, maxSteps) * (LANE_STEP_W + LANE_STEP_GAP) + 12,
height: LANE_HEADER + Math.max(1, laneCount) * LANE_H + 10,
}
}
export function laneStepPosition(laneIndex: number, stepIndex: number): { x: number; y: number } {
return {
x: LANE_LABEL_W + 8 + stepIndex * (LANE_STEP_W + LANE_STEP_GAP),
y: LANE_HEADER + laneIndex * LANE_H + 16,
}
}
export function fitParallelFrames(nodes: CanvasNode[]): CanvasNode[] {
return nodes.map((node) => {
if (node.data?.kind !== 'PARALLEL') {
return node
}
const lanes = node.data.lanes
const children = nodes.filter((child) => child.parentNode === node.id)
const maxSteps = Math.max(1, ...lanes.map((lane) => children.filter((child) => laneOf(child.data) === lane).length))
const size = parallelSize(lanes.length, maxSteps)
return {
...node,
zIndex: 0,
style: { width: `${size.width}px`, height: `${size.height}px` },
}
})
}
export function settleLaneNodes(nodes: CanvasNode[]): CanvasNode[] {
const next = nodes.map((node) => {
if (!node.parentNode) {
return node
}
const parent = nodes.find((item) => item.id === node.parentNode)
if (!parent || parent.data?.kind !== 'PARALLEL') {
return node
}
const lanes = parent.data.lanes
const laneIndex = Math.min(
lanes.length - 1,
Math.max(0, Math.floor((node.position.y - LANE_HEADER) / LANE_H)),
)
const lane = lanes[laneIndex] ?? lanes[0]
if (!lane) {
return node
}
const data = node.data
if (data.kind !== 'APPROVAL' && data.kind !== 'ACTION') {
return node
}
return {
...node,
zIndex: 1,
data: { ...data, laneId: lane },
position: {
x: Math.max(LANE_LABEL_W, node.position.x),
y: LANE_HEADER + laneIndex * LANE_H + 16,
},
}
})
return fitParallelFrames(next)
}
export function approvalStepDocument(data: ApprovalNodeData, id: string): StepDocument {
const step: StepDocument = {
id,
name: data.name,
kind: 'APPROVAL',
candidates: data.candidates,
policy: data.policy,
}
if (data.dueEnabled && data.due.after && data.due.then) {
step.due = {
after: data.due.after,
then: data.due.then,
...(data.due.to ? { to: data.due.to } : {}),
...(data.due.action ? { action: data.due.action } : {}),
}
}
return step
}
export function branchesOf(
parallelId: string,
lanes: string[],
nodes: CanvasNode[],
edges: CanvasEdge[],
): BranchDocument[] {
return lanes.map((lane) => {
const steps = nodes
.filter((node) => node.parentNode === parallelId && laneOf(node.data) === lane)
.sort((a, b) => a.position.x - b.position.x || a.id.localeCompare(b.id))
const ids = new Set(steps.map((step) => step.id))
const transitions: TransitionDocument[] = edges
.filter((edge) => ids.has(edge.source) && ids.has(edge.target))
.map((edge) => ({
from: edge.source,
to: edge.target,
...(edge.data.when ? { when: edge.data.when } : {}),
priority: edge.data.priority ?? 0,
}))
for (const step of steps) {
if (!edges.some((edge) => edge.source === step.id && ids.has(edge.target))) {
transitions.push({ from: step.id, to: null, priority: 0 })
}
}
return {
id: lane,
steps: steps.map((node) => {
if (node.data.kind === 'ACTION') {
return { id: node.id, name: node.data.name, kind: 'ACTION' as const, action: node.data.action }
}
return approvalStepDocument(node.data as ApprovalNodeData, node.id)
}),
transitions,
}
})
}
export function spawnLaneStep(
parallelId: string,
laneId: string,
laneIndex: number,
stepIndex: number,
id: string,
): CanvasNode {
const data: ApprovalNodeData = { ...defaultApprovalData(), name: 'Approval', laneId }
return {
id,
type: 'approval',
parentNode: parallelId,
extent: 'parent',
zIndex: 1,
position: laneStepPosition(laneIndex, stepIndex),
data,
}
}
export function materializeParallel(
id: string,
name: string,
position: { x: number; y: number },
branches: BranchDocument[],
isStart = false,
): { parent: CanvasNode; children: CanvasNode[]; edges: CanvasEdge[] } {
const lanes = branches.map((branch) => branch.id)
const data: ParallelNodeData = { kind: 'PARALLEL', name, lanes, isStart }
const maxSteps = Math.max(1, ...branches.map((branch) => branch.steps.length))
const size = parallelSize(lanes.length, maxSteps)
const parent: CanvasNode = {
id,
type: 'parallel',
position,
zIndex: 0,
style: { width: `${size.width}px`, height: `${size.height}px` },
data,
}
const children: CanvasNode[] = []
const edges: CanvasEdge[] = []
branches.forEach((branch, laneIndex) => {
branch.steps.forEach((step, stepIndex) => {
children.push(stepNode(step, id, branch.id, laneIndex, stepIndex))
})
branch.transitions.forEach((transition, index) => {
if (!transition.to) {
return
}
const data = { when: transition.when, priority: transition.priority ?? 0 }
edges.push({
id: `e-${id}-${branch.id}-${index}`,
source: transition.from,
target: transition.to,
label: formatWhen(transition.when) || 'default',
data,
})
})
})
return { parent, children, edges }
}
function stepNode(
step: StepDocument,
parallelId: string,
laneId: string,
laneIndex: number,
stepIndex: number,
): CanvasNode {
if (step.kind === 'ACTION') {
const data: ActionNodeData = { ...defaultActionData(), name: step.name, action: step.action ?? '', laneId }
return {
id: step.id,
type: 'action',
parentNode: parallelId,
extent: 'parent',
zIndex: 1,
position: laneStepPosition(laneIndex, stepIndex),
data,
}
}
const data: ApprovalNodeData = {
...defaultApprovalData(),
name: step.name,
candidates: step.candidates ?? [],
policy: step.policy ?? 'ANY',
dueEnabled: Boolean(step.due),
due: step.due
? { after: step.due.after, then: step.due.then, to: step.due.to ?? undefined, action: step.due.action ?? undefined }
: { after: 'PT48H', then: 'notify' },
laneId,
}
return {
id: step.id,
type: 'approval',
parentNode: parallelId,
extent: 'parent',
zIndex: 1,
position: laneStepPosition(laneIndex, stepIndex),
data,
}
}
export function dropCrossLaneEdges(nodes: CanvasNode[], edges: CanvasEdge[]): CanvasEdge[] {
const byId = new Map(nodes.map((node) => [node.id, node]))
return edges.filter((edge) => {
const source = byId.get(edge.source)
const target = byId.get(edge.target)
if (!source?.parentNode && !target?.parentNode) {
return true
}
if (!source?.parentNode || !target?.parentNode) {
return false
}
return source.parentNode === target.parentNode && laneOf(source.data) === laneOf(target.data)
})
}
export function reflowLanes(nodes: CanvasNode[], parallelId: string): CanvasNode[] {
const parent = nodes.find((node) => node.id === parallelId)
if (!parent || parent.data.kind !== 'PARALLEL') {
return nodes
}
const placed = new Map<string, CanvasNode>()
parent.data.lanes.forEach((lane, laneIndex) => {
const steps = nodes
.filter((node) => node.parentNode === parallelId && laneOf(node.data) === lane)
.sort((a, b) => a.position.x - b.position.x || a.id.localeCompare(b.id))
steps.forEach((step, stepIndex) => {
const data = step.data
if (data.kind !== 'APPROVAL' && data.kind !== 'ACTION') {
return
}
placed.set(step.id, {
...step,
position: laneStepPosition(laneIndex, stepIndex),
data: { ...data, laneId: lane },
})
})
})
return fitParallelFrames(nodes.map((node) => placed.get(node.id) ?? node))
}
export function expandLegacyParallel(
nodes: CanvasNode[],
edges: CanvasEdge[],
): { nodes: CanvasNode[]; edges: CanvasEdge[] } {
const next: CanvasNode[] = []
const extra: CanvasEdge[] = []
for (const node of nodes) {
const data = node.data as ParallelNodeData & { branches?: BranchDocument[] }
if (data?.kind === 'PARALLEL' && data.branches?.length && !data.lanes) {
const built = materializeParallel(node.id, data.name, node.position, data.branches, Boolean(data.isStart))
next.push(built.parent, ...built.children)
extra.push(...built.edges)
continue
}
next.push(node)
}
return { nodes: next, edges: [...extra, ...edges] }
}
+216
View File
@@ -0,0 +1,216 @@
export type CompareOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in'
export type RoutingWhen =
| { ref: string; args?: Record<string, unknown> }
| { and: RoutingWhen[] }
| { or: RoutingWhen[] }
| { not: RoutingWhen }
| { eq: [string, unknown] }
| { ne: [string, unknown] }
| { gt: [string, unknown] }
| { gte: [string, unknown] }
| { lt: [string, unknown] }
| { lte: [string, unknown] }
| { in: [string, unknown[]] }
const COMPARE_OPS: CompareOp[] = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in']
export function isRefWhen(when: RoutingWhen | undefined): when is { ref: string; args?: Record<string, unknown> } {
return Boolean(when && typeof when === 'object' && 'ref' in when)
}
export function formatWhen(when: RoutingWhen | undefined): string {
if (!when) {
return ''
}
if (isRefWhen(when)) {
const args = when.args
if (!args || Object.keys(args).length === 0) {
return when.ref
}
const body = Object.entries(args)
.map(([k, v]) => `${k}=${String(v)}`)
.join(',')
return `${when.ref}(${body})`
}
if ('and' in when) {
return when.and.map(formatWhen).join(' AND ')
}
if ('or' in when) {
return when.or.map((part) => `(${formatWhen(part)})`).join(' OR ')
}
if ('not' in when) {
return `NOT (${formatWhen(when.not)})`
}
if ('in' in when) {
return `${when.in[0]} in [${when.in[1].map(String).join(', ')}]`
}
for (const op of COMPARE_OPS) {
if (op === 'in') {
continue
}
if (op in when) {
const pair = (when as Record<string, [string, unknown]>)[op]
const symbol =
op === 'eq' ? '=' : op === 'ne' ? '!=' : op === 'gt' ? '>' : op === 'gte' ? '>=' : op === 'lt' ? '<' : '<='
return `${pair?.[0]} ${symbol} ${String(pair?.[1])}`
}
}
return ''
}
export function defaultLeaf(): RoutingWhen {
return { gt: ['', 0] }
}
export const MAX_PREDICATE_DEPTH = 8
export const MAX_PREDICATE_LEAVES = 32
export function isLogicGroup(
when: RoutingWhen,
): when is { and: RoutingWhen[] } | { or: RoutingWhen[] } {
return 'and' in when || 'or' in when
}
export function isNotWhen(when: RoutingWhen): when is { not: RoutingWhen } {
return 'not' in when
}
export function groupChildren(when: RoutingWhen): RoutingWhen[] {
if ('and' in when) {
return when.and
}
if ('or' in when) {
return when.or
}
return []
}
export function leafOp(when: RoutingWhen): CompareOp {
for (const op of COMPARE_OPS) {
if (op in when) {
return op
}
}
return 'gt'
}
export function leafVar(when: RoutingWhen): string {
const pair = (when as Record<string, [string, unknown]>)[leafOp(when)]
return pair?.[0] ?? ''
}
export function leafValueText(when: RoutingWhen): string {
const op = leafOp(when)
const pair = (when as Record<string, [string, unknown]>)[op]
const value = pair?.[1]
if (op === 'in' && Array.isArray(value)) {
return value.map(String).join(', ')
}
return value == null ? '' : String(value)
}
export function makeLeaf(op: CompareOp, variable: string, text: string): RoutingWhen {
if (op === 'in') {
const values = text
.split(',')
.map((part) => part.trim())
.filter(Boolean)
.map(coerceScalar)
return { in: [variable, values] }
}
return { [op]: [variable, coerceScalar(text)] } as RoutingWhen
}
function coerceScalar(text: string): unknown {
if (text === 'true') {
return true
}
if (text === 'false') {
return false
}
if (text !== '' && !Number.isNaN(Number(text))) {
return Number(text)
}
return text
}
export function countLeaves(when: RoutingWhen | undefined): number {
if (!when || isRefWhen(when)) {
return 0
}
if (isLogicGroup(when)) {
return groupChildren(when).reduce((sum, child) => sum + countLeaves(child), 0)
}
if (isNotWhen(when)) {
return countLeaves(when.not)
}
return 1
}
export function predicateDepth(when: RoutingWhen | undefined): number {
if (!when || isRefWhen(when)) {
return 0
}
if (isLogicGroup(when)) {
return 1 + groupChildren(when).reduce((max, child) => Math.max(max, predicateDepth(child)), 0)
}
if (isNotWhen(when)) {
return 1 + predicateDepth(when.not)
}
return 1
}
export function validatePredicate(when: RoutingWhen | undefined): string | null {
if (!when || isRefWhen(when)) {
return 'Add at least one condition'
}
if (predicateDepth(when) > MAX_PREDICATE_DEPTH) {
return `Nesting cannot exceed ${MAX_PREDICATE_DEPTH} levels`
}
if (countLeaves(when) > MAX_PREDICATE_LEAVES) {
return `At most ${MAX_PREDICATE_LEAVES} conditions`
}
return validateNode(when)
}
function validateNode(when: RoutingWhen): string | null {
if (isLogicGroup(when)) {
const children = groupChildren(when)
if (children.length === 0) {
return 'A group needs at least one condition'
}
for (const child of children) {
const error = validateNode(child)
if (error) {
return error
}
}
return null
}
if (isNotWhen(when)) {
return validateNode(when.not)
}
if (!leafVar(when).trim()) {
return 'Variable is required'
}
if (leafOp(when) === 'in' && leafValueText(when).trim() === '') {
return 'in requires at least one value, comma-separated'
}
return null
}
/** Single-child AND/OR is equivalent to its child; keep published JSON flat. */
export function collapseTrivialGroups(when: RoutingWhen): RoutingWhen {
if (isLogicGroup(when)) {
const children = groupChildren(when).map(collapseTrivialGroups)
if (children.length === 1) {
return children[0] ?? defaultLeaf()
}
return 'and' in when ? { and: children } : { or: children }
}
if (isNotWhen(when)) {
return { not: collapseTrivialGroups(when.not) }
}
return when
}
+13
View File
@@ -0,0 +1,13 @@
import { createRouter, createWebHistory } from 'vue-router'
import DefinitionListView from '@/views/DefinitionListView.vue'
import DesignerView from '@/views/DesignerView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', name: 'definitions', component: DefinitionListView },
{ path: '/designer/:definitionId?', name: 'designer', component: DesignerView },
],
})
export default router
+35
View File
@@ -0,0 +1,35 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as ordo from '@/api/ordo'
import type { CatalogItem } from '@/types/definition'
export const useCatalogStore = defineStore('catalog', () => {
const conditions = ref<CatalogItem[]>([])
const actions = ref<CatalogItem[]>([])
const assignees = ref<CatalogItem[]>([])
const loaded = ref(false)
const error = ref<string | null>(null)
async function load() {
error.value = null
try {
const [c, a, s] = await Promise.all([
ordo.listConditions(),
ordo.listActions(),
ordo.listAssignees(),
])
conditions.value = c
actions.value = a
assignees.value = s
loaded.value = true
} catch (e) {
conditions.value = []
actions.value = []
assignees.value = []
loaded.value = false
error.value = e instanceof Error ? e.message : 'Catalog unavailable'
}
}
return { conditions, actions, assignees, loaded, error, load }
})
+795
View File
@@ -0,0 +1,795 @@
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() {
nodes.value = nodes.value.map((node) => {
if (!node.data || node.data.kind === 'END') {
return node
}
return {
...node,
data: { ...node.data, isStart: node.id === startStepId.value },
}
})
}
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)
nodes.value = fitParallelFrames(expanded.nodes)
edges.value = assignEdgeHandles(expanded.nodes, 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) {
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 = fitParallelFrames([...nodes.value, spawnLaneStep(parallelId, lane, laneIndex, stepIndex, nextId(lane))])
}
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,
}
})
+82
View File
@@ -0,0 +1,82 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { ProcessDefinitionDocument } from '@/types/definition'
import { layoutFromNodes, type DesignerEdge, type DesignerNode } from '@/mapper/graph'
export interface DesignerDraft {
id: string
name: string
startStepId: string
nodes: DesignerNode[]
edges: DesignerEdge[]
layout: Record<string, { x: number; y: number }>
updatedAt: number
}
const STORAGE_KEY = 'ordo-designer-drafts'
function readAll(): Record<string, DesignerDraft> {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) {
return {}
}
return JSON.parse(raw) as Record<string, DesignerDraft>
} catch {
return {}
}
}
function writeAll(drafts: Record<string, DesignerDraft>) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(drafts))
}
export const useDraftsStore = defineStore('drafts', () => {
const revision = ref(0)
function list(): DesignerDraft[] {
return Object.values(readAll()).sort((a, b) => b.updatedAt - a.updatedAt)
}
function get(id: string): DesignerDraft | undefined {
return readAll()[id]
}
function save(params: {
id: string
name: string
startStepId: string
nodes: DesignerNode[]
edges: DesignerEdge[]
}) {
const drafts = readAll()
drafts[params.id] = {
id: params.id,
name: params.name,
startStepId: params.startStepId,
nodes: params.nodes,
edges: params.edges,
layout: layoutFromNodes(params.nodes),
updatedAt: Date.now(),
}
writeAll(drafts)
revision.value += 1
}
function layoutOf(id: string): Record<string, { x: number; y: number }> {
return readAll()[id]?.layout ?? {}
}
function remove(id: string) {
const drafts = readAll()
delete drafts[id]
writeAll(drafts)
revision.value += 1
}
function fromPublished(doc: ProcessDefinitionDocument): DesignerDraft | undefined {
return get(doc.id)
}
return { revision, list, get, save, layoutOf, remove, fromPublished }
})
+30
View File
@@ -0,0 +1,30 @@
@import "tailwindcss";
html,
body,
#app {
height: 100%;
margin: 0;
}
.vue-flow {
background:
radial-gradient(circle at 1px 1px, #e2e8f0 1px, transparent 0) 0 0 / 20px 20px,
#f8fafc;
}
.vue-flow__edge-path {
fill: none;
}
.vue-flow__handle {
transition: transform 0.15s ease;
}
.vue-flow__handle:hover {
transform: scale(1.25);
}
.pointer-events-all {
pointer-events: all;
}
+156
View File
@@ -0,0 +1,156 @@
import type { RoutingWhen } from '@/mapper/when'
export type StepKind = 'APPROVAL' | 'ACTION' | 'PARALLEL'
export type ApprovalPolicy = 'ANY' | 'ALL'
export type DueThen = 'reassign' | 'notify' | 'goto'
export interface StepDueDocument {
after: string
then: DueThen
to?: string | null
action?: string | null
}
export interface BranchDocument {
id: string
steps: StepDocument[]
transitions: TransitionDocument[]
}
export interface StepDocument {
id: string
name: string
kind?: StepKind
candidates?: string[]
policy?: ApprovalPolicy
action?: string
due?: StepDueDocument | null
branches?: BranchDocument[]
}
export interface TransitionDocument {
from: string
to: string | null
when?: RoutingWhen
priority?: number
}
export interface ProcessDefinitionDocument {
id: string
name: string
version?: number
startStep?: string
steps: StepDocument[]
transitions: TransitionDocument[]
}
export interface CatalogParam {
name: string
type: 'number' | 'string' | 'boolean'
label: string
}
export interface CatalogItem {
key: string
label: string
params?: CatalogParam[]
}
export interface PageResponse<T> {
content: T[]
totalElements: number
page: number
size: number
totalPages: number
hasNext: boolean
}
export type NodeKind = 'APPROVAL' | 'ACTION' | 'PARALLEL' | 'END'
export interface ApprovalNodeData {
kind: 'APPROVAL'
name: string
candidates: string[]
policy: ApprovalPolicy
dueEnabled: boolean
due: StepDueDocument
isStart?: boolean
laneId?: string
}
export interface ActionNodeData {
kind: 'ACTION'
name: string
action: string
isStart?: boolean
laneId?: string
}
export interface ParallelNodeData {
kind: 'PARALLEL'
name: string
lanes: string[]
isStart?: boolean
}
export interface EndNodeData {
kind: 'END'
name: string
}
export type DesignerNodeData = ApprovalNodeData | ActionNodeData | ParallelNodeData | EndNodeData
export interface DesignerEdgeData {
when?: RoutingWhen
priority: number
}
export const LEAVE_REQUEST_EXAMPLE: ProcessDefinitionDocument = {
id: 'leave-request-routed',
name: 'Leave request',
startStep: 'notify-submitted',
steps: [
{ id: 'notify-submitted', name: 'Notify submitted', kind: 'ACTION', action: 'leave-submitted-mail' },
{ id: 'manager', name: 'Manager approval', candidates: ['maria'], policy: 'ANY' },
{ id: 'hr', name: 'HR approval', candidates: ['henry'], policy: 'ANY' },
{ id: 'notify-approved', name: 'Notify approved', kind: 'ACTION', action: 'leave-approved-mail' },
],
transitions: [
{ from: 'notify-submitted', to: 'manager' },
{ from: 'manager', to: 'hr', when: { gt: ['days', 3] }, priority: 0 },
{ from: 'manager', to: 'notify-approved', priority: 1 },
{ from: 'hr', to: 'notify-approved' },
{ from: 'notify-approved', to: null },
],
}
export function defaultApprovalData(): ApprovalNodeData {
return {
kind: 'APPROVAL',
name: 'Approval',
candidates: [],
policy: 'ANY',
dueEnabled: false,
due: { after: 'PT48H', then: 'notify' },
}
}
export function defaultActionData(): ActionNodeData {
return {
kind: 'ACTION',
name: 'Action',
action: '',
}
}
export function defaultParallelData(): ParallelNodeData {
return {
kind: 'PARALLEL',
name: 'Parallel',
lanes: ['legal', 'finance'],
}
}
export function defaultEndData(): EndNodeData {
return { kind: 'END', name: 'End' }
}
+237
View File
@@ -0,0 +1,237 @@
<script setup lang="ts">
import { computed, h, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { DataTableColumns } from 'naive-ui'
import { NButton, NSpace, NTag } from 'naive-ui'
import * as ordo from '@/api/ordo'
import { useDraftsStore } from '@/stores/drafts'
import type { ProcessDefinitionDocument } from '@/types/definition'
import type { DesignerDraft } from '@/stores/drafts'
import DraftConflictModal from '@/components/designer/DraftConflictModal.vue'
import { comparableDocument, toDocument } from '@/mapper/graph'
import { useAsync } from '@/composables/useAsync'
const router = useRouter()
const drafts = useDraftsStore()
const listAsync = useAsync()
const versionsAsync = useAsync()
const loading = listAsync.loading
const error = listAsync.error
const published = ref<ProcessDefinitionDocument[]>([])
const localDrafts = ref<DesignerDraft[]>([])
const versionsShow = ref(false)
const versions = ref<ProcessDefinitionDocument[]>([])
const versionsTitle = ref('')
const conflictShow = ref(false)
const conflictDraft = ref<DesignerDraft | null>(null)
const conflictPublished = ref<ProcessDefinitionDocument | null>(null)
const publishedById = computed(() => {
const map = new Map<string, ProcessDefinitionDocument>()
for (const row of published.value) {
map.set(row.id, row)
}
return map
})
const publishedColumns: DataTableColumns<ProcessDefinitionDocument> = [
{ title: 'id', key: 'id' },
{ title: 'name', key: 'name' },
{ title: 'version', key: 'version', width: 100 },
{
title: 'draft',
key: 'draft',
width: 140,
render(row) {
const draft = drafts.get(row.id)
if (!draft) {
return '—'
}
return h(NSpace, { size: 8, align: 'center' }, () => [
h(NTag, { size: 'small', type: 'warning' }, () => 'Draft'),
h(NButton, { size: 'tiny', onClick: () => goDesigner(row.id, 'draft') }, () => 'Open draft'),
])
},
},
{
title: 'actions',
key: 'actions',
width: 200,
render(row) {
return h(NSpace, { size: 8 }, () => [
h(NButton, { size: 'small', onClick: () => openPublished(row) }, () => 'Open'),
h(NButton, { size: 'small', onClick: () => void openVersions(row.id) }, () => 'Versions'),
])
},
},
]
const draftColumns: DataTableColumns<DesignerDraft> = [
{ title: 'id', key: 'id' },
{ title: 'name', key: 'name' },
{
title: 'updated',
key: 'updatedAt',
render(row) {
return new Date(row.updatedAt).toLocaleString()
},
},
{
title: 'published',
key: 'published',
width: 100,
render(row) {
const doc = publishedById.value.get(row.id)
return doc?.version != null ? `v${doc.version}` : '—'
},
},
{
title: 'actions',
key: 'actions',
width: 120,
render(row) {
return h(NButton, { size: 'small', onClick: () => goDesigner(row.id, 'draft') }, () => 'Open')
},
},
]
const versionColumns: DataTableColumns<ProcessDefinitionDocument> = [
{ title: 'version', key: 'version', width: 100 },
{ title: 'name', key: 'name' },
{
title: 'actions',
key: 'actions',
width: 160,
render(row) {
return h(NSpace, { size: 8 }, () => [
h(
NButton,
{ size: 'small', onClick: () => openDesigner(row.id, row.version) },
() => 'Open',
),
])
},
},
]
const conflictDraftText = computed(() => {
const draft = conflictDraft.value
if (!draft) {
return ''
}
return comparableDocument(toDocument(draft.id, draft.name, draft.startStepId, draft.nodes, draft.edges))
})
const conflictPublishedText = computed(() =>
conflictPublished.value ? comparableDocument(conflictPublished.value) : '',
)
function goDesigner(id: string, source?: 'draft' | 'published', version?: number) {
versionsShow.value = false
conflictShow.value = false
void router.push({
name: 'designer',
params: { definitionId: id },
query: {
...(version != null ? { version: String(version) } : {}),
...(source ? { source } : {}),
},
})
}
function openDesigner(id: string, version?: number) {
goDesigner(id, undefined, version)
}
function openPublished(row: ProcessDefinitionDocument) {
const draft = drafts.get(row.id)
if (draft) {
conflictDraft.value = draft
conflictPublished.value = row
conflictShow.value = true
return
}
goDesigner(row.id, undefined)
}
function onOpenDraft() {
const draft = conflictDraft.value
if (!draft) {
return
}
goDesigner(draft.id, 'draft')
}
function onDiscardDraft() {
const publishedDoc = conflictPublished.value
const draft = conflictDraft.value
if (draft) {
drafts.remove(draft.id)
localDrafts.value = drafts.list()
}
if (publishedDoc) {
goDesigner(publishedDoc.id, 'published')
}
}
function newProcess() {
void router.push({ name: 'designer' })
}
async function openVersions(id: string) {
versionsTitle.value = id
try {
const page = await versionsAsync.run(() => ordo.listDefinitionVersions(id))
versions.value = page.content
versionsShow.value = true
} catch {
error.value = versionsAsync.error.value || 'Failed to load versions'
}
}
async function load() {
localDrafts.value = drafts.list()
try {
const page = await listAsync.run(() => ordo.listDefinitions())
published.value = page.content
} catch {
published.value = []
}
}
onMounted(() => {
void load()
})
</script>
<template>
<div class="min-h-screen bg-slate-100 p-6">
<div class="mx-auto flex max-w-5xl flex-col gap-4">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold text-slate-800">Process definitions</h1>
<n-space>
<n-button @click="load">Refresh</n-button>
<n-button type="primary" @click="newProcess">New</n-button>
</n-space>
</div>
<n-alert v-if="error" type="warning">{{ error }}</n-alert>
<n-card title="Published">
<n-data-table :columns="publishedColumns" :data="published" :loading="loading" :bordered="false" />
</n-card>
<n-card title="Local drafts">
<n-data-table :columns="draftColumns" :data="localDrafts" :bordered="false" />
</n-card>
</div>
<n-modal v-model:show="versionsShow">
<n-card :title="`Versions · ${versionsTitle}`" style="width: 560px" closable @close="versionsShow = false">
<n-data-table :columns="versionColumns" :data="versions" :bordered="false" />
</n-card>
</n-modal>
<DraftConflictModal
v-model:show="conflictShow"
:draft-text="conflictDraftText"
:published-text="conflictPublishedText"
@open-draft="onOpenDraft"
@discard="onDiscardDraft"
/>
</div>
</template>
+748
View File
@@ -0,0 +1,748 @@
<script setup lang="ts">
import { computed, markRaw, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import {
VueFlow,
useVueFlow,
ConnectionMode,
type Connection,
type EdgeMouseEvent,
type EdgeTypesObject,
type NodeMouseEvent,
type NodeTypesObject,
} from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import { MiniMap } from '@vue-flow/minimap'
import { useDialog, useMessage } from 'naive-ui'
import { ArrowLeft } from '@lucide/vue'
import NodePalette from '@/components/designer/NodePalette.vue'
import PropertyPanel from '@/components/designer/PropertyPanel.vue'
import GraphErrorList from '@/components/designer/GraphErrorList.vue'
import DraftConflictModal from '@/components/designer/DraftConflictModal.vue'
import ApprovalNode from '@/components/designer/nodes/ApprovalNode.vue'
import ActionNode from '@/components/designer/nodes/ActionNode.vue'
import ParallelNode from '@/components/designer/nodes/ParallelNode.vue'
import EndNode from '@/components/designer/nodes/EndNode.vue'
import BranchEdge from '@/components/designer/edges/BranchEdge.vue'
import { useDesignerStore } from '@/stores/designer'
import { useCatalogStore } from '@/stores/catalog'
import { useDraftsStore, type DesignerDraft } from '@/stores/drafts'
import { useAsync } from '@/composables/useAsync'
import * as ordo from '@/api/ordo'
import { comparableDocument, toDocument } from '@/mapper/graph'
import { LEAVE_REQUEST_EXAMPLE, type ProcessDefinitionDocument } from '@/types/definition'
const route = useRoute()
const router = useRouter()
const message = useMessage()
const dialog = useDialog()
const designer = useDesignerStore()
const catalog = useCatalogStore()
const drafts = useDraftsStore()
const bootAsync = useAsync()
const actionAsync = useAsync()
const { nodes, edges, definitionId, name, publishedVersion, loadedFromDraft, isDirty, canUndo, canRedo, selectedNodeIds } =
storeToRefs(designer)
const { fitView, screenToFlowCoordinate, setNodes } = useVueFlow('ordo-designer')
const { revision } = storeToRefs(drafts)
const loading = bootAsync.loading
const conflictShow = ref(false)
const conflictDraft = ref<DesignerDraft | null>(null)
const conflictPublished = ref<ProcessDefinitionDocument | null>(null)
const shortcutsShow = ref(false)
const jsonShow = ref(false)
const savedHint = ref(false)
let bootGen = 0
let skipRouteBootstrap = false
let allowAutosave = false
let autosaveTimer: ReturnType<typeof setTimeout> | null = null
let savedHintTimer: ReturnType<typeof setTimeout> | null = null
const nodeTypes = {
approval: markRaw(ApprovalNode),
action: markRaw(ActionNode),
parallel: markRaw(ParallelNode),
end: markRaw(EndNode),
} as unknown as NodeTypesObject
const edgeTypes = {
default: markRaw(BranchEdge),
} as unknown as EdgeTypesObject
const readOnly = computed(() => {
const versionRaw = route.query.version
return typeof versionRaw === 'string' && Number.isFinite(Number(versionRaw))
})
const versionLabel = computed(() => {
if (readOnly.value && publishedVersion.value != null) {
return `v${publishedVersion.value} (read-only)`
}
return loadedFromDraft.value ? 'Draft' : publishedVersion.value != null ? `v${publishedVersion.value}` : 'Draft'
})
const publishedHint = computed(() =>
loadedFromDraft.value && publishedVersion.value != null ? `Published v${publishedVersion.value}` : '',
)
const hasLocalDraft = computed(() => {
void revision.value
return Boolean(definitionId.value && drafts.get(definitionId.value))
})
const dirtyLabel = computed(() => (isDirty.value ? 'Unsaved' : ''))
const canAlign = computed(() => !readOnly.value && selectedNodeIds.value.length >= 2)
const jsonText = computed(() => JSON.stringify(designer.document(), null, 2))
const conflictDraftText = computed(() => {
const draft = conflictDraft.value
if (!draft) {
return ''
}
return comparableDocument(toDocument(draft.id, draft.name, draft.startStepId, draft.nodes, draft.edges))
})
const conflictPublishedText = computed(() =>
conflictPublished.value ? comparableDocument(conflictPublished.value) : '',
)
function syncFlowNodes() {
setNodes(nodes.value.map((n) => ({ ...n, position: { ...n.position } })))
}
async function fitAfterLoad() {
await nextTick()
fitView({ padding: 0.2 })
}
async function confirmLeave(): Promise<boolean> {
if (!designer.isDirty) {
return true
}
return new Promise((resolve) => {
dialog.warning({
title: 'Unsaved changes',
content: 'Leaving will discard unsaved edits.',
positiveText: 'Leave',
negativeText: 'Stay',
onPositiveClick: () => resolve(true),
onNegativeClick: () => resolve(false),
onClose: () => resolve(false),
})
})
}
function persistDraft(silent: boolean) {
if (readOnly.value || !definitionId.value) {
return
}
drafts.save({
id: definitionId.value,
name: name.value,
startStepId: designer.startStepId,
nodes: nodes.value,
edges: edges.value,
})
designer.loadedFromDraft = true
designer.captureBaseline()
if (silent) {
savedHint.value = true
if (savedHintTimer) {
clearTimeout(savedHintTimer)
}
savedHintTimer = setTimeout(() => {
savedHint.value = false
}, 2000)
return
}
message.success('Draft saved')
replaceDefinition(definitionId.value)
}
function scheduleAutosave() {
if (!allowAutosave || readOnly.value || !definitionId.value || !isDirty.value) {
return
}
if (autosaveTimer) {
clearTimeout(autosaveTimer)
}
autosaveTimer = setTimeout(() => {
autosaveTimer = null
persistDraft(true)
}, 1500)
}
function flushAutosave() {
if (autosaveTimer) {
clearTimeout(autosaveTimer)
autosaveTimer = null
}
if (allowAutosave && !readOnly.value && definitionId.value && designer.isDirty) {
persistDraft(true)
}
}
async function bootstrap() {
const gen = ++bootGen
allowAutosave = false
conflictShow.value = false
designer.clear()
try {
await bootAsync.run(async () => {
await catalog.load()
if (gen !== bootGen) {
return
}
const id = typeof route.params.definitionId === 'string' ? route.params.definitionId : ''
const versionRaw = route.query.version
const version = typeof versionRaw === 'string' ? Number(versionRaw) : NaN
const source = typeof route.query.source === 'string' ? route.query.source : ''
if (id && Number.isFinite(version)) {
const doc = await ordo.getDefinitionVersion(id, version)
if (gen !== bootGen) {
return
}
designer.loadDocument(doc)
await fitAfterLoad()
return
}
if (id) {
const draft = drafts.get(id)
let published: ProcessDefinitionDocument | null = null
try {
published = await ordo.getDefinition(id)
} catch {
published = null
}
if (gen !== bootGen) {
return
}
if (draft && published && source !== 'draft' && source !== 'published') {
conflictDraft.value = draft
conflictPublished.value = published
conflictShow.value = true
return
}
if (draft && (source === 'draft' || !published)) {
designer.loadGraph({
...draft,
version: published?.version ?? null,
fromDraft: true,
})
await fitAfterLoad()
return
}
if (published) {
if (source === 'published' && draft) {
drafts.remove(id)
}
designer.loadDocument(published)
await fitAfterLoad()
return
}
designer.resetEmpty(id)
return
}
designer.resetEmpty()
})
} catch {
message.error(bootAsync.error.value || 'Failed to load')
const id = typeof route.params.definitionId === 'string' ? route.params.definitionId : ''
designer.resetEmpty(id || undefined)
} finally {
if (gen === bootGen) {
allowAutosave = true
}
}
}
function applyDraft(draft: DesignerDraft, version: number | null) {
designer.loadGraph({
...draft,
version,
fromDraft: true,
})
void fitAfterLoad()
}
function onOpenDraft() {
const draft = conflictDraft.value
const published = conflictPublished.value
conflictShow.value = false
if (!draft) {
return
}
applyDraft(draft, published?.version ?? null)
}
function onDiscardDraft() {
const published = conflictPublished.value
const draft = conflictDraft.value
conflictShow.value = false
if (draft) {
drafts.remove(draft.id)
}
if (published) {
designer.loadDocument(published)
void fitAfterLoad()
}
}
async function discardLocalDraft() {
if (!definitionId.value) {
return
}
const ok = await new Promise<boolean>((resolve) => {
dialog.warning({
title: 'Discard local draft',
content: 'Delete the local draft and open the published version.',
positiveText: 'Discard',
negativeText: 'Cancel',
onPositiveClick: () => resolve(true),
onNegativeClick: () => resolve(false),
onClose: () => resolve(false),
})
})
if (!ok) {
return
}
const id = definitionId.value
drafts.remove(id)
try {
const published = await actionAsync.run(() => ordo.getDefinition(id))
designer.loadDocument(published)
await fitAfterLoad()
} catch {
message.error(actionAsync.error.value || 'Failed to open published')
}
}
function onKeyDown(event: KeyboardEvent) {
const target = event.target as HTMLElement | null
if (target?.closest('input, textarea, [contenteditable="true"]')) {
return
}
const meta = event.ctrlKey || event.metaKey
if (meta && event.key.toLowerCase() === 's') {
event.preventDefault()
if (!readOnly.value) {
persistDraft(false)
}
return
}
if (readOnly.value) {
return
}
if (meta && event.key.toLowerCase() === 'z') {
event.preventDefault()
if (event.shiftKey) {
applyRedo()
} else {
applyUndo()
}
return
}
if (meta && event.key.toLowerCase() === 'c') {
event.preventDefault()
designer.copySelection()
return
}
if (meta && event.key.toLowerCase() === 'v') {
event.preventDefault()
designer.pasteClipboard()
syncFlowNodes()
return
}
if (event.key !== 'Delete' && event.key !== 'Backspace') {
return
}
event.preventDefault()
void requestDeleteSelected()
}
async function requestDeleteSelected() {
if (readOnly.value) {
return
}
const nodeIds = designer.selectedNodeIds.length
? designer.selectedNodeIds
: designer.selectedNodeId
? [designer.selectedNodeId]
: []
if (nodeIds.length) {
if (designer.hasOutgoingAmong(nodeIds)) {
const ok = await new Promise<boolean>((resolve) => {
dialog.warning({
title: 'Delete node',
content: 'A selected node has outgoing edges. Delete selected nodes and connected edges?',
positiveText: 'Delete',
negativeText: 'Cancel',
onPositiveClick: () => resolve(true),
onNegativeClick: () => resolve(false),
onClose: () => resolve(false),
})
})
if (!ok) {
return
}
}
designer.removeNodes(nodeIds)
return
}
designer.removeSelected()
}
function applyUndo() {
if (designer.undo()) {
syncFlowNodes()
}
}
function applyRedo() {
if (designer.redo()) {
syncFlowNodes()
}
}
function align(mode: 'left' | 'center' | 'top') {
if (readOnly.value) {
return
}
if (designer.alignSelected(mode)) {
syncFlowNodes()
}
}
function onBeforeUnload(event: BeforeUnloadEvent) {
flushAutosave()
if (!designer.isDirty) {
return
}
event.preventDefault()
event.returnValue = ''
}
onMounted(() => {
void bootstrap()
window.addEventListener('keydown', onKeyDown)
window.addEventListener('beforeunload', onBeforeUnload)
})
onUnmounted(() => {
flushAutosave()
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('beforeunload', onBeforeUnload)
if (savedHintTimer) {
clearTimeout(savedHintTimer)
}
})
onBeforeRouteLeave(async () => {
flushAutosave()
return confirmLeave()
})
function replaceDefinition(id: string) {
if (route.params.definitionId === id) {
return
}
skipRouteBootstrap = true
void router.replace({ name: 'designer', params: { definitionId: id } })
}
watch(
() => [route.params.definitionId, route.query.version] as const,
(curr, prev) => {
if (skipRouteBootstrap) {
skipRouteBootstrap = false
return
}
if (prev && curr[0] === prev[0] && curr[1] === prev[1]) {
return
}
void bootstrap()
},
)
watch(isDirty, () => {
scheduleAutosave()
})
function onDragOver(event: DragEvent) {
event.preventDefault()
if (event.dataTransfer) {
event.dataTransfer.dropEffect = readOnly.value ? 'none' : 'move'
}
}
function onDrop(event: DragEvent) {
if (readOnly.value) {
return
}
const type = event.dataTransfer?.getData('application/vueflow')
if (!type) {
return
}
const position = screenToFlowCoordinate({ x: event.clientX, y: event.clientY })
const result = designer.addNode(type, position)
if (result === 'duplicate-end') {
message.info('Only one End node is allowed')
}
}
function onConnect(connection: Connection) {
if (readOnly.value) {
return
}
designer.onConnect(connection)
}
function onNodeClick({ node }: NodeMouseEvent) {
designer.selectNode(node.id)
}
function onEdgeClick({ edge }: EdgeMouseEvent) {
designer.selectEdge(edge.id)
}
function onPaneClick() {
designer.selectNode(null)
designer.selectEdge(null)
}
function onSelectionChange(params: { nodes: { id: string }[]; edges: { id: string }[] }) {
designer.setSelection(
params.nodes.map((n) => n.id),
params.edges.map((e) => e.id),
)
}
let dragHistoryStarted = false
function onNodeDragStart() {
if (readOnly.value || dragHistoryStarted) {
return
}
dragHistoryStarted = true
designer.pushHistory()
}
function onNodeDragStop() {
dragHistoryStarted = false
designer.settleLanes()
}
function saveAsDraft() {
drafts.save({
id: definitionId.value,
name: name.value,
startStepId: designer.startStepId,
nodes: nodes.value,
edges: edges.value,
})
designer.loadedFromDraft = true
designer.captureBaseline()
message.success('Saved as draft')
skipRouteBootstrap = true
void router.replace({ name: 'designer', params: { definitionId: definitionId.value } })
}
function saveDraft() {
persistDraft(false)
}
async function loadExample() {
const ok = await confirmLeave()
if (!ok) {
return
}
designer.loadDocument(LEAVE_REQUEST_EXAMPLE)
void fitAfterLoad()
replaceDefinition(LEAVE_REQUEST_EXAMPLE.id)
}
async function autoLayout() {
if (readOnly.value) {
return
}
designer.relayout()
syncFlowNodes()
await fitAfterLoad()
}
async function validate() {
const local = designer.localErrors()
if (local.length) {
message.error(`${local.length} validation error(s)`)
return
}
try {
await actionAsync.run(() => ordo.parseDefinition(designer.document()))
message.success('Validation passed')
} catch {
message.error(actionAsync.error.value || 'Validation failed')
}
}
async function publish() {
const local = designer.localErrors()
if (local.length) {
message.error(`${local.length} validation error(s), cannot publish`)
return
}
try {
const previous = designer.publishedVersion
const published = await actionAsync.run(() => ordo.publishDefinition(designer.document()))
designer.publishedVersion = published.version ?? null
drafts.save({
id: definitionId.value,
name: name.value,
startStepId: designer.startStepId,
nodes: nodes.value,
edges: edges.value,
})
designer.loadedFromDraft = false
designer.captureBaseline()
if (published.version != null && previous != null && published.version === previous) {
message.info(`Graph unchanged (still v${published.version})`)
} else {
message.success(`Published v${published.version}`)
}
replaceDefinition(definitionId.value)
} catch {
message.error(actionAsync.error.value || 'Publish failed')
}
}
async function copyJson() {
await navigator.clipboard.writeText(jsonText.value)
message.success('Copied')
}
function downloadJson() {
const blob = new Blob([jsonText.value], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${definitionId.value || 'process'}.json`
a.click()
URL.revokeObjectURL(url)
}
</script>
<template>
<div class="flex h-screen flex-col bg-slate-100">
<header class="flex h-12 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-4">
<div class="flex items-center gap-3">
<n-button quaternary size="small" @click="router.push({ name: 'definitions' })">
<template #icon>
<ArrowLeft :size="16" />
</template>
Back
</n-button>
<span class="text-sm font-medium text-slate-800">{{ name || 'Designer' }}</span>
<n-tag size="small">{{ versionLabel }}</n-tag>
<n-tag v-if="publishedHint" size="small" type="info">{{ publishedHint }}</n-tag>
<n-tag v-if="dirtyLabel" size="small" type="warning">{{ dirtyLabel }}</n-tag>
<n-tag v-if="savedHint" size="small" type="success">Saved</n-tag>
</div>
<n-space>
<n-button v-if="hasLocalDraft && !readOnly" size="small" @click="discardLocalDraft">Discard draft</n-button>
<n-button v-if="readOnly" size="small" type="primary" @click="saveAsDraft">Save as draft</n-button>
<n-button size="small" :disabled="readOnly || !canUndo" @click="applyUndo">Undo</n-button>
<n-button size="small" :disabled="readOnly || !canRedo" @click="applyRedo">Redo</n-button>
<n-button size="small" :disabled="readOnly" @click="loadExample">Load example</n-button>
<n-button size="small" :disabled="readOnly" @click="autoLayout">Auto layout</n-button>
<n-button size="small" :disabled="readOnly" @click="saveDraft">Save draft</n-button>
<n-button size="small" :disabled="readOnly" @click="validate">Validate</n-button>
<n-button size="small" type="primary" :disabled="readOnly" @click="publish">Publish</n-button>
<n-button size="small" @click="jsonShow = true">JSON</n-button>
<n-button size="small" @click="shortcutsShow = true">Shortcuts</n-button>
</n-space>
</header>
<GraphErrorList />
<div class="flex min-h-0 flex-1">
<NodePalette />
<div class="relative min-w-0 flex-1" @drop="onDrop" @dragover="onDragOver">
<n-spin :show="loading" class="h-full w-full" content-class="h-full">
<VueFlow
id="ordo-designer"
v-model:nodes="nodes"
v-model:edges="edges"
:node-types="nodeTypes"
:edge-types="edgeTypes"
:nodes-draggable="!readOnly"
:nodes-connectable="!readOnly"
:elements-selectable="true"
:connection-mode="ConnectionMode.Loose"
:snap-to-grid="true"
:snap-grid="[16, 16]"
multi-selection-key-code="Shift"
class="h-full w-full"
@connect="onConnect"
@node-click="onNodeClick"
@edge-click="onEdgeClick"
@pane-click="onPaneClick"
@selection-change="onSelectionChange"
@node-drag-start="onNodeDragStart"
@node-drag-stop="onNodeDragStop"
>
<Background />
<Controls />
<MiniMap />
</VueFlow>
</n-spin>
<div v-if="canAlign" class="absolute top-3 left-3 z-10 flex gap-1 rounded-md border border-slate-200 bg-white p-1 shadow-sm">
<n-button size="tiny" @click="align('left')">Align left</n-button>
<n-button size="tiny" @click="align('center')">Align center</n-button>
<n-button size="tiny" @click="align('top')">Align top</n-button>
</div>
<div
v-if="!loading && !nodes.length"
class="pointer-events-none absolute inset-0 flex items-center justify-center"
>
<div class="rounded-xl border border-dashed border-slate-300 bg-white/80 px-6 py-4 text-center text-sm text-slate-500">
Drag a node from the left palette onto the canvas
</div>
</div>
</div>
<PropertyPanel :read-only="readOnly" />
</div>
<DraftConflictModal
v-model:show="conflictShow"
:draft-text="conflictDraftText"
:published-text="conflictPublishedText"
@open-draft="onOpenDraft"
@discard="onDiscardDraft"
/>
<n-modal v-model:show="shortcutsShow">
<n-card title="Shortcuts" style="width: 420px" closable @close="shortcutsShow = false">
<div class="space-y-2 text-sm text-slate-600">
<p>Undo: Ctrl/Cmd + Z</p>
<p>Redo: Ctrl/Cmd + Shift + Z</p>
<p>Copy: Ctrl/Cmd + C</p>
<p>Paste: Ctrl/Cmd + V</p>
<p>Delete: Delete / Backspace</p>
<p>Box select: Shift + drag</p>
<p>Save draft: Ctrl/Cmd + S</p>
<p v-if="readOnly" class="text-amber-600">Edit shortcuts are disabled in read-only mode.</p>
</div>
</n-card>
</n-modal>
<n-drawer v-model:show="jsonShow" :width="480" placement="right">
<n-drawer-content title="Engine JSON">
<template #footer>
<n-space>
<n-button size="small" @click="copyJson">Copy</n-button>
<n-button size="small" type="primary" @click="downloadJson">Download .json</n-button>
</n-space>
</template>
<pre class="overflow-auto rounded-md bg-slate-50 p-3 text-xs text-slate-700">{{ jsonText }}</pre>
</n-drawer-content>
</n-drawer>
</div>
</template>
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true,
// Path mapping for cleaner imports.
"paths": {
"@/*": ["./src/*"]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}
+27
View File
@@ -0,0 +1,27 @@
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
// Most tools use transpilation instead of Node.js's native type-stripping.
// Bundler mode provides a smoother developer experience.
"module": "preserve",
"moduleResolution": "bundler",
// Include Node.js types and avoid accidentally including other `@types/*` packages.
"types": ["node"],
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
"noEmit": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}
+28
View File
@@ -0,0 +1,28 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
tailwindcss(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
proxy: {
'/ordo': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})