feat: 工作流新增任务详情抽屉

This commit is contained in:
xlsea 2024-05-25 16:20:57 +08:00
parent 9b79d41ecb
commit d631ff2b5e
16 changed files with 239 additions and 94 deletions

4
.env
View File

@ -4,6 +4,8 @@ VITE_APP_DESC=A flexible, reliable, and fast platform for distributed task retry
VITE_APP_VERSION=1.0.0-beta2 VITE_APP_VERSION=1.0.0-beta2
VITE_APP_DEFAULT_TOKEN=SJ_Wyz3dmsdbDOkDujOTSSoBjGQP1BMsVnj
# the prefix of the icon name # the prefix of the icon name
VITE_ICON_PREFIX=icon VITE_ICON_PREFIX=icon
@ -46,3 +48,5 @@ VITE_SOURCE_MAP=N
# Used to differentiate storage across different domains # Used to differentiate storage across different domains
VITE_STORAGE_PREFIX= VITE_STORAGE_PREFIX=

View File

@ -1,7 +1,7 @@
VITE_BASE_URL=/ VITE_BASE_URL=/
# backend service base url, test environment # backend service base url, test environment
VITE_SERVICE_BASE_URL=http://localhost:8080/snail-job VITE_SERVICE_BASE_URL=http://preview.easyretry.com/snail-job
# other backend service base url, test environment # other backend service base url, test environment
VITE_OTHER_SERVICE_BASE_URL= `{ VITE_OTHER_SERVICE_BASE_URL= `{

View File

@ -53,3 +53,10 @@ export function fetchWorkflowInfo(id: string) {
method: 'get' method: 'get'
}); });
} }
export function fetchWorkflowBatchInfo(id: string) {
return request<Flow.NodeDataType>({
url: `/workflow/batch/${id}`,
method: 'get'
});
}

View File

