mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(ui/workflows): vue-flow canvas + component dialogs + dark / mobile polish
This commit is contained in:
parent
2a25f4fcfb
commit
f8499eefa7
@ -13,9 +13,14 @@
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@google/model-viewer": "^4.2.0",
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.3",
|
||||
"@vue-flow/core": "^1.48.2",
|
||||
"@vue-flow/minimap": "^1.5.4",
|
||||
"axios": "^1.7.9",
|
||||
"cytoscape": "^3.32.0",
|
||||
"cytoscape-cose-bilkent": "^4.1.0",
|
||||
"dagre": "^0.8.5",
|
||||
"dayjs": "^1.11.13",
|
||||
"dompurify": "^3.3.3",
|
||||
"echarts": "^6.0.0",
|
||||
@ -33,6 +38,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/dagre": "^0.7.54",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
|
||||
132
mateclaw-ui/pnpm-lock.yaml
generated
132
mateclaw-ui/pnpm-lock.yaml
generated
@ -14,6 +14,18 @@ importers:
|
||||
'@google/model-viewer':
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0(three@0.182.0)
|
||||
'@vue-flow/background':
|
||||
specifier: ^1.3.2
|
||||
version: 1.3.2(@vue-flow/core@1.48.2(vue@3.5.31(typescript@5.7.3)))(vue@3.5.31(typescript@5.7.3))
|
||||
'@vue-flow/controls':
|
||||
specifier: ^1.1.3
|
||||
version: 1.1.3(@vue-flow/core@1.48.2(vue@3.5.31(typescript@5.7.3)))(vue@3.5.31(typescript@5.7.3))
|
||||
'@vue-flow/core':
|
||||
specifier: ^1.48.2
|
||||
version: 1.48.2(vue@3.5.31(typescript@5.7.3))
|
||||
'@vue-flow/minimap':
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4(@vue-flow/core@1.48.2(vue@3.5.31(typescript@5.7.3)))(vue@3.5.31(typescript@5.7.3))
|
||||
axios:
|
||||
specifier: ^1.7.9
|
||||
version: 1.14.0
|
||||
@ -23,6 +35,9 @@ importers:
|
||||
cytoscape-cose-bilkent:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0(cytoscape@3.33.2)
|
||||
dagre:
|
||||
specifier: ^0.8.5
|
||||
version: 0.8.5
|
||||
dayjs:
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.20
|
||||
@ -69,6 +84,9 @@ importers:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.2(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
'@types/dagre':
|
||||
specifier: ^0.7.54
|
||||
version: 0.7.54
|
||||
'@types/katex':
|
||||
specifier: ^0.16.8
|
||||
version: 0.16.8
|
||||
@ -753,6 +771,9 @@ packages:
|
||||
'@types/d3@7.4.3':
|
||||
resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==}
|
||||
|
||||
'@types/dagre@0.7.54':
|
||||
resolution: {integrity: sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==}
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
@ -796,6 +817,29 @@ packages:
|
||||
'@volar/typescript@2.4.28':
|
||||
resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==}
|
||||
|
||||
'@vue-flow/background@1.3.2':
|
||||
resolution: {integrity: sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==}
|
||||
peerDependencies:
|
||||
'@vue-flow/core': ^1.23.0
|
||||
vue: ^3.3.0
|
||||
|
||||
'@vue-flow/controls@1.1.3':
|
||||
resolution: {integrity: sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==}
|
||||
peerDependencies:
|
||||
'@vue-flow/core': ^1.23.0
|
||||
vue: ^3.3.0
|
||||
|
||||
'@vue-flow/core@1.48.2':
|
||||
resolution: {integrity: sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==}
|
||||
peerDependencies:
|
||||
vue: ^3.3.0
|
||||
|
||||
'@vue-flow/minimap@1.5.4':
|
||||
resolution: {integrity: sha512-l4C+XTAXnRxsRpUdN7cAVFBennC1sVRzq4bDSpVK+ag7tdMczAnhFYGgbLkUw3v3sY6gokyWwMl8CDonp8eB2g==}
|
||||
peerDependencies:
|
||||
'@vue-flow/core': ^1.23.0
|
||||
vue: ^3.3.0
|
||||
|
||||
'@vue/compiler-core@3.5.31':
|
||||
resolution: {integrity: sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ==}
|
||||
|
||||
@ -851,12 +895,21 @@ packages:
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
'@vueuse/core@10.11.1':
|
||||
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
|
||||
|
||||
'@vueuse/core@12.0.0':
|
||||
resolution: {integrity: sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==}
|
||||
|
||||
'@vueuse/metadata@10.11.1':
|
||||
resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
|
||||
|
||||
'@vueuse/metadata@12.0.0':
|
||||
resolution: {integrity: sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==}
|
||||
|
||||
'@vueuse/shared@10.11.1':
|
||||
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
|
||||
|
||||
'@vueuse/shared@12.0.0':
|
||||
resolution: {integrity: sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==}
|
||||
|
||||
@ -1148,6 +1201,9 @@ packages:
|
||||
dagre-d3-es@7.0.14:
|
||||
resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==}
|
||||
|
||||
dagre@0.8.5:
|
||||
resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==}
|
||||
|
||||
dayjs@1.11.20:
|
||||
resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
|
||||
|
||||
@ -1372,6 +1428,9 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
graphlib@2.1.8:
|
||||
resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==}
|
||||
|
||||
hachure-fill@0.5.2:
|
||||
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
|
||||
|
||||
@ -1937,6 +1996,17 @@ packages:
|
||||
vue-component-type-helpers@3.2.6:
|
||||
resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@vue/composition-api': ^1.0.0-rc.1
|
||||
vue: ^3.0.0-0 || ^2.6.0
|
||||
peerDependenciesMeta:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
vue-eslint-parser@9.4.3:
|
||||
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
|
||||
engines: {node: ^14.17.0 || >=16.0.0}
|
||||
@ -2501,6 +2571,8 @@ snapshots:
|
||||
'@types/d3-transition': 3.0.9
|
||||
'@types/d3-zoom': 3.0.8
|
||||
|
||||
'@types/dagre@0.7.54': {}
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/geojson@7946.0.16': {}
|
||||
@ -2542,6 +2614,34 @@ snapshots:
|
||||
path-browserify: 1.0.1
|
||||
vscode-uri: 3.1.0
|
||||
|
||||
'@vue-flow/background@1.3.2(@vue-flow/core@1.48.2(vue@3.5.31(typescript@5.7.3)))(vue@3.5.31(typescript@5.7.3))':
|
||||
dependencies:
|
||||
'@vue-flow/core': 1.48.2(vue@3.5.31(typescript@5.7.3))
|
||||
vue: 3.5.31(typescript@5.7.3)
|
||||
|
||||
'@vue-flow/controls@1.1.3(@vue-flow/core@1.48.2(vue@3.5.31(typescript@5.7.3)))(vue@3.5.31(typescript@5.7.3))':
|
||||
dependencies:
|
||||
'@vue-flow/core': 1.48.2(vue@3.5.31(typescript@5.7.3))
|
||||
vue: 3.5.31(typescript@5.7.3)
|
||||
|
||||
'@vue-flow/core@1.48.2(vue@3.5.31(typescript@5.7.3))':
|
||||
dependencies:
|
||||
'@vueuse/core': 10.11.1(vue@3.5.31(typescript@5.7.3))
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-zoom: 3.0.0
|
||||
vue: 3.5.31(typescript@5.7.3)
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
|
||||
'@vue-flow/minimap@1.5.4(@vue-flow/core@1.48.2(vue@3.5.31(typescript@5.7.3)))(vue@3.5.31(typescript@5.7.3))':
|
||||
dependencies:
|
||||
'@vue-flow/core': 1.48.2(vue@3.5.31(typescript@5.7.3))
|
||||
d3-selection: 3.0.0
|
||||
d3-zoom: 3.0.0
|
||||
vue: 3.5.31(typescript@5.7.3)
|
||||
|
||||
'@vue/compiler-core@3.5.31':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.2
|
||||
@ -2631,6 +2731,16 @@ snapshots:
|
||||
typescript: 5.7.3
|
||||
vue: 3.5.31(typescript@5.7.3)
|
||||
|
||||
'@vueuse/core@10.11.1(vue@3.5.31(typescript@5.7.3))':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.20
|
||||
'@vueuse/metadata': 10.11.1
|
||||
'@vueuse/shared': 10.11.1(vue@3.5.31(typescript@5.7.3))
|
||||
vue-demi: 0.14.10(vue@3.5.31(typescript@5.7.3))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@vueuse/core@12.0.0(typescript@5.7.3)':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.20
|
||||
@ -2640,8 +2750,17 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@vueuse/metadata@10.11.1': {}
|
||||
|
||||
'@vueuse/metadata@12.0.0': {}
|
||||
|
||||
'@vueuse/shared@10.11.1(vue@3.5.31(typescript@5.7.3))':
|
||||
dependencies:
|
||||
vue-demi: 0.14.10(vue@3.5.31(typescript@5.7.3))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@vueuse/shared@12.0.0(typescript@5.7.3)':
|
||||
dependencies:
|
||||
vue: 3.5.31(typescript@5.7.3)
|
||||
@ -2962,6 +3081,11 @@ snapshots:
|
||||
d3: 7.9.0
|
||||
lodash-es: 4.18.1
|
||||
|
||||
dagre@0.8.5:
|
||||
dependencies:
|
||||
graphlib: 2.1.8
|
||||
lodash: 4.18.1
|
||||
|
||||
dayjs@1.11.20: {}
|
||||
|
||||
debug@4.4.3:
|
||||
@ -3241,6 +3365,10 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
graphlib@2.1.8:
|
||||
dependencies:
|
||||
lodash: 4.18.1
|
||||
|
||||
hachure-fill@0.5.2: {}
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
@ -3724,6 +3852,10 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.2.6: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.31(typescript@5.7.3)):
|
||||
dependencies:
|
||||
vue: 3.5.31(typescript@5.7.3)
|
||||
|
||||
vue-eslint-parser@9.4.3(eslint@9.39.4(jiti@2.6.1)):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
|
||||
@ -140,6 +140,11 @@
|
||||
--mc-scrollbar-thumb: #C4B5A8;
|
||||
--mc-scrollbar-thumb-hover: #A08070;
|
||||
|
||||
/* workflow canvas — vue-flow surfaces */
|
||||
--mc-canvas-dot: rgba(0, 0, 0, 0.18);
|
||||
--mc-edge: #c2b3a5;
|
||||
--mc-edge-strong: #8a7261;
|
||||
|
||||
/* Element Plus light override */
|
||||
--el-color-primary: #D97757;
|
||||
--el-font-family: var(--mc-font-body);
|
||||
@ -260,9 +265,51 @@ html.dark {
|
||||
--mc-scrollbar-thumb: #5A4438;
|
||||
--mc-scrollbar-thumb-hover: #7A6050;
|
||||
|
||||
/* workflow canvas — darker dots + softer edge color so they read on
|
||||
the dark background without overpowering the node card. */
|
||||
--mc-canvas-dot: rgba(255, 255, 255, 0.14);
|
||||
--mc-edge: #5a4a3e;
|
||||
--mc-edge-strong: #8c7361;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Vue Flow theme bridge — its default stylesheet ships hard-coded
|
||||
light colors for handles / mini-map / control buttons. Map them to
|
||||
our theme tokens so dark mode inherits cleanly. */
|
||||
.vue-flow__handle {
|
||||
background: var(--mc-edge-strong, #777);
|
||||
border-color: var(--mc-bg-elevated, #fff);
|
||||
}
|
||||
.vue-flow__edge-path {
|
||||
stroke: var(--mc-edge, #999);
|
||||
}
|
||||
.vue-flow__edge-text {
|
||||
fill: var(--mc-text-secondary, #555);
|
||||
font-size: 11px;
|
||||
}
|
||||
.vue-flow__edge-textbg {
|
||||
fill: var(--mc-bg-elevated, #fff);
|
||||
}
|
||||
.vue-flow__minimap {
|
||||
background: var(--mc-bg-sunken, rgba(0, 0, 0, 0.04)) !important;
|
||||
border: 1px solid var(--mc-border-light, rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
.vue-flow__minimap-mask {
|
||||
fill: var(--mc-bg-muted, rgba(0, 0, 0, 0.06));
|
||||
}
|
||||
.vue-flow__controls {
|
||||
box-shadow: var(--mc-shadow-soft, 0 2px 6px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
.vue-flow__controls-button {
|
||||
background: var(--mc-bg-elevated, #fff);
|
||||
border-bottom-color: var(--mc-border-light, rgba(0, 0, 0, 0.08));
|
||||
fill: var(--mc-text-primary, #333);
|
||||
}
|
||||
.vue-flow__controls-button:hover {
|
||||
background: var(--mc-bg-muted, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
Base resets
|
||||
================================================================ */
|
||||
|
||||
85
mateclaw-ui/src/components/workflow/CreateWorkflowDialog.vue
Normal file
85
mateclaw-ui/src/components/workflow/CreateWorkflowDialog.vue
Normal file
@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
:title="t('workflows.dialogs.createTitle')"
|
||||
width="480px"
|
||||
align-center
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<ElForm :model="form" label-position="top" @submit.prevent="handleSubmit">
|
||||
<ElFormItem :label="t('workflows.dialogs.fieldName')" required>
|
||||
<ElInput
|
||||
v-model="form.name"
|
||||
:placeholder="t('workflows.dialogs.namePlaceholder')"
|
||||
maxlength="128"
|
||||
show-word-limit
|
||||
autofocus
|
||||
@keyup.enter="handleSubmit"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="t('workflows.dialogs.fieldDescription')">
|
||||
<ElInput
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="1024"
|
||||
show-word-limit
|
||||
:placeholder="t('workflows.dialogs.descriptionPlaceholder')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="handleClose">{{ t('common.cancel') }}</ElButton>
|
||||
<ElButton type="primary" :loading="loading" :disabled="!form.name.trim()" @click="handleSubmit">
|
||||
{{ t('workflows.dialogs.createSubmit') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElDialog, ElForm, ElFormItem, ElInput, ElButton } from 'element-plus'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { loading: false })
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(e: 'submit', payload: { name: string; description: string }): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const visible = ref(props.modelValue)
|
||||
const form = reactive({ name: '', description: '' })
|
||||
|
||||
// Reset form whenever the dialog re-opens so a stale value from the
|
||||
// last create attempt doesn't leak in.
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
visible.value = open
|
||||
if (open) {
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
watch(visible, (v) => emit('update:modelValue', v))
|
||||
|
||||
function handleClose() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const trimmed = form.name.trim()
|
||||
if (!trimmed) return
|
||||
emit('submit', { name: trimmed, description: form.description.trim() })
|
||||
}
|
||||
</script>
|
||||
77
mateclaw-ui/src/components/workflow/PublishDialog.vue
Normal file
77
mateclaw-ui/src/components/workflow/PublishDialog.vue
Normal file
@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
:title="t('workflows.dialogs.publishTitle')"
|
||||
width="480px"
|
||||
align-center
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<p class="publish-hint">{{ t('workflows.dialogs.publishHint') }}</p>
|
||||
<ElForm :model="form" label-position="top" @submit.prevent="handleSubmit">
|
||||
<ElFormItem :label="t('workflows.dialogs.publishNote')">
|
||||
<ElInput
|
||||
v-model="form.note"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
:placeholder="t('workflows.dialogs.publishNotePlaceholder')"
|
||||
autofocus
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="handleClose">{{ t('common.cancel') }}</ElButton>
|
||||
<ElButton type="primary" :loading="loading" @click="handleSubmit">
|
||||
{{ t('workflows.actions.publish') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElDialog, ElForm, ElFormItem, ElInput, ElButton } from 'element-plus'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { loading: false })
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(e: 'submit', payload: { note: string }): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const visible = ref(props.modelValue)
|
||||
const form = reactive({ note: '' })
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
visible.value = open
|
||||
if (open) form.note = ''
|
||||
}
|
||||
)
|
||||
watch(visible, (v) => emit('update:modelValue', v))
|
||||
|
||||
function handleClose() {
|
||||
visible.value = false
|
||||
}
|
||||
function handleSubmit() {
|
||||
emit('submit', { note: form.note.trim() })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.publish-hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
</style>
|
||||
215
mateclaw-ui/src/components/workflow/StepNode.vue
Normal file
215
mateclaw-ui/src/components/workflow/StepNode.vue
Normal file
@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<div class="step-node" :class="`mode-${data.modeType}`" :data-selected="props.selected">
|
||||
<Handle type="target" :position="targetPosition" />
|
||||
<div class="step-band" />
|
||||
<div class="step-body">
|
||||
<div class="step-header">
|
||||
<span class="step-icon" v-html="modeIcon" />
|
||||
<span class="step-name" :title="data.name">{{ data.name }}</span>
|
||||
<span class="step-mode-tag">{{ data.modeType }}</span>
|
||||
</div>
|
||||
<div v-if="agentSlug" class="step-row">
|
||||
<span class="step-row-label">{{ t('workflows.canvas.nodeAgent') }}</span>
|
||||
<span class="step-row-value" :title="agentSlug">{{ agentSlug }}</span>
|
||||
</div>
|
||||
<div v-if="data.expression" class="step-row">
|
||||
<span class="step-row-label">{{ t('workflows.canvas.nodeExpression') }}</span>
|
||||
<code class="step-row-code" :title="data.expression">{{ data.expression }}</code>
|
||||
</div>
|
||||
<div v-if="approverHint" class="step-row">
|
||||
<span class="step-row-label">{{ t('workflows.canvas.nodeApprovalKind') }}</span>
|
||||
<span class="step-row-value">{{ approverHint }}</span>
|
||||
</div>
|
||||
<div v-if="channelHint" class="step-row">
|
||||
<span class="step-row-label">{{ t('workflows.canvas.nodeChannels') }}</span>
|
||||
<span class="step-row-value">{{ channelHint }}</span>
|
||||
</div>
|
||||
<div v-if="mergeHint" class="step-row">
|
||||
<span class="step-row-label">{{ t('workflows.canvas.nodeMergeStrategy') }}</span>
|
||||
<span class="step-row-value">{{ mergeHint }}</span>
|
||||
</div>
|
||||
<div v-if="data.promptTemplate" class="step-prompt" :title="data.promptTemplate">
|
||||
{{ truncatedPrompt }}
|
||||
</div>
|
||||
</div>
|
||||
<Handle type="source" :position="sourcePosition" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Handle, Position, type NodeProps } from '@vue-flow/core'
|
||||
import type { StepNodeData } from '@/composables/useWorkflowGraph'
|
||||
|
||||
const props = defineProps<NodeProps<StepNodeData>>()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Position values come from the parent canvas (LR or TB orientation);
|
||||
// we read them off the node so the arrows attach in the right place.
|
||||
const sourcePosition = computed(() => props.sourcePosition ?? Position.Right)
|
||||
const targetPosition = computed(() => props.targetPosition ?? Position.Left)
|
||||
|
||||
const data = computed(() => props.data as StepNodeData)
|
||||
|
||||
const agentSlug = computed(() => data.value.agentName?.trim() || '')
|
||||
|
||||
const approverHint = computed(() => {
|
||||
if (data.value.modeType !== 'await_approval') return ''
|
||||
return data.value.approvalKind || t('workflows.canvas.approvalDefault')
|
||||
})
|
||||
|
||||
const channelHint = computed(() => {
|
||||
if (data.value.modeType !== 'dispatch_channel') return ''
|
||||
const list = data.value.channels ?? []
|
||||
if (!list.length) return t('workflows.canvas.channelsEmpty')
|
||||
return list.join(', ')
|
||||
})
|
||||
|
||||
const mergeHint = computed(() => {
|
||||
if (data.value.modeType !== 'write_memory') return ''
|
||||
return data.value.mergeStrategy || 'append'
|
||||
})
|
||||
|
||||
const truncatedPrompt = computed(() => {
|
||||
const pt = data.value.promptTemplate ?? ''
|
||||
if (pt.length <= 80) return pt
|
||||
return pt.slice(0, 77) + '…'
|
||||
})
|
||||
|
||||
// Inline icons keep the bundle slim — no external icon set per node type.
|
||||
const ICONS: Record<string, string> = {
|
||||
sequential:
|
||||
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16"/><path d="M14 6l6 6-6 6"/></svg>',
|
||||
fan_out:
|
||||
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h6"/><path d="M11 6h8"/><path d="M11 18h8"/><path d="M11 6v12"/></svg>',
|
||||
collect:
|
||||
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 6h8"/><path d="M5 18h8"/><path d="M13 6v12"/><path d="M13 12h6"/></svg>',
|
||||
conditional:
|
||||
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l9 9-9 9-9-9z"/></svg>',
|
||||
await_approval:
|
||||
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
|
||||
dispatch_channel:
|
||||
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 11l18-8-7 18-2-8z"/></svg>',
|
||||
write_memory:
|
||||
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M4 9h16"/><path d="M9 14h6"/></svg>',
|
||||
}
|
||||
|
||||
const modeIcon = computed(() => ICONS[data.value.modeType] ?? ICONS.sequential)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.step-node {
|
||||
position: relative;
|
||||
width: 220px;
|
||||
min-height: 88px;
|
||||
border-radius: 10px;
|
||||
background: var(--mc-bg-elevated, #ffffff);
|
||||
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.12));
|
||||
color: var(--mc-text-primary, inherit);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
.step-node[data-selected="true"] {
|
||||
box-shadow: 0 0 0 2px var(--mc-primary, #4084ff);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.step-band {
|
||||
width: 6px;
|
||||
flex: 0 0 6px;
|
||||
background: var(--node-band, #4084ff);
|
||||
}
|
||||
/* Per-mode color bands so the canvas reads at a glance. */
|
||||
.step-node.mode-sequential { --node-band: #4084ff; }
|
||||
.step-node.mode-fan_out { --node-band: #b388ff; }
|
||||
.step-node.mode-collect { --node-band: #7e57c2; }
|
||||
.step-node.mode-conditional { --node-band: #ff8f3f; }
|
||||
.step-node.mode-await_approval { --node-band: #ffb84d; }
|
||||
.step-node.mode-dispatch_channel { --node-band: #2ecc71; }
|
||||
.step-node.mode-write_memory { --node-band: #1abc9c; }
|
||||
|
||||
.step-body {
|
||||
flex: 1;
|
||||
padding: 8px 10px 8px 10px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.step-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.step-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--node-band, #4084ff);
|
||||
}
|
||||
.step-name {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.step-mode-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--mc-bg-muted, rgba(0, 0, 0, 0.06));
|
||||
color: var(--mc-text-secondary, inherit);
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.step-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: baseline;
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
min-width: 0;
|
||||
}
|
||||
.step-row-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.55;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.step-row-value {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.step-row-code {
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-size: 11px;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.step-prompt {
|
||||
margin-top: 2px;
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-size: 10.5px;
|
||||
opacity: 0.7;
|
||||
line-height: 1.35;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
/* Limit to two lines to keep the node a sane height. */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
250
mateclaw-ui/src/components/workflow/WorkflowCanvas.vue
Normal file
250
mateclaw-ui/src/components/workflow/WorkflowCanvas.vue
Normal file
@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<div class="workflow-canvas" :class="{ fullscreen }">
|
||||
<div class="canvas-toolbar">
|
||||
<div class="canvas-toolbar-group">
|
||||
<button class="canvas-btn" :class="{ active: direction === 'LR' }" @click="direction = 'LR'">
|
||||
{{ t('workflows.canvas.layoutLR') }}
|
||||
</button>
|
||||
<button class="canvas-btn" :class="{ active: direction === 'TB' }" @click="direction = 'TB'">
|
||||
{{ t('workflows.canvas.layoutTB') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="canvas-toolbar-group">
|
||||
<span class="canvas-stat">{{ t('workflows.canvas.stats', { nodes: graph.nodes.length, edges: graph.edges.length }) }}</span>
|
||||
<span v-if="graph.parseError" class="canvas-stat err">{{ t('workflows.canvas.parseError', { msg: graph.parseError }) }}</span>
|
||||
<button
|
||||
class="canvas-btn icon-btn"
|
||||
:title="fullscreen ? t('workflows.canvas.fullscreenExit') : t('workflows.canvas.fullscreenEnter')"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<svg v-if="!fullscreen" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 9V3h6"/><path d="M21 9V3h-6"/><path d="M3 15v6h6"/><path d="M21 15v6h-6"/>
|
||||
</svg>
|
||||
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 3v6H3"/><path d="M15 3v6h6"/><path d="M9 21v-6H3"/><path d="M15 21v-6h6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!graph.nodes.length && !graph.parseError" class="canvas-empty">
|
||||
{{ t('workflows.canvas.empty') }}
|
||||
</div>
|
||||
|
||||
<VueFlow
|
||||
v-else-if="!graph.parseError"
|
||||
:id="canvasId"
|
||||
class="canvas-flow"
|
||||
:nodes="graph.nodes"
|
||||
:edges="graph.edges"
|
||||
:node-types="nodeTypes"
|
||||
:nodes-draggable="false"
|
||||
:nodes-connectable="false"
|
||||
:elements-selectable="true"
|
||||
:pan-on-drag="true"
|
||||
:zoom-on-scroll="true"
|
||||
:min-zoom="0.4"
|
||||
:max-zoom="1.8"
|
||||
:default-edge-options="defaultEdgeOptions"
|
||||
fit-view-on-init
|
||||
@node-click="handleNodeClick"
|
||||
@pane-click="handlePaneClick"
|
||||
>
|
||||
<Background :pattern-color="dotColor" :gap="20" />
|
||||
<Controls show-zoom show-fit-view show-interactive />
|
||||
<MiniMap
|
||||
:node-color="miniNodeColor"
|
||||
:node-stroke-color="miniNodeStrokeColor"
|
||||
:node-class-name="(n: any) => 'mini-' + (n?.data?.modeType ?? 'sequential')"
|
||||
pannable
|
||||
zoomable
|
||||
/>
|
||||
</VueFlow>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, markRaw, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { VueFlow, type Node } from '@vue-flow/core'
|
||||
import { Background } from '@vue-flow/background'
|
||||
import { Controls } from '@vue-flow/controls'
|
||||
import { MiniMap } from '@vue-flow/minimap'
|
||||
import StepNode from './StepNode.vue'
|
||||
import { useWorkflowGraph, type StepNodeData } from '@/composables/useWorkflowGraph'
|
||||
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'
|
||||
|
||||
interface Props {
|
||||
/** Workflow draft JSON — the source of truth. Canvas is read-only over it. */
|
||||
modelValue: string
|
||||
/** Stable id so multiple canvases on the page don't collide in vue-flow's state map. */
|
||||
canvasId?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { canvasId: 'workflow-canvas' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-step', payload: StepNodeData | null): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const direction = ref<'LR' | 'TB'>('LR')
|
||||
const jsonRef = computed(() => props.modelValue)
|
||||
const graph = useWorkflowGraph(jsonRef, direction)
|
||||
|
||||
// Register the custom node component once. markRaw prevents Vue from
|
||||
// turning the component definition into a deep proxy, which vue-flow
|
||||
// recommends for performance.
|
||||
const nodeTypes = {
|
||||
step: markRaw(StepNode),
|
||||
}
|
||||
|
||||
// Match the rest of the app's accent.
|
||||
const defaultEdgeOptions = {
|
||||
type: 'smoothstep' as const,
|
||||
animated: false,
|
||||
style: { stroke: 'var(--mc-edge, #999999)', strokeWidth: 1.5 },
|
||||
}
|
||||
|
||||
const dotColor = 'var(--mc-canvas-dot, rgba(0, 0, 0, 0.16))'
|
||||
const miniNodeColor = (node: Node<StepNodeData>) => {
|
||||
switch (node.data?.modeType) {
|
||||
case 'fan_out': return '#b388ff'
|
||||
case 'collect': return '#7e57c2'
|
||||
case 'conditional': return '#ff8f3f'
|
||||
case 'await_approval': return '#ffb84d'
|
||||
case 'dispatch_channel': return '#2ecc71'
|
||||
case 'write_memory': return '#1abc9c'
|
||||
default: return '#4084ff'
|
||||
}
|
||||
}
|
||||
const miniNodeStrokeColor = () => 'transparent'
|
||||
|
||||
function handleNodeClick(payload: { node: Node<StepNodeData> }) {
|
||||
emit('select-step', payload?.node?.data ?? null)
|
||||
}
|
||||
function handlePaneClick() {
|
||||
emit('select-step', null)
|
||||
}
|
||||
|
||||
// Reset direction toggle highlight when external code resets the model.
|
||||
watch(jsonRef, () => {
|
||||
/* nothing to do — the graph computed re-runs automatically */
|
||||
})
|
||||
|
||||
// Fullscreen toggle. We use a CSS-driven overlay rather than the
|
||||
// Fullscreen API so the rest of the page (sidebar utilities, app
|
||||
// header) stays styled consistently and the operator can still drag a
|
||||
// dialog over the canvas if needed.
|
||||
const fullscreen = ref(false)
|
||||
function toggleFullscreen() {
|
||||
fullscreen.value = !fullscreen.value
|
||||
}
|
||||
function handleEsc(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && fullscreen.value) fullscreen.value = false
|
||||
}
|
||||
watch(fullscreen, (on) => {
|
||||
if (on) {
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
// Lock page scroll behind the overlay.
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.removeEventListener('keydown', handleEsc)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', handleEsc)
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workflow-canvas {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-height: 320px;
|
||||
flex: 1;
|
||||
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.1));
|
||||
border-radius: 8px;
|
||||
background: var(--mc-bg-sunken, rgba(0, 0, 0, 0.02));
|
||||
color: var(--mc-text-primary, inherit);
|
||||
overflow: hidden;
|
||||
}
|
||||
.workflow-canvas.fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
min-height: 100vh;
|
||||
background: var(--mc-bg, #ffffff);
|
||||
}
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
padding: 0;
|
||||
height: 26px;
|
||||
}
|
||||
.canvas-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--mc-border-light, rgba(0, 0, 0, 0.08));
|
||||
font-size: 12px;
|
||||
background: var(--mc-bg-elevated, transparent);
|
||||
}
|
||||
.canvas-toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.canvas-btn {
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.12));
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.canvas-btn.active {
|
||||
background: var(--mc-primary, #4084ff);
|
||||
border-color: var(--mc-primary, #4084ff);
|
||||
color: #ffffff;
|
||||
}
|
||||
.canvas-stat {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.canvas-stat.err {
|
||||
color: #c0392b;
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
}
|
||||
.canvas-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
padding: 24px;
|
||||
}
|
||||
.canvas-flow {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 280px;
|
||||
}
|
||||
</style>
|
||||
235
mateclaw-ui/src/composables/useWorkflowGraph.ts
Normal file
235
mateclaw-ui/src/composables/useWorkflowGraph.ts
Normal file
@ -0,0 +1,235 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
import { Position, type Edge, type Node } from '@vue-flow/core'
|
||||
import dagre from 'dagre'
|
||||
|
||||
/**
|
||||
* Linear `{ steps: [...] }` workflow JSON ↔ vue-flow graph translation.
|
||||
*
|
||||
* The JSON shape is the source of truth (matches the workflow design's
|
||||
* "linear list + mode field" model). The canvas is a derived view, so
|
||||
* this composable rebuilds nodes + edges every time the JSON changes.
|
||||
*
|
||||
* Edge rules (v0 7-mode set):
|
||||
* - sequential / await_approval / dispatch_channel / write_memory:
|
||||
* one inbound edge from the previous "merge point", outbound becomes
|
||||
* the next merge point.
|
||||
* - fan_out: branches off the current merge point. Consecutive fan_out
|
||||
* steps all share the same parent (parallel siblings).
|
||||
* - collect: joins from every fan_out step in the most recent fan_out
|
||||
* group; the join becomes the new merge point.
|
||||
* - conditional: same shape as sequential, but the inbound edge carries
|
||||
* the expression as a label so readers can see the gate at a glance.
|
||||
*
|
||||
* Layout is done via dagre with a left-to-right rank direction; vue-flow
|
||||
* just renders the resulting `{ x, y }` positions. We don't enable
|
||||
* draggable nodes — keeping the graph derived from JSON keeps the two
|
||||
* representations from drifting.
|
||||
*/
|
||||
|
||||
export interface RawStep {
|
||||
name?: string
|
||||
agentName?: string
|
||||
mode?: { type?: string; expression?: string; [k: string]: unknown }
|
||||
promptTemplate?: string
|
||||
outputVar?: string
|
||||
outputContentType?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
export interface RawWorkflow {
|
||||
steps?: RawStep[]
|
||||
}
|
||||
|
||||
export interface StepNodeData {
|
||||
index: number
|
||||
name: string
|
||||
modeType: string
|
||||
agentName?: string
|
||||
promptTemplate?: string
|
||||
expression?: string
|
||||
approvalKind?: string
|
||||
channels?: string[]
|
||||
mergeStrategy?: string
|
||||
raw: RawStep
|
||||
}
|
||||
|
||||
const NODE_WIDTH = 220
|
||||
const NODE_HEIGHT = 88
|
||||
const RANK_SEP = 80
|
||||
const NODE_SEP = 40
|
||||
|
||||
function fallbackName(idx: number) {
|
||||
return `step-${idx + 1}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce sane node ids even when two steps share a name. The compiler
|
||||
* rejects duplicates at publish time, but a draft might have them; we
|
||||
* suffix with the index so vue-flow can still render the graph.
|
||||
*/
|
||||
function uniqueId(name: string, idx: number, seen: Set<string>): string {
|
||||
const base = name && name.trim() ? name.trim() : fallbackName(idx)
|
||||
if (!seen.has(base)) {
|
||||
seen.add(base)
|
||||
return base
|
||||
}
|
||||
const composite = `${base}__${idx}`
|
||||
seen.add(composite)
|
||||
return composite
|
||||
}
|
||||
|
||||
export function buildGraph(json: string): { nodes: Node<StepNodeData>[]; edges: Edge[]; parseError: string | null } {
|
||||
let parsed: RawWorkflow | null = null
|
||||
let parseError: string | null = null
|
||||
try {
|
||||
parsed = json && json.trim() ? (JSON.parse(json) as RawWorkflow) : { steps: [] }
|
||||
} catch (e) {
|
||||
parseError = (e as Error).message
|
||||
return { nodes: [], edges: [], parseError }
|
||||
}
|
||||
const steps = Array.isArray(parsed?.steps) ? parsed!.steps! : []
|
||||
if (steps.length === 0) {
|
||||
return { nodes: [], edges: [], parseError }
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const nodes: Node<StepNodeData>[] = steps.map((step, idx) => {
|
||||
const id = uniqueId(step?.name ?? '', idx, seen)
|
||||
const modeType = (step?.mode?.type ?? 'sequential').toString()
|
||||
const data: StepNodeData = {
|
||||
index: idx,
|
||||
name: step?.name?.trim() || fallbackName(idx),
|
||||
modeType,
|
||||
agentName: step?.agentName,
|
||||
promptTemplate: step?.promptTemplate,
|
||||
expression: typeof step?.mode?.expression === 'string' ? step.mode!.expression : undefined,
|
||||
approvalKind: typeof step?.mode?.approvalKind === 'string' ? (step.mode!.approvalKind as string) : undefined,
|
||||
channels: Array.isArray(step?.mode?.channels) ? (step.mode!.channels as string[]) : undefined,
|
||||
mergeStrategy: typeof step?.mode?.mergeStrategy === 'string' ? (step.mode!.mergeStrategy as string) : undefined,
|
||||
raw: step ?? {},
|
||||
}
|
||||
return {
|
||||
id,
|
||||
type: 'step',
|
||||
position: { x: 0, y: 0 }, // placeholder; dagre fills it in
|
||||
data,
|
||||
// Disable interactions — the JSON is the source of truth.
|
||||
draggable: false,
|
||||
selectable: true,
|
||||
connectable: false,
|
||||
}
|
||||
})
|
||||
|
||||
// Edge derivation walks the steps once, tracking:
|
||||
// mergePoint: the node id whose output the next non-fan_out step should consume
|
||||
// fanGroup: the ids of fan_out steps in the most recent open group, ready to be joined by a collect
|
||||
const edges: Edge[] = []
|
||||
let mergePoint: string | null = null
|
||||
let fanGroup: string[] = []
|
||||
let prevModeWasFanOut = false
|
||||
|
||||
nodes.forEach((node, idx) => {
|
||||
const mode = node.data!.modeType
|
||||
if (mode === 'fan_out') {
|
||||
// Open a new fan_out group on the previous merge point.
|
||||
if (!prevModeWasFanOut) fanGroup = []
|
||||
fanGroup.push(node.id)
|
||||
if (mergePoint) {
|
||||
edges.push({
|
||||
id: `e-${mergePoint}->${node.id}`,
|
||||
source: mergePoint,
|
||||
target: node.id,
|
||||
animated: false,
|
||||
})
|
||||
}
|
||||
prevModeWasFanOut = true
|
||||
} else if (mode === 'collect') {
|
||||
// Join every open fan_out branch into this node and reset the
|
||||
// merge point to here. If there is no open group (misconfigured
|
||||
// draft) we still draw an edge from the prior merge point so the
|
||||
// canvas surfaces the orphan visually.
|
||||
const sources = fanGroup.length ? fanGroup : (mergePoint ? [mergePoint] : [])
|
||||
for (const src of sources) {
|
||||
edges.push({
|
||||
id: `e-${src}->${node.id}`,
|
||||
source: src,
|
||||
target: node.id,
|
||||
animated: false,
|
||||
})
|
||||
}
|
||||
fanGroup = []
|
||||
mergePoint = node.id
|
||||
prevModeWasFanOut = false
|
||||
} else {
|
||||
// Sequential / conditional / await_approval / dispatch_channel / write_memory
|
||||
// all attach to the current merge point and become the next merge point.
|
||||
if (mergePoint) {
|
||||
const edge: Edge = {
|
||||
id: `e-${mergePoint}->${node.id}`,
|
||||
source: mergePoint,
|
||||
target: node.id,
|
||||
animated: false,
|
||||
}
|
||||
if (mode === 'conditional' && node.data?.expression) {
|
||||
edge.label = `if ${node.data.expression}`
|
||||
}
|
||||
edges.push(edge)
|
||||
}
|
||||
mergePoint = node.id
|
||||
fanGroup = []
|
||||
prevModeWasFanOut = false
|
||||
}
|
||||
// Quietly suppress idx/unused-warning if the runtime drops us here.
|
||||
void idx
|
||||
})
|
||||
|
||||
return { nodes, edges, parseError }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run dagre over the given nodes / edges and write the computed
|
||||
* positions back onto the node objects in place. Returns the same
|
||||
* arrays so callers can chain.
|
||||
*/
|
||||
export function applyDagreLayout(
|
||||
nodes: Node<StepNodeData>[],
|
||||
edges: Edge[],
|
||||
direction: 'LR' | 'TB' = 'LR'
|
||||
): { nodes: Node<StepNodeData>[]; edges: Edge[] } {
|
||||
if (nodes.length === 0) return { nodes, edges }
|
||||
const g = new dagre.graphlib.Graph({ compound: false })
|
||||
g.setDefaultEdgeLabel(() => ({}))
|
||||
g.setGraph({ rankdir: direction, nodesep: NODE_SEP, ranksep: RANK_SEP })
|
||||
|
||||
for (const n of nodes) {
|
||||
g.setNode(n.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
|
||||
}
|
||||
for (const e of edges) {
|
||||
g.setEdge(e.source, e.target)
|
||||
}
|
||||
dagre.layout(g)
|
||||
|
||||
const laidOut: Node<StepNodeData>[] = nodes.map((n) => {
|
||||
const pos = g.node(n.id)
|
||||
return {
|
||||
...n,
|
||||
// dagre returns the node center; vue-flow expects the top-left
|
||||
// corner, so offset by half the dimensions.
|
||||
position: { x: pos.x - NODE_WIDTH / 2, y: pos.y - NODE_HEIGHT / 2 },
|
||||
sourcePosition: direction === 'LR' ? Position.Right : Position.Bottom,
|
||||
targetPosition: direction === 'LR' ? Position.Left : Position.Top,
|
||||
}
|
||||
})
|
||||
|
||||
return { nodes: laidOut, edges }
|
||||
}
|
||||
|
||||
export function useWorkflowGraph(jsonRef: Ref<string>, direction: Ref<'LR' | 'TB'>) {
|
||||
const graph = computed(() => {
|
||||
const raw = buildGraph(jsonRef.value)
|
||||
if (raw.nodes.length === 0) return raw
|
||||
const { nodes, edges } = applyDagreLayout(raw.nodes, raw.edges, direction.value)
|
||||
return { nodes, edges, parseError: raw.parseError }
|
||||
})
|
||||
return graph
|
||||
}
|
||||
@ -1941,6 +1941,45 @@ export default {
|
||||
},
|
||||
jsonInvalid: 'JSON parse failed: {msg}',
|
||||
jsonOk: 'JSON syntax OK.',
|
||||
tabs: {
|
||||
canvas: 'Canvas',
|
||||
json: 'JSON',
|
||||
},
|
||||
canvas: {
|
||||
empty: 'The current draft has no steps. Add one in the JSON editor or pick a template.',
|
||||
layoutLR: 'Horizontal',
|
||||
layoutTB: 'Vertical',
|
||||
stats: '{nodes} nodes · {edges} edges',
|
||||
parseError: 'JSON parse failed: {msg}',
|
||||
fullscreenEnter: 'Fullscreen',
|
||||
fullscreenExit: 'Exit fullscreen',
|
||||
nodeAgent: 'Agent',
|
||||
nodeExpression: 'Condition',
|
||||
nodeApprovalKind: 'Approval',
|
||||
nodeChannels: 'Channels',
|
||||
nodeMergeStrategy: 'Merge',
|
||||
approvalDefault: 'manual',
|
||||
channelsEmpty: 'unspecified',
|
||||
inspector: {
|
||||
title: 'Step inspector',
|
||||
empty: 'Click a node on the canvas to inspect it.',
|
||||
rawHeader: 'Raw step JSON',
|
||||
},
|
||||
},
|
||||
dialogs: {
|
||||
createTitle: 'New workflow',
|
||||
fieldName: 'Name',
|
||||
namePlaceholder: 'e.g. employee-onboarding',
|
||||
fieldDescription: 'Description',
|
||||
descriptionPlaceholder: 'Optional. One or two sentences on what this workflow does.',
|
||||
createSubmit: 'Create',
|
||||
publishTitle: 'Publish workflow',
|
||||
publishHint: 'Publishing compiles the current draft into a new revision. Running instances stay on their current revision.',
|
||||
publishNote: 'Publish note',
|
||||
publishNotePlaceholder: 'Optional summary of what changed.',
|
||||
deleteTitle: 'Delete workflow',
|
||||
deleteContent: 'Delete workflow "{name}"? This is reversible via the audit log.',
|
||||
},
|
||||
selectHint: 'Select a workflow on the left, or click "New Workflow" to start a fresh draft.',
|
||||
runs: {
|
||||
header: 'Recent runs ({count})',
|
||||
|
||||
@ -1953,6 +1953,45 @@ export default {
|
||||
},
|
||||
jsonInvalid: 'JSON 解析失败:{msg}',
|
||||
jsonOk: 'JSON 语法 OK。',
|
||||
tabs: {
|
||||
canvas: '画布视图',
|
||||
json: 'JSON 编辑',
|
||||
},
|
||||
canvas: {
|
||||
empty: '当前草稿没有任何步骤,先在 JSON 编辑器中添加步骤或选择一个模板。',
|
||||
layoutLR: '横向布局',
|
||||
layoutTB: '纵向布局',
|
||||
stats: '{nodes} 节点 · {edges} 连线',
|
||||
parseError: 'JSON 解析失败:{msg}',
|
||||
fullscreenEnter: '全屏',
|
||||
fullscreenExit: '退出全屏',
|
||||
nodeAgent: 'Agent',
|
||||
nodeExpression: '条件',
|
||||
nodeApprovalKind: '审批',
|
||||
nodeChannels: '渠道',
|
||||
nodeMergeStrategy: '合并策略',
|
||||
approvalDefault: '人工审批',
|
||||
channelsEmpty: '未指定',
|
||||
inspector: {
|
||||
title: '步骤详情',
|
||||
empty: '点击画布上的节点查看详情。',
|
||||
rawHeader: '原始 JSON',
|
||||
},
|
||||
},
|
||||
dialogs: {
|
||||
createTitle: '新建工作流',
|
||||
fieldName: '名称',
|
||||
namePlaceholder: '例如:employee-onboarding',
|
||||
fieldDescription: '描述',
|
||||
descriptionPlaceholder: '可选,用一两句话说明这条工作流要解决什么问题',
|
||||
createSubmit: '创建',
|
||||
publishTitle: '发布工作流',
|
||||
publishHint: '发布会编译当前草稿,固化为新的 revision;正在运行的实例不会回退。',
|
||||
publishNote: '发布备注',
|
||||
publishNotePlaceholder: '可选,简述这次变更的内容',
|
||||
deleteTitle: '删除工作流',
|
||||
deleteContent: '将删除工作流「{name}」?此操作可通过审计日志撤销。',
|
||||
},
|
||||
selectHint: '在左侧选择一个工作流,或点击「新建工作流」开始。',
|
||||
runs: {
|
||||
header: '近期运行 ({count})',
|
||||
|
||||
@ -309,7 +309,7 @@ watch(workspaceId, reload)
|
||||
}
|
||||
.triggers-table th {
|
||||
font-weight: 600;
|
||||
background: var(--mc-surface, rgba(0, 0, 0, 0.04));
|
||||
background: var(--mc-bg-sunken, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
.pattern-detail {
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
@ -411,4 +411,33 @@ button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Mobile / tablet: the trigger table has 9 columns and breaks on
|
||||
phones. Wrap it in a horizontal-scroll container, collapse the
|
||||
form to one column, and stack the page header. */
|
||||
@media (max-width: 900px) {
|
||||
.triggers-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.triggers-table thead,
|
||||
.triggers-table tbody,
|
||||
.triggers-table tr {
|
||||
display: table;
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
}
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.form-grid .span-2 {
|
||||
grid-column: span 1;
|
||||
}
|
||||
.mc-page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -50,28 +50,64 @@
|
||||
<button class="btn-danger" :disabled="busy" @click="remove">{{ t('workflows.actions.delete') }}</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="editor-toolbar">
|
||||
<label class="template-picker">
|
||||
<span>{{ t('workflows.templates.label') }}</span>
|
||||
<select v-model="templateChoice" @change="insertTemplate">
|
||||
<option value="">{{ t('workflows.templates.placeholder') }}</option>
|
||||
<option value="sequential">{{ t('workflows.templates.sequential') }}</option>
|
||||
<option value="fan_out">{{ t('workflows.templates.fan_out') }}</option>
|
||||
<option value="collect">{{ t('workflows.templates.collect') }}</option>
|
||||
<option value="conditional">{{ t('workflows.templates.conditional') }}</option>
|
||||
<option value="await_approval">{{ t('workflows.templates.await_approval') }}</option>
|
||||
<option value="dispatch_channel">{{ t('workflows.templates.dispatch_channel') }}</option>
|
||||
<option value="write_memory">{{ t('workflows.templates.write_memory') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="json-hint" :class="jsonHintKind">{{ jsonHint }}</span>
|
||||
|
||||
<div class="editor-tabs">
|
||||
<button class="tab-btn" :class="{ active: editorTab === 'canvas' }" @click="editorTab = 'canvas'">
|
||||
{{ t('workflows.tabs.canvas') }}
|
||||
</button>
|
||||
<button class="tab-btn" :class="{ active: editorTab === 'json' }" @click="editorTab = 'json'">
|
||||
{{ t('workflows.tabs.json') }}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="draftJson"
|
||||
class="editor-body"
|
||||
spellcheck="false"
|
||||
:placeholder="t('workflows.bodyPlaceholder')"
|
||||
/>
|
||||
|
||||
<div v-if="editorTab === 'canvas'" class="canvas-pane">
|
||||
<WorkflowCanvas
|
||||
v-model="canvasModel"
|
||||
:canvas-id="`wf-${selected.id}`"
|
||||
@select-step="onCanvasSelect"
|
||||
/>
|
||||
<aside v-if="canvasSelection" class="canvas-inspector">
|
||||
<header>{{ t('workflows.canvas.inspector.title') }}</header>
|
||||
<dl class="inspector-grid">
|
||||
<dt>{{ t('workflows.dialogs.fieldName') }}</dt><dd>{{ canvasSelection.name }}</dd>
|
||||
<dt>mode</dt><dd>{{ canvasSelection.modeType }}</dd>
|
||||
<dt v-if="canvasSelection.agentName">{{ t('workflows.canvas.nodeAgent') }}</dt>
|
||||
<dd v-if="canvasSelection.agentName">{{ canvasSelection.agentName }}</dd>
|
||||
<dt v-if="canvasSelection.expression">{{ t('workflows.canvas.nodeExpression') }}</dt>
|
||||
<dd v-if="canvasSelection.expression"><code>{{ canvasSelection.expression }}</code></dd>
|
||||
</dl>
|
||||
<details>
|
||||
<summary>{{ t('workflows.canvas.inspector.rawHeader') }}</summary>
|
||||
<pre class="inspector-raw">{{ inspectorJson }}</pre>
|
||||
</details>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div v-else class="json-pane">
|
||||
<div class="editor-toolbar">
|
||||
<label class="template-picker">
|
||||
<span>{{ t('workflows.templates.label') }}</span>
|
||||
<select v-model="templateChoice" @change="insertTemplate">
|
||||
<option value="">{{ t('workflows.templates.placeholder') }}</option>
|
||||
<option value="sequential">{{ t('workflows.templates.sequential') }}</option>
|
||||
<option value="fan_out">{{ t('workflows.templates.fan_out') }}</option>
|
||||
<option value="collect">{{ t('workflows.templates.collect') }}</option>
|
||||
<option value="conditional">{{ t('workflows.templates.conditional') }}</option>
|
||||
<option value="await_approval">{{ t('workflows.templates.await_approval') }}</option>
|
||||
<option value="dispatch_channel">{{ t('workflows.templates.dispatch_channel') }}</option>
|
||||
<option value="write_memory">{{ t('workflows.templates.write_memory') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="json-hint" :class="jsonHintKind">{{ jsonHint }}</span>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="draftJson"
|
||||
class="editor-body"
|
||||
spellcheck="false"
|
||||
:placeholder="t('workflows.bodyPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="compileErrors.length" class="errors-panel">
|
||||
<div class="errors-title">{{ t('workflows.compileErrorsTitle', { count: compileErrors.length }) }}</div>
|
||||
<ul>
|
||||
@ -126,12 +162,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateWorkflowDialog v-model="createDialogOpen" :loading="busy" @submit="onCreateSubmit" />
|
||||
<PublishDialog v-model="publishDialogOpen" :loading="busy" @submit="onPublishSubmit" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
workflowApi,
|
||||
type WorkflowSummary,
|
||||
@ -141,6 +181,10 @@ import {
|
||||
type WorkflowCompileFailure,
|
||||
} from '@/api'
|
||||
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
|
||||
import WorkflowCanvas from '@/components/workflow/WorkflowCanvas.vue'
|
||||
import CreateWorkflowDialog from '@/components/workflow/CreateWorkflowDialog.vue'
|
||||
import PublishDialog from '@/components/workflow/PublishDialog.vue'
|
||||
import type { StepNodeData } from '@/composables/useWorkflowGraph'
|
||||
|
||||
const { t } = useI18n()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
@ -160,6 +204,36 @@ const runDetail = ref<{ run: WorkflowRun; steps: WorkflowRunStep[] } | null>(nul
|
||||
|
||||
const templateChoice = ref('')
|
||||
|
||||
// View-mode toggle between the canvas (read-only graph derived from
|
||||
// the JSON) and the raw JSON editor. Canvas is the default — most
|
||||
// authors visit the page to make sense of an existing flow rather
|
||||
// than to type fresh JSON.
|
||||
const editorTab = ref<'canvas' | 'json'>('canvas')
|
||||
|
||||
// The canvas reads from `draftJson` directly. We keep the model write
|
||||
// path on the JSON editor only — the canvas is purely a derived view
|
||||
// to avoid two sources of truth drifting apart.
|
||||
const canvasModel = computed({
|
||||
get: () => draftJson.value,
|
||||
set: (v: string) => { draftJson.value = v },
|
||||
})
|
||||
|
||||
const canvasSelection = ref<StepNodeData | null>(null)
|
||||
function onCanvasSelect(payload: StepNodeData | null) {
|
||||
canvasSelection.value = payload
|
||||
}
|
||||
const inspectorJson = computed(() => {
|
||||
if (!canvasSelection.value) return ''
|
||||
try {
|
||||
return JSON.stringify(canvasSelection.value.raw, null, 2)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const createDialogOpen = ref(false)
|
||||
const publishDialogOpen = ref(false)
|
||||
|
||||
// Live JSON-syntax check on the textarea so the operator sees parse errors
|
||||
// immediately, instead of waiting for compile to round-trip.
|
||||
const jsonHint = computed(() => {
|
||||
@ -292,18 +366,23 @@ async function loadRun(runId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function openCreate() {
|
||||
function openCreate() {
|
||||
if (!workspaceId.value) return
|
||||
createDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function onCreateSubmit(payload: { name: string; description: string }) {
|
||||
if (!workspaceId.value) return
|
||||
const name = window.prompt(t('workflows.prompts.newName'), t('workflows.prompts.defaultName'))
|
||||
if (!name) return
|
||||
busy.value = true
|
||||
try {
|
||||
const res = await workflowApi.create({
|
||||
workspaceId: workspaceId.value,
|
||||
name,
|
||||
name: payload.name,
|
||||
description: payload.description || undefined,
|
||||
enabled: true,
|
||||
})
|
||||
const created = res.data as unknown as WorkflowSummary
|
||||
createDialogOpen.value = false
|
||||
await reload()
|
||||
if (created?.id) await select(created.id)
|
||||
} catch (e) {
|
||||
@ -359,14 +438,19 @@ async function compile() {
|
||||
}
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
function publish() {
|
||||
if (!selected.value) return
|
||||
publishDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function onPublishSubmit(payload: { note: string }) {
|
||||
if (!selected.value) return
|
||||
busy.value = true
|
||||
compileErrors.value = []
|
||||
try {
|
||||
await workflowApi.saveDraft(selected.value.id, draftJson.value)
|
||||
const note = window.prompt(t('workflows.prompts.publishNote'), '') ?? undefined
|
||||
await workflowApi.publish(selected.value.id, note)
|
||||
await workflowApi.publish(selected.value.id, payload.note || undefined)
|
||||
publishDialogOpen.value = false
|
||||
setStatus(t('workflows.status.published'), 'ok')
|
||||
await reload()
|
||||
} catch (e) {
|
||||
@ -378,7 +462,21 @@ async function publish() {
|
||||
|
||||
async function remove() {
|
||||
if (!selected.value) return
|
||||
if (!window.confirm(t('workflows.prompts.deleteConfirm', { name: selected.value.name ?? '' }))) return
|
||||
const name = selected.value.name ?? ''
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
t('workflows.dialogs.deleteContent', { name }),
|
||||
t('workflows.dialogs.deleteTitle'),
|
||||
{
|
||||
confirmButtonText: t('workflows.actions.delete'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
type: 'warning',
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
// user cancelled — nothing to do
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
await workflowApi.delete(selected.value.id)
|
||||
@ -471,7 +569,7 @@ watch(workspaceId, reload)
|
||||
}
|
||||
.list-row:hover,
|
||||
.run-row:hover {
|
||||
background: var(--mc-surface-hover, rgba(0, 0, 0, 0.05));
|
||||
background: var(--mc-bg-muted, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
.list-row.active {
|
||||
background: var(--mc-primary-bg, rgba(64, 132, 255, 0.18));
|
||||
@ -493,7 +591,7 @@ watch(workspaceId, reload)
|
||||
border-radius: 999px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: var(--mc-surface-hover, rgba(0, 0, 0, 0.06));
|
||||
background: var(--mc-bg-muted, rgba(0, 0, 0, 0.06));
|
||||
}
|
||||
.badge.published {
|
||||
background: #2ecc71;
|
||||
@ -574,7 +672,7 @@ watch(workspaceId, reload)
|
||||
padding: 12px;
|
||||
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.1));
|
||||
border-radius: 6px;
|
||||
background: var(--mc-surface, rgba(0, 0, 0, 0.02));
|
||||
background: var(--mc-bg-sunken, rgba(0, 0, 0, 0.02));
|
||||
color: inherit;
|
||||
resize: vertical;
|
||||
min-height: 320px;
|
||||
@ -655,7 +753,7 @@ watch(workspaceId, reload)
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--mc-surface, rgba(0, 0, 0, 0.04));
|
||||
background: var(--mc-bg-sunken, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
.run-detail-title {
|
||||
font-weight: 600;
|
||||
@ -711,9 +809,136 @@ button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.editor-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px 0 8px;
|
||||
border-bottom: 1px solid var(--mc-border, rgba(0, 0, 0, 0.06));
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.tab-btn {
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.tab-btn:hover {
|
||||
background: var(--mc-bg-muted, rgba(0, 0, 0, 0.04));
|
||||
opacity: 1;
|
||||
}
|
||||
.tab-btn.active {
|
||||
opacity: 1;
|
||||
background: var(--mc-primary-bg, rgba(64, 132, 255, 0.14));
|
||||
color: var(--mc-primary, #4084ff);
|
||||
}
|
||||
.canvas-pane {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 240px;
|
||||
gap: 12px;
|
||||
min-height: 360px;
|
||||
}
|
||||
.canvas-inspector {
|
||||
background: var(--mc-bg-elevated, rgba(0, 0, 0, 0.02));
|
||||
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.08));
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.canvas-inspector header {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.inspector-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 8px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.inspector-grid dt {
|
||||
opacity: 0.6;
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
align-self: center;
|
||||
}
|
||||
.inspector-grid dd {
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
.inspector-grid code {
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
.inspector-raw {
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-size: 10.5px;
|
||||
background: var(--mc-bg-sunken, rgba(0, 0, 0, 0.04));
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.json-pane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
/* Responsive: collapse to a single column below 1100px so the editor
|
||||
can breathe on tablets, and stack everything vertically below 720px
|
||||
so the page is usable on a phone. The runs panel becomes a
|
||||
collapsible details element on small screens. */
|
||||
@media (max-width: 1100px) {
|
||||
.workflows-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.canvas-pane {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.canvas-inspector {
|
||||
max-height: 240px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.mc-page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.editor-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.editor-name,
|
||||
.editor-desc {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
}
|
||||
.editor-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.editor-body {
|
||||
min-height: 240px;
|
||||
}
|
||||
.canvas-pane {
|
||||
min-height: 320px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user