feat(workflow): 新增子流程节点功能

- 添加子流程节点相关的组件和逻辑
- 实现子流程节点的添加、编辑和删除
- 添加工作流列表获取和展示功能
- 优化工作流节点的渲染和交互
This commit is contained in:
csc 2025-05-09 00:51:22 +08:00
parent 989f627211
commit 06050b976c
10 changed files with 576 additions and 1 deletions

View File

@ -0,0 +1,164 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { type FormInst } from 'naive-ui';
import { useWorkflowStore } from '@/store/modules/workflow';
import { $t } from '@/locales';
import { failStrategyOptions, workFlowNodeStatusOptions } from '@/constants/business';
import EditableInput from '@/components/common/editable-input.vue';
defineOptions({
name: 'WorkflowDrawer'
});
interface Props {
modelValue?: Workflow.ConditionNodeType;
open?: boolean;
len?: number;
}
const props = withDefaults(defineProps<Props>(), {
open: false,
len: 0,
modelValue: () => ({})
});
interface Emits {
(e: 'update:open', open: boolean): void;
(e: 'save', form: Workflow.ConditionNodeType): void;
}
const emit = defineEmits<Emits>();
const store = useWorkflowStore();
const drawer = ref<boolean>(false);
const form = ref<Workflow.ConditionNodeType>({});
const workflowList = ref<Pick<Api.Workflow.Workflow, 'id' | 'workflowName'>[]>([]);
watch(
() => store.workflowList,
val => {
workflowList.value = val;
},
{ immediate: true }
);
watch(
() => props.open,
val => {
drawer.value = val;
},
{ immediate: true }
);
watch(
() => props.modelValue,
val => {
form.value = val;
},
{ immediate: true }
);
const formRef = ref<FormInst>();
const close = () => {
emit('update:open', false);
drawer.value = false;
};
const save = () => {
formRef.value
?.validate(errors => {
if (!errors) {
close();
emit('save', form.value);
}
})
.catch(() => window.$message?.warning('请检查表单信息'));
};
const rules = {
failStrategy: [{ required: true, message: '请选择失败策略' }],
workflowNodeStatus: [{ required: true, message: '请选择工作流状态' }],
subWorkflow: {
id: [{ required: true, message: '请选择' }]
}
};
const jobTaskChange = (_: string, option: { label: string; value: number }) => {
form.value.subWorkflow!.name = option.label;
};
</script>
<template>
<NDrawer v-model:show="drawer" display-directive="if" :width="500" @after-leave="close">
<NDrawerContent>
<template #header>
<div class="w-460px flex-center">
<EditableInput v-model="form.nodeName" class="mr-16px max-w-320px min-w-320px" />
<NSelect
v-model:value="form.priorityLevel"
class="max-w-110px"
:options="
Array(len)
.fill(0)
.map((_, index) => {
return {
label: '优先级 ' + (index + 1),
value: index + 1
};
})
"
/>
</div>
</template>
<NForm ref="formRef" :model="form" :rules="rules" label-align="left" label-width="100px">
<NFormItem path="subWorkflow.id" label="调用工作流" placeholder="请选择工作流">
<NSelect
v-model:value="form.subWorkflow!.id"
filterable
:options="
workflowList.map(item => {
return {
label: item.workflowName,
value: item.id
};
})
"
@update:value="jobTaskChange"
/>
</NFormItem>
<NFormItem path="failStrategy" label="失败策略">
<NRadioGroup v-model:value="form.failStrategy">
<NSpace>
<NRadio
v-for="(options, index) in failStrategyOptions"
:key="index"
:label="$t(options.label)"
:value="options.value"
/>
</NSpace>
</NRadioGroup>
</NFormItem>
<NFormItem path="workflowNodeStatus" label="状态">
<NRadioGroup v-model:value="form.workflowNodeStatus">
<NSpace>
<NRadio
v-for="(options, index) in workFlowNodeStatusOptions"
:key="index"
:label="$t(options.label)"
:value="options.value"
/>
</NSpace>
</NRadioGroup>
</NFormItem>
</NForm>
<template #footer>
<NButton type="primary" @click="save">保存</NButton>
<NButton class="ml-12px" @click="close">取消</NButton>
</template>
</NDrawerContent>
</NDrawer>
</template>