@ -76,8 +76,8 @@ const show = () => {
<template> <template>
<div class="node-wrap"> <div class="node-wrap">
<div <div
:class="disabled ? 'start-node-disabled' : 'node-wrap-box-hover'" :class="`${disabled ? 'start-node-disabled' : 'node-wrap-box-hover'} ${store.type === 2 ? 'node-error-success' : ''}`"
class="node-wrap-box node-error-success start-node" class="node-wrap-box start-node"
@click="show" @click="show"
> >
<div class="title"> <div class="title">
@ -102,15 +102,13 @@ const show = () => {
<div v-else class="content min-h-85px"> <div v-else class="content min-h-85px">
<span class="placeholder">{{ $t('snail.form.workflowTip') }}</span> <span class="placeholder">{{ $t('snail.form.workflowTip') }}</span>
</div> </div>
<NTooltip v-if="store.type === 2" trigger="hover"> <NTooltip v-if="store.type === 2">
<template #trigger>{{ taskBatchStatusEnum[3].title }}</template> <template #trigger>
<SvgIcon <div class="error-tip text-24px" :style="{ color: taskBatchStatusEnum[3].color }">
class="error-tip" <SvgIcon :icon="taskBatchStatusEnum[3].icon" />
:color="taskBatchStatusEnum[3].color" </div>
size="24px" </template>
:icon="taskBatchStatusEnum[3].icon" {{ taskBatchStatusEnum[3].title }}
@click.stop="() => {}"
/>
</NTooltip> </NTooltip>
</div> </div>
<AddNode v-model="nodeData.nodeConfig!" :disabled="disabled"></AddNode> <AddNode v-model="nodeData.nodeConfig!" :disabled="disabled"></AddNode>

View File

@ -6,6 +6,7 @@ import { useFlowStore } from '../stores';
import { $t } from '../locales'; import { $t } from '../locales';
import { failStrategyRecord, taskBatchStatusEnum } from '../constants/business'; import { failStrategyRecord, taskBatchStatusEnum } from '../constants/business';
import TaskDrawer from '../drawer/task-drawer.vue'; import TaskDrawer from '../drawer/task-drawer.vue';
import TaskDetail from '../detail/task-detail.vue';
import AddNode from './add-node.vue'; import AddNode from './add-node.vue';
defineOptions({ defineOptions({
@ -31,7 +32,6 @@ const emit = defineEmits<Emits>();
const store = useFlowStore(); const store = useFlowStore();
const message = useMessage(); const message = useMessage();
const nodeConfig = ref<Flow.NodeModelType>({}); const nodeConfig = ref<Flow.NodeModelType>({});
const popoverVisible = ref<Record<number, boolean>>({});
watch( watch(
() => props.modelValue, () => props.modelValue,
@ -186,15 +186,13 @@ const isStop = (taskBatchStatus: number) => {
<div v-for="(item, i) in nodeConfig.conditionNodes" :key="i" class="col-box"> <div v-for="(item, i) in nodeConfig.conditionNodes" :key="i" class="col-box">
<div class="condition-node"> <div class="condition-node">
<div class="condition-node-box"> <div class="condition-node-box">
<NPopover <NPopover :disabled="store.type !== 2">
:show="popoverVisible[i] && store.type === 2"
@update:show="(visible: boolean) => (popoverVisible[i] = visible)"
>
<div class="popover"> <div class="popover">
<NDivider v-if="isRetry(item.taskBatchStatus!)" vertical /> <NButton v-if="isRetry(item.taskBatchStatus!)" text @click="retry(item!)">
<NButton v-if="isRetry(item.taskBatchStatus!)" text class="popover-item" @click="retry(item!)"> <span class="popover-item">
<icon-ant-design:redo-outlined /> <icon-ant-design:redo-outlined class="mb-3px text-24px font-bold" />
<span>{{ $t('snail.retry') }}</span> {{ $t('snail.retry') }}
</span>
</NButton> </NButton>
<NDivider v-if="isStop(item.taskBatchStatus!)" vertical /> <NDivider v-if="isStop(item.taskBatchStatus!)" vertical />
<NButton v-if="isStop(item.taskBatchStatus!)" text class="popover-item" @click="stop(item!)"> <NButton v-if="isStop(item.taskBatchStatus!)" text class="popover-item" @click="stop(item!)">
@ -238,19 +236,20 @@ const isStop = (taskBatchStatus: number) => {
> >
<icon-ant-design:right-outlined /> <icon-ant-design:right-outlined />
</div> </div>
<NTooltip v-if="store.type === 2 && item.taskBatchStatus" trigger="hover">
<template #trigger>{{ taskBatchStatusEnum[item.taskBatchStatus].title }}</template>
<SvgIcon
class="error-tip"
:color="taskBatchStatusEnum[item.taskBatchStatus].color"
size="24px"
:icon="taskBatchStatusEnum[item.taskBatchStatus].icon"
@click.stop="() => {}"
/>
</NTooltip>
</div> </div>
</template> </template>
</NPopover> </NPopover>
<NTooltip v-if="store.type === 2 && item.taskBatchStatus">
<template #trigger>
<div
class="task-error-tip text-24px"
:style="{ color: taskBatchStatusEnum[item.taskBatchStatus!].color }"
>
<SvgIcon :icon="taskBatchStatusEnum[item.taskBatchStatus!].icon" />
</div>
</template>
{{ taskBatchStatusEnum[item.taskBatchStatus!].title }}
</NTooltip>
<AddNode v-model="item.childNode!" :disabled="disabled"></AddNode> <AddNode v-model="item.childNode!" :disabled="disabled"></AddNode>
</div> </div>
</div> </div>
@ -259,13 +258,8 @@ const isStop = (taskBatchStatus: number) => {
<div v-if="i == 0" class="bottom-left-cover-line"></div> <div v-if="i == 0" class="bottom-left-cover-line"></div>
<div v-if="i == nodeConfig.conditionNodes!.length - 1" class="top-right-cover-line"></div> <div v-if="i == nodeConfig.conditionNodes!.length - 1" class="top-right-cover-line"></div>
<div v-if="i == nodeConfig.conditionNodes!.length - 1" class="bottom-right-cover-line"></div> <div v-if="i == nodeConfig.conditionNodes!.length - 1" class="bottom-right-cover-line"></div>
<!--
<TaskDetail <TaskDetail v-if="store.type !== 0" v-model:open="detailDrawer[i]" v-model="nodeConfig.conditionNodes![i]" />
v-if="store.type !== 0 && detailDrawer[i]"
v-model:open="detailDrawer[i]"
v-model="nodeConfig.conditionNodes![i]"
/>
-->
</div> </div>
</div> </div>
<AddNode v-if="nodeConfig.conditionNodes!.length > 1" v-model="nodeConfig.childNode!" :disabled="disabled" /> <AddNode v-if="nodeConfig.conditionNodes!.length > 1" v-model="nodeConfig.childNode!" :disabled="disabled" />
@ -282,6 +276,14 @@ const isStop = (taskBatchStatus: number) => {
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.task-error-tip {
cursor: default;
position: absolute;
top: 63px;
left: 291px;
font-size: 24px;
}
.popover { .popover {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -36,14 +36,14 @@ export const contentTypeRecord: Record<Flow.ContentType, string> = {
2: 'application/x-www-form-urlencoded' 2: 'application/x-www-form-urlencoded'
}; };
export const triggerTypeRecord: Record<Flow.TriggerType, string> = { export const triggerTypeRecord: Record<Flow.TriggerType, FlowI18n.I18nKey> = {
2: '固定时间', 2: 'snail.enum.triggerType.time',
3: 'CRON表达式' 3: 'snail.enum.triggerType.cron'
}; };
export const triggerTypeOptions = transformRecordToOption(triggerTypeRecord); export const triggerTypeOptions = transformRecordToOption(triggerTypeRecord);
export const workFlowNodeStatusRecord: Record<Flow.WorkFlowNodeStatus, string> = { export const workFlowNodeStatusRecord: Record<Flow.WorkFlowNodeStatus, FlowI18n.I18nKey> = {
0: 'snail.enum.workFlowNodeStatus.close', 0: 'snail.enum.workFlowNodeStatus.close',
1: 'snail.enum.workFlowNodeStatus.open' 1: 'snail.enum.workFlowNodeStatus.open'
}; };

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { $t } from '../locales';
import { blockStrategyRecord, triggerTypeRecord, workFlowNodeStatusRecord } from '../constants/business'; import { blockStrategyRecord, triggerTypeRecord, workFlowNodeStatusRecord } from '../constants/business';
defineOptions({ defineOptions({
@ -38,19 +39,19 @@ const onClose = () => {
</script> </script>
<template> <template>
<NDrawer v-model:open="visible" placement="right" :width="500" display-directive="if" @close="onClose"> <NDrawer v-model:show="visible" placement="right" :width="500" display-directive="if" @after-leave="onClose">
<NDrawerContent title="工作流详情"> <NDrawerContent title="工作流详情">
<NDescriptions :column="1" bordered :label-style="{ width: '120px' }"> <NDescriptions :column="1" bordered :label-style="{ width: '120px' }">
<NDescriptionsItem label="工作流名称">{{ modelValue.workflowName }}</NDescriptionsItem> <NDescriptionsItem label="工作流名称">{{ modelValue.workflowName }}</NDescriptionsItem>
<NDescriptionsItem label="组名称">{{ modelValue.groupName }}</NDescriptionsItem> <NDescriptionsItem label="组名称">{{ modelValue.groupName }}</NDescriptionsItem>
<NDescriptionsItem label="触发类型">{{ triggerTypeRecord[modelValue.triggerType!] }}</NDescriptionsItem> <NDescriptionsItem label="触发类型">{{ $t(triggerTypeRecord[modelValue.triggerType!]) }}</NDescriptionsItem>
<NDescriptionsItem label="触发间隔"> <NDescriptionsItem label="触发间隔">
{{ modelValue.triggerInterval }} {{ modelValue.triggerType === 2 ? '秒' : null }} {{ modelValue.triggerInterval }} {{ modelValue.triggerType === 2 ? '秒' : null }}
</NDescriptionsItem> </NDescriptionsItem>
<NDescriptionsItem label="执行超时时间">{{ modelValue.executorTimeout }} </NDescriptionsItem> <NDescriptionsItem label="执行超时时间">{{ modelValue.executorTimeout }} </NDescriptionsItem>
<NDescriptionsItem label="阻塞策略">{{ blockStrategyRecord[modelValue.blockStrategy!] }}</NDescriptionsItem> <NDescriptionsItem label="阻塞策略">{{ blockStrategyRecord[modelValue.blockStrategy!] }}</NDescriptionsItem>
<NDescriptionsItem label="工作流状态"> <NDescriptionsItem label="工作流状态">
{{ workFlowNodeStatusRecord[modelValue.workflowStatus!] }} {{ $t(workFlowNodeStatusRecord[modelValue.workflowStatus!]) }}
</NDescriptionsItem> </NDescriptionsItem>
</NDescriptions> </NDescriptions>
</NDrawerContent> </NDrawerContent>

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { $t } from '../locales';
import { useFlowStore } from '../stores';
import { failStrategyRecord, workFlowNodeStatusRecord } from '../constants/business';
defineOptions({
name: 'TaskDetail'
});
interface Props {
modelValue?: Flow.ConditionNodeType;
open?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
open: false,
modelValue: () => ({})
});
interface Emits {
(e: 'update:open', open: boolean): void;
}
const emit = defineEmits<Emits>();
const store = useFlowStore();
const visible = ref(false);
watch(
() => props.open,
val => {
visible.value = val;
},
{ immediate: true }
);
const onClose = () => {
emit('update:open', false);
};
const getTaskName = (id: string) => {
const jobList: { id?: string; jobName?: string }[] = store.jobList;
return jobList.find(item => item.id === id)?.jobName;
};
</script>
<template>
<NDrawer v-model:show="visible" placement="right" :width="500" display-directive="if" @after-leave="onClose">
<NDrawerContent title="工作流详情">
<NDescriptions :column="1" bordered :label-style="{ width: '120px' }">
<NDescriptionsItem label="节点名称">{{ modelValue.nodeName }}</NDescriptionsItem>
<NDescriptionsItem label="任务 ID">{{ modelValue.jobTask?.jobId }}</NDescriptionsItem>
<NDescriptionsItem label="任务名称">{{ getTaskName(modelValue.jobTask?.jobId!) }}</NDescriptionsItem>
<NDescriptionsItem label="失败策略">
{{ $t(failStrategyRecord[modelValue.failStrategy!]) }}
</NDescriptionsItem>
<NDescriptionsItem label="工作流状态">
{{ $t(workFlowNodeStatusRecord[modelValue.workflowNodeStatus!]) }}
</NDescriptionsItem>
</NDescriptions>
</NDrawerContent>
</NDrawer>
</template>
<style scoped lang="scss"></style>

View File

@ -34,7 +34,9 @@ export const useFlowStore = defineStore('workflow', () => {
if (!error) { if (!error) {
jobList.value = data; jobList.value = data;
} }
const workflow = localStg.get('workflow');
localStg.set('workflow', { localStg.set('workflow', {
...workflow,
groupName: value, groupName: value,
jobList: data! jobList: data!
}); });

View File

@ -53,10 +53,11 @@
border-color: rgb(202, 202, 202) transparent transparent; border-color: rgb(202, 202, 202) transparent transparent;
} }
.node-error,
.start-node-disabled { .start-node-disabled {
// cursor: default; // cursor: default;
border: 1px solid #00000036; border: 1px solid #00000036 !important;
box-shadow: 0 2px 5px #00000036; box-shadow: 0 2px 5px #00000036 !important;
} }
.node-wrap-box.start-node:before { .node-wrap-box.start-node:before {
@ -521,8 +522,8 @@
cursor: default; cursor: default;
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; left: 0;
transform: translate(150%); transform: translate(960%);
font-size: 24px; font-size: 24px;
} }
} }

View File

@ -41,9 +41,6 @@ importers:
'@sa/utils': '@sa/utils':
specifier: workspace:* specifier: workspace:*
version: link:packages/utils version: link:packages/utils
'@sa/workflow':
specifier: workspace:*
version: link:packages/work-flow
'@vueuse/core': '@vueuse/core':
specifier: 10.9.0 specifier: 10.9.0
version: 10.9.0(vue@3.4.26(typescript@5.4.5)) version: 10.9.0(vue@3.4.26(typescript@5.4.5))
@ -158,7 +155,7 @@ importers:
version: 5.4.5 version: 5.4.5
unplugin-icons: unplugin-icons:
specifier: 0.19.0 specifier: 0.19.0
version: 0.19.0(@vue/compiler-sfc@3.4.26)(vue-template-compiler@2.7.16) version: 0.19.0(@vue/compiler-sfc@3.4.25)(vue-template-compiler@2.7.16)
unplugin-vue-components: unplugin-vue-components:
specifier: 0.27.0 specifier: 0.27.0
version: 0.27.0(@babel/parser@7.24.4)(rollup@4.17.0)(vue@3.4.26(typescript@5.4.5)) version: 0.27.0(@babel/parser@7.24.4)(rollup@4.17.0)(vue@3.4.26(typescript@5.4.5))
@ -294,8 +291,6 @@ importers:
specifier: 4.2.2 specifier: 4.2.2
version: 4.2.2 version: 4.2.2
packages/work-flow: {}
packages: packages:
'@ampproject/remapping@2.3.0': '@ampproject/remapping@2.3.0':
@ -9770,7 +9765,7 @@ snapshots:
universalify@2.0.1: {} universalify@2.0.1: {}
unplugin-icons@0.19.0(@vue/compiler-sfc@3.4.26)(vue-template-compiler@2.7.16): unplugin-icons@0.19.0(@vue/compiler-sfc@3.4.25)(vue-template-compiler@2.7.16):
dependencies: dependencies:
'@antfu/install-pkg': 0.3.3 '@antfu/install-pkg': 0.3.3
'@antfu/utils': 0.7.7 '@antfu/utils': 0.7.7
@ -9780,7 +9775,7 @@ snapshots:
local-pkg: 0.5.0 local-pkg: 0.5.0
unplugin: 1.10.1 unplugin: 1.10.1
optionalDependencies: optionalDependencies:
'@vue/compiler-sfc': 3.4.26 '@vue/compiler-sfc': 3.4.25
vue-template-compiler: 2.7.16 vue-template-compiler: 2.7.16
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color

View File

@ -1,19 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import Workflow, { flowFetch, flowStores } from '@sa/workflow';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import Workflow, { flowFetch, flowStores } from '@sa/workflow';
import { $t } from '@/locales'; import { $t } from '@/locales';
const store = flowStores.useFlowStore(); const store = flowStores.useFlowStore();
const router = useRouter(); const router = useRouter();
const spinning = ref(false);
const disabled = ref(false);
onMounted(() => { onMounted(() => {
store.clear(); store.clear();
store.setType(0); store.setType(0);
disabled.value = false;
}); });
const node = ref<Flow.NodeDataType>({ const node = ref<Flow.NodeDataType>({
@ -38,7 +34,7 @@ const cancel = () => {
</script> </script>
<template> <template>
<Workflow v-model="node" :spinning="spinning" :disabled="disabled" @save="save" @cancel="cancel" /> <Workflow v-model="node" @save="save" @cancel="cancel" />
</template> </template>
<style scoped></style> <style scoped></style>

View File

@ -1,17 +1,35 @@
<script setup lang="ts"> <script setup lang="ts">
import WorkFlowIframe from '../modules/workflow-iframe.vue'; import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import Workflow, { flowFetch, flowStores } from '@sa/workflow';
const store = flowStores.useFlowStore();
const route = useRoute();
defineOptions({ const spinning = ref(false);
name: 'WorkFlowDetail'
const id: string = String(route.query.id);
const node = ref<Flow.NodeDataType>({});
const getBatchDetail = async () => {
spinning.value = true;
const { data, error } = await flowFetch.fetchWorkflowBatchInfo(id);
if (!error) {
node.value = data;
}
spinning.value = false;
};
onMounted(() => {
store.clear();
store.setType(2);
store.setId(id);
getBatchDetail();
}); });
</script> </script>
<template> <template>
<div class="iframe"><WorkFlowIframe value="xkjIc2ZHZ0" /></div> <Workflow v-model="node" :spinning="spinning" disabled />
</template> </template>
<style scoped> <style scoped></style>
.iframe {
padding: 0 !important;
}
</style>

View File

@ -1,17 +1,55 @@
<script setup lang="ts"> <script setup lang="ts">
import WorkFlowIframe from '../modules/workflow-iframe.vue'; import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import Workflow, { flowFetch, flowStores } from '@sa/workflow';
import { $t } from '@/locales';
defineOptions({ const store = flowStores.useFlowStore();
name: 'WorkFlowCopy' const route = useRoute();
const router = useRouter();
const spinning = ref(false);
const id: string = String(route.query.id);
const node = ref<Flow.NodeDataType>({
workflowName: `Workflow ${new Date().getTime()}`,
workflowStatus: 1,
blockStrategy: 1,
description: undefined,
executorTimeout: 60
}); });
const getDetail = async () => {
spinning.value = true;
const { data, error } = await flowFetch.fetchWorkflowInfo(id);
if (!error) {
node.value = data;
}
spinning.value = false;
};
onMounted(() => {
store.clear();
store.setType(0);
getDetail();
});
const save = async () => {
const { error } = await flowFetch.fetchAddWorkflow(node.value);
if (!error) {
window.$message?.info($t('common.addSuccess'));
router.push('/workflow/task');
}
};
const cancel = () => {
router.push('/workflow/task');
};
</script> </script>
<template> <template>
<div class="iframe"><WorkFlowIframe value="wA4wN1nZ" /></div> <Workflow v-model="node" :spinning="spinning" @save="save" @cancel="cancel" />
</template> </template>
<style scoped> <style scoped></style>
.iframe {
padding: 0 !important;
}
</style>

View File

@ -1,17 +1,36 @@
<script setup lang="ts"> <script setup lang="ts">
import WorkFlowIframe from '../modules/workflow-iframe.vue'; import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import Workflow, { flowFetch, flowStores } from '@sa/workflow';
defineOptions({ const store = flowStores.useFlowStore();
name: 'WorkFlowDetail' const route = useRoute();
const spinning = ref(false);
const id: string = String(route.query.id);
const node = ref<Flow.NodeDataType>({});
const getDetail = async () => {
spinning.value = true;
const { data, error } = await flowFetch.fetchWorkflowInfo(id);
if (!error) {
node.value = data;
}
spinning.value = false;
};
onMounted(() => {
store.clear();
store.setType(1);
store.setId(id);
getDetail();
}); });
</script> </script>
<template> <template>
<div class="iframe"><WorkFlowIframe value="kaxC8Iml" /></div> <Workflow v-model="node" :spinning="spinning" disabled />
</template> </template>
<style scoped> <style scoped></style>
.iframe {
padding: 0 !important;
}
</style>

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import Workflow, { flowFetch, flowStores } from '@sa/workflow';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import Workflow, { flowFetch, flowStores } from '@sa/workflow';
import { $t } from '@/locales'; import { $t } from '@/locales';
const store = flowStores.useFlowStore(); const store = flowStores.useFlowStore();
@ -9,7 +9,6 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const spinning = ref(false); const spinning = ref(false);
const disabled = ref(false);
const id: string = String(route.query.id); const id: string = String(route.query.id);
@ -29,7 +28,6 @@ onMounted(() => {
store.setType(0); store.setType(0);
store.setId(id); store.setId(id);
getDetail(); getDetail();
disabled.value = false;
}); });
const update = async () => { const update = async () => {
@ -46,7 +44,7 @@ const cancel = () => {
</script> </script>
<template> <template>
<Workflow v-model="node" :spinning="spinning" :disabled="disabled" @save="update" @cancel="cancel" /> <Workflow v-model="node" :spinning="spinning" @save="update" @cancel="cancel" />
</template> </template>
<style scoped></style> <style scoped></style>