View File

@ -84,6 +84,26 @@ const addType = (type: number) => {
], ],
childNode: props.modelValue childNode: props.modelValue
}; };
} else if (type === 4) {
//
node = {
nodeName: $t('workflow.node.subWorkflow.nodeName'),
nodeType: type,
conditionNodes: [
{
nodeName: $t('workflow.node.subWorkflow.nodeName'),
failStrategy: 1,
priorityLevel: 1,
workflowNodeStatus: 1,
subWorkflow: {
id: undefined
}
}
],
childNode: props.modelValue
};
} else {
throw new Error(`type error:${type}`);
} }
emit('update:modelValue', node); emit('update:modelValue', node);
}; };
@ -118,6 +138,12 @@ const addType = (type: number) => {
</NButton> </NButton>
<p>{{ $t('workflow.node.callback.nodeName') }}</p> <p>{{ $t('workflow.node.callback.nodeName') }}</p>
</li> </li>
<li>
<NButton circle size="large" @click="addType(4)">
<icon-ant-design:fork class="text-20px color-#935af6" />
</NButton>
<p>{{ $t('workflow.node.subWorkflow.nodeName') }}</p>
</li>
</ul> </ul>
</div> </div>
</NPopover> </NPopover>

View File

@ -62,5 +62,16 @@ watch(
</template> </template>
</CallbackNode> </CallbackNode>
<WorkflowNode
v-if="nodeConfig.nodeType == 4"
v-model="nodeConfig"
:disabled="disabled"
@refresh="() => emit('refresh')"
>
<template #default="slot">
<NodeWrap v-if="slot.node" v-model="slot.node.childNode" :disabled="disabled" />
</template>
</WorkflowNode>
<NodeWrap v-if="nodeConfig.childNode" v-model="nodeConfig.childNode" :disabled="disabled" /> <NodeWrap v-if="nodeConfig.childNode" v-model="nodeConfig.childNode" :disabled="disabled" />
</template> </template>

View File

@ -51,6 +51,7 @@ watch(
val => { val => {
if (val) { if (val) {
store.setJobList(val); store.setJobList(val);
store.setWorkflowList(val);
} }
}, },
{ immediate: true } { immediate: true }

View File

@ -0,0 +1,324 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue';
import { useMessage } from 'naive-ui';
import { fetchNodeRetry, fetchNodeStop } from '@/service/api';
import { $t } from '@/locales';
import { useWorkflowStore } from '@/store/modules/workflow';
import { failStrategyRecord, taskBatchStatusEnum } from '@/constants/business';
import TaskDetail from '../detail/task-detail.vue';
import DetailCard from '../common/detail-card.vue';
import AddNode from './add-node.vue';
defineOptions({
name: 'WorkflowNode'
});
interface Props {
modelValue?: Workflow.NodeModelType;
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
modelValue: () => ({})
});
interface Emits {
(e: 'refresh'): void;
(e: 'update:modelValue', modelValue: Workflow.NodeModelType): void;
}
const emit = defineEmits<Emits>();
const store = useWorkflowStore();
const message = useMessage();
const nodeConfig = ref<Workflow.NodeModelType>({});
watch(
() => props.modelValue,
val => {
nodeConfig.value = val;
},
{ immediate: true }
);
const addTerm = () => {
const len = nodeConfig.value.conditionNodes!.length + 1;
nodeConfig.value.conditionNodes?.push({
nodeName: `${$t('workflow.node.subWorkflow.nodeName')} ${len}`,
priorityLevel: len,
failStrategy: 1,
workflowNodeStatus: 1,
subWorkflow: { id: undefined }
} as Workflow.ConditionNodeType);
emit('update:modelValue', nodeConfig.value);
};
const reData = (data: Workflow.NodeModelType, addData: Workflow.NodeModelType) => {
if (!data.childNode) {
data.childNode = addData;
} else {
reData(data.childNode, addData);
}
};
const delTerm = (currentIndex: number) => {
if (nodeConfig.value.conditionNodes!.length === 1) {
if (nodeConfig.value.childNode) {
if (nodeConfig.value.conditionNodes![0].childNode) {
reData(nodeConfig.value.conditionNodes![0].childNode, nodeConfig.value.childNode);
} else {
nodeConfig.value.conditionNodes![0].childNode = nodeConfig.value.childNode;
}
}
nextTick(() => {
emit('update:modelValue', nodeConfig.value.conditionNodes![0].childNode!);
});
} else {
nodeConfig.value.conditionNodes?.splice(currentIndex, 1);
}
};
const arrTransfer = (index: number, type: number = 1) => {
nodeConfig.value.conditionNodes![index] = nodeConfig.value.conditionNodes!.splice(
index + type,
1,
nodeConfig.value.conditionNodes![index]
)[0];
nodeConfig.value.conditionNodes?.map((item, i) => (item.priorityLevel = i + 1));
emit('update:modelValue', nodeConfig.value);
};
const index = ref<number>(0);
const drawer = ref<boolean>(false);
const form = ref<Workflow.ConditionNodeType>({});
const save = (val: Workflow.ConditionNodeType) => {
const oldLevel = nodeConfig.value.conditionNodes![index.value].priorityLevel;
const newLevel = val.priorityLevel;
nodeConfig.value.conditionNodes![index.value] = val;
if (oldLevel !== newLevel) {
arrTransfer(index.value, newLevel! - oldLevel!);
}
emit('update:modelValue', nodeConfig.value);
};
const show = (currentIndex: number) => {
if (!props.disabled) {
index.value = currentIndex;
form.value = JSON.parse(JSON.stringify(nodeConfig.value.conditionNodes![currentIndex]));
drawer.value = true;
}
};
const getClass = (item: Workflow.ConditionNodeType) => {
if (props.disabled) {
if (store.type === 2) {
return `node-error node-error-${
item.taskBatchStatus && taskBatchStatusEnum[item.taskBatchStatus]
? taskBatchStatusEnum[item.taskBatchStatus].name
: 'default'
}`;
}
return 'node-error';
}
return 'auto-judge-def auto-judge-hover';
};
const detailId = ref<string>();
const detailIds = ref<string[]>();
const cardDrawer = ref(false);
const detailDrawer = ref<boolean[]>([]);
const showDetail = (node: Workflow.ConditionNodeType, detailIndex: number) => {
detailIds.value = [];
if (store.type === 4) {
node.jobBatchList
?.sort((a, b) => a.taskBatchStatus - b.taskBatchStatus)
.forEach(item => {
if (item.id) {
detailIds.value?.push(item.id);
} else if (item.jobId) {
detailId.value = item.jobId?.toString();
}
});
if (node.jobTask?.jobId) {
detailId.value = node.jobTask?.jobId?.toString();
}
cardDrawer.value = true;
} else if (store.type === 1) {
detailDrawer.value[detailIndex] = true;
} else {
show(detailIndex);
}
};
const retry = async (node: Workflow.ConditionNodeType) => {
const { error } = await fetchNodeRetry(node.id!, store.id!);
if (!error) {
message.success('执行重试成功');
emit('refresh');
}
};
const stop = async (node: Workflow.ConditionNodeType) => {
const { error } = await fetchNodeStop(node.id!, store.id!);
if (!error) {
message.success('停止任务成功');
emit('refresh');
}
};
const isRetry = (taskBatchStatus: number) => {
return taskBatchStatus === 4 || taskBatchStatus === 5 || taskBatchStatus === 6;
};
const isStop = (taskBatchStatus: number) => {
return taskBatchStatus === 1 || taskBatchStatus === 2;
};
const isShow = (taskBatchStatus: number) => {
return isRetry(taskBatchStatus!) || isStop(taskBatchStatus!);
};
</script>
<template>
<div class="node-wrap">
<div class="branch-box">
<NButton v-if="!disabled" class="add-branch" strong type="success" @click="addTerm">
{{ $t('workflow.node.task.add') }}
</NButton>
<div v-for="(item, i) in nodeConfig.conditionNodes" :key="i" class="col-box">
<div class="condition-node">
<div class="condition-node-box">
<NPopover :disabled="store.type !== 2 || !isShow(item.taskBatchStatus!)">
<div class="popover">
<NButton v-if="isRetry(item.taskBatchStatus!)" text @click="retry(item!)">
<span class="popover-item">
<icon-ant-design:redo-outlined class="mb-3px text-24px font-bold" />
{{ $t('common.retry') }}
</span>
</NButton>
<NDivider v-if="isStop(item.taskBatchStatus!) && isRetry(item.taskBatchStatus!)" vertical />
<NButton v-if="isStop(item.taskBatchStatus!)" text @click="stop(item!)">
<span class="popover-item">
<icon-ant-design:stop-outlined />
{{ $t('common.stop') }}
</span>
</NButton>
</div>
<template #trigger>
<div class="auto-judge cursor-pointer" :class="getClass(item)" @click="showDetail(item!, i)">
<div v-if="i != 0" class="sort-left" @click.stop="arrTransfer(i, -1)">
<icon-ant-design:left-outlined />
</div>
<div class="title">
<span class="text color-#3296fa">
<NBadge processing dot :color="item.workflowNodeStatus === 1 ? '#52c41a' : '#ff4d4f'" />
&nbsp;{{ item.nodeName }}
<span v-if="item.id">&nbsp;({{ item.id }})</span>
</span>
<span class="priority-title">{{ $t('workflow.node.priority') }}{{ item.priorityLevel }}</span>
<icon-ant-design:close-outlined v-if="!disabled" class="close" @click.stop="delTerm(i)" />
</div>
<div class="content min-h-72px">
<div v-if="!item.subWorkflow?.id" class="placeholder">请选择工作流</div>
<template v-if="item.subWorkflow?.id">
<div>
<span class="content_label">工作流名称:&nbsp;</span>
<NEllipsis class="max-w-123px">
{{ `${item.subWorkflow?.name}(${item.subWorkflow?.id})` }}
</NEllipsis>
</div>
<div>
<span class="content_label">失败策略:&nbsp;</span>
{{ $t(failStrategyRecord[item.failStrategy!]) }}
</div>
<div>.........</div>
</template>
</div>
<div
v-if="i != nodeConfig.conditionNodes!.length - 1"
class="sort-right"
@click.stop="arrTransfer(i)"
>
<icon-ant-design:right-outlined />
</div>
</div>
</template>
</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>
</div>
</div>
<slot v-if="item.childNode" :node="item"></slot>
<div v-if="i == 0" class="top-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="bottom-right-cover-line"></div>
<TaskDetail
v-if="store.type !== 0"
v-model:open="detailDrawer[i]"
:model-value="nodeConfig.conditionNodes![i]"
/>
</div>
</div>
<AddNode v-if="nodeConfig.conditionNodes!.length > 1" v-model="nodeConfig.childNode!" :disabled="disabled" />
<WorkflowDrawer
v-if="store.type === 0"
v-model:open="drawer"
v-model="form"
v-model:len="nodeConfig.conditionNodes!.length"
@save="save"
/>
<DetailCard v-if="store.type !== 0" :id="detailId" v-model:show="cardDrawer" :ids="detailIds" />
</div>
</template>
<style scoped lang="scss">
.task-error-tip {
cursor: default;
position: absolute;
top: 63px;
left: 291px;
font-size: 24px;
}
.popover {
display: flex;
align-items: center;
justify-content: space-around;
.popover-item {
height: 42px;
display: flex;
font-size: 13px;
align-items: center;
justify-content: center;
flex-direction: column;
text-align: center;
span {
margin-inline-start: 0;
}
.anticon {
font-size: 22px;
justify-content: center;
align-items: center;
}
}
}
</style>

View File

@ -985,6 +985,14 @@ const local: App.I18n.Schema = {
webhookTip: 'Please configure callback notifications' webhookTip: 'Please configure callback notifications'
} }
}, },
subWorkflow: {
nodeName: 'Workflow',
conditionNodes: {
nodeName: 'Workflow',
contentType: 'Workflow',
webhookTip: 'Workflow'
}
},
endNode: 'End Node', endNode: 'End Node',
log: { log: {
title: 'Log Detail' title: 'Log Detail'

View File

@ -991,6 +991,14 @@ const local: App.I18n.Schema = {
webhookTip: '请配置回调通知' webhookTip: '请配置回调通知'
} }
}, },
subWorkflow: {
nodeName: '工作流',
conditionNodes: {
nodeName: '工作流',
contentType: '工作流',
webhookTip: '工作流'
}
},
endNode: '流程结束', endNode: '流程结束',
log: { log: {
title: '日志详情' title: '日志详情'

View File

@ -1,6 +1,6 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref } from 'vue'; import { ref } from 'vue';
import { fetchGetJobList } from '@/service/api'; import { fetchGetJobList, fetchGetWorkflowNameList } from '@/service/api';
import { SetupStoreId } from '@/enum'; import { SetupStoreId } from '@/enum';
export const useWorkflowStore = defineStore(SetupStoreId.Workflow, () => { export const useWorkflowStore = defineStore(SetupStoreId.Workflow, () => {
@ -8,6 +8,7 @@ export const useWorkflowStore = defineStore(SetupStoreId.Workflow, () => {
const type = ref<number>(); const type = ref<number>();
const groupName = ref<string>(); const groupName = ref<string>();
const jobList = ref<Pick<Api.Job.Job, 'id' | 'jobName'>[]>([]); const jobList = ref<Pick<Api.Job.Job, 'id' | 'jobName'>[]>([]);
const workflowList = ref<Pick<Api.Workflow.Workflow, 'id' | 'workflowName'>[]>([]);
function setId(value: string) { function setId(value: string) {
id.value = value; id.value = value;
@ -30,6 +31,19 @@ export const useWorkflowStore = defineStore(SetupStoreId.Workflow, () => {
} }
} }
async function setWorkflowList(value: string) {
groupName.value = value;
const { data, error } = await fetchGetWorkflowNameList({ groupName: value });
if (!error) {
workflowList.value = data.map(item => {
return {
id: item.id!,
workflowName: item.workflowName
};
});
}
}
function clear() { function clear() {
id.value = undefined; id.value = undefined;
type.value = undefined; type.value = undefined;
@ -42,7 +56,9 @@ export const useWorkflowStore = defineStore(SetupStoreId.Workflow, () => {
type, type,
groupName, groupName,
jobList, jobList,
workflowList,
setJobList, setJobList,
setWorkflowList,
setType, setType,
setId, setId,
clear clear

View File

@ -1194,6 +1194,14 @@ declare namespace App {
webhookTip: string; webhookTip: string;
}; };
}; };
subWorkflow: {
nodeName: string;
conditionNodes: {
nodeName: string;
contentType: string;
webhookTip: string;
};
};
endNode: string; endNode: string;
log: { log: {
title: string; title: string;

View File

@ -76,6 +76,8 @@ declare namespace Workflow {
callback?: CallbackNodeType; callback?: CallbackNodeType;
/** 子节点 */ /** 子节点 */
childNode?: NodeModelType; childNode?: NodeModelType;
/** 子流程 */
subWorkflow?: WorkflowNodeType;
}; };
/** 任务节点 */ /** 任务节点 */
@ -85,6 +87,13 @@ declare namespace Workflow {
/** 任务名称 */ /** 任务名称 */
jobName?: string; jobName?: string;
}; };
/** 子流程 */
type WorkflowNodeType = {
/** 任务ID */
id?: string;
/** 任务名称 */
name?: string;
};
/** 条件节点 */ /** 条件节点 */
type BrachNodeType = { type BrachNodeType = {