feat: 支持工作流插件复用与试运行

- 新增工作流插件类型、发布快照同步、实时可用性与下线影响检查

- 收口绑定候选、分类权限、间接环路校验与运行态优雅降级

- 补齐管理端工作流插件配置、详情与试运行界面及定向测试
This commit is contained in:
2026-04-12 13:15:13 +08:00
parent 6da90e2296
commit 47655a728b
57 changed files with 4018 additions and 780 deletions

View File

@@ -4,11 +4,12 @@ import type { FormInstance } from 'element-plus';
import { ref } from 'vue';
import { Position } from '@element-plus/icons-vue';
import { ElButton, ElForm, ElFormItem, ElInput, ElMessage } from 'element-plus';
import { ElButton, ElForm, ElFormItem, ElMessage } from 'element-plus';
import { api } from '#/api/request';
import ShowJson from '#/components/json/ShowJson.vue';
import { $t } from '#/locales';
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
interface Props {
workflowId: any;
@@ -47,19 +48,10 @@ function submit() {
<template>
<div>
<ElForm label-position="top" ref="singleRunForm" :model="runParams">
<ElFormItem
v-for="(item, idx) in node?.data.parameters"
:prop="item.name"
:key="idx"
:label="item.description || item.name"
:rules="[{ required: true, message: $t('message.required') }]"
>
<ElInput
v-if="item.formType === 'input' || !item.formType"
v-model="runParams[item.name]"
:placeholder="item.formPlaceholder"
/>
</ElFormItem>
<WorkflowFormItem
v-model:run-params="runParams"
:parameters="node?.data.parameters || []"
/>
<ElFormItem>
<ElButton
type="primary"

View File

@@ -29,16 +29,64 @@ export interface WorkflowStepsProps {
nodeJson: any;
initSignal?: boolean;
pollingData?: any;
expandAll?: boolean;
}
const props = defineProps<WorkflowStepsProps>();
const emit = defineEmits(['resume']);
const nodes = ref<any[]>([]);
const nodeStatusMap = ref<Record<string, any>>({});
const isChainError = ref(false);
const activeName = ref<any>(props.expandAll ? [] : '');
const confirmParams = ref<any>({});
// 定义一个对象来存储所有的 form 实例key 为 node.key
const formRefs = ref<Record<string, FormInstance>>({});
const confirmBtnLoading = ref(false);
const chainErrMsg = ref('');
function shouldAutoExpandStatus(status: unknown) {
return [1, 5, 20, 21].includes(Number(status));
}
function isExpandedNode(nodeKey: string) {
if (Array.isArray(activeName.value)) {
return activeName.value.includes(nodeKey);
}
return activeName.value === nodeKey;
}
function hasNodePayloadChanged(previous: any, current: any) {
if (previous === current) {
return false;
}
return JSON.stringify(previous ?? null) !== JSON.stringify(current ?? null);
}
function hasNodeStateChanged(previous: any, current: any) {
if (!previous) {
return true;
}
if ((previous?.status || null) !== (current?.status || null)) {
return true;
}
if ((previous?.message || '') !== (current?.message || '')) {
return true;
}
if (hasNodePayloadChanged(previous?.result, current?.result)) {
return true;
}
return hasNodePayloadChanged(
previous?.suspendForParameters,
current?.suspendForParameters,
);
}
watch(
() => props.pollingData,
(newVal) => {
const nodes = newVal.nodes;
if (!newVal) {
return;
}
const currentNodes = newVal.nodes || {};
if (newVal.status === 21) {
isChainError.value = true;
chainErrMsg.value = newVal.message;
@@ -46,9 +94,20 @@ watch(
if (![20, 21].includes(newVal.status)) {
confirmBtnLoading.value = false;
}
for (const nodeId in nodes) {
nodeStatusMap.value[nodeId] = nodes[nodeId];
if (nodes[nodeId].status === 5) {
for (const nodeId in currentNodes) {
const previousNodeState = nodeStatusMap.value[nodeId];
const currentNodeState = currentNodes[nodeId];
const previousStatus = previousNodeState?.status;
const currentStatus = currentNodeState?.status;
if (!hasNodeStateChanged(previousNodeState, currentNodeState)) {
continue;
}
nodeStatusMap.value[nodeId] = currentNodeState;
if (
!props.expandAll &&
previousStatus !== currentStatus &&
shouldAutoExpandStatus(currentStatus)
) {
activeName.value = nodeId;
}
}
@@ -62,14 +121,17 @@ watch(
isChainError.value = false;
confirmBtnLoading.value = false;
chainErrMsg.value = '';
activeName.value = props.expandAll ? [] : '';
},
);
watch(
() => props.nodeJson,
(newVal) => {
if (newVal) {
nodes.value = [...newVal];
}
const nextNodes = Array.isArray(newVal) ? [...newVal] : [];
nodes.value = nextNodes;
activeName.value = props.expandAll
? nextNodes.map((node: any) => node.key)
: '';
},
{ immediate: true },
);
@@ -79,18 +141,12 @@ const displayNodes = computed(() => {
...nodeStatusMap.value[node.key],
}));
});
const activeName = ref('1');
const confirmParams = ref<any>({});
// 定义一个对象来存储所有的 form 实例key 为 node.key
const formRefs = ref<Record<string, FormInstance>>({});
// 动态设置 Ref 的辅助函数
const setFormRef = (el: any, key: string) => {
if (el) {
formRefs.value[key] = el as FormInstance;
}
};
const confirmBtnLoading = ref(false);
const chainErrMsg = ref('');
function getSelectMode(ops: any) {
return ops.formType || 'radio';
}
@@ -124,7 +180,11 @@ function handleConfirm(node: any) {
<div class="mb-1">
<ElAlert v-if="chainErrMsg" :title="chainErrMsg" type="error" />
</div>
<ElCollapse v-model="activeName" accordion expand-icon-position="left">
<ElCollapse
v-model="activeName"
:accordion="!props.expandAll"
expand-icon-position="left"
>
<ElCollapseItem
v-for="node in displayNodes"
:key="node.key"
@@ -165,64 +225,66 @@ function handleConfirm(node: any) {
</div>
</div>
</template>
<div v-if="node.original.type === 'confirmNode'" class="p-2.5">
<div class="mb-2 text-[16px] font-bold">
{{ node.original.data.message }}
</div>
<ElForm
:ref="(el) => setFormRef(el, node.key)"
label-position="top"
:model="confirmParams"
>
<template
v-for="(ops, idx) in node.suspendForParameters"
:key="idx"
<template v-if="isExpandedNode(node.key)">
<div v-if="node.original.type === 'confirmNode'" class="p-2.5">
<div class="mb-2 text-[16px] font-bold">
{{ node.original.data.message }}
</div>
<ElForm
:ref="(el) => setFormRef(el, node.key)"
label-position="top"
:model="confirmParams"
>
<div class="header-container" v-if="ops.formType !== 'confirm'">
<div class="blue-bar">&nbsp;</div>
<span>{{ ops.formLabel || $t('message.confirmItem') }}</span>
</div>
<div
class="description-container"
v-if="ops.formType !== 'confirm'"
<template
v-for="(ops, idx) in node.suspendForParameters"
:key="idx"
>
{{ ops.formDescription }}
</div>
<ElFormItem
v-if="ops.formType !== 'confirm'"
:prop="ops.name"
:rules="[{ required: true, message: $t('message.required') }]"
>
<ConfirmItem
v-if="getSelectMode(ops) === 'radio'"
v-model="confirmParams[ops.name]"
:selection-data-type="ops.contentType || 'text'"
:selection-data="ops.enums"
/>
<ConfirmItemMulti
v-else
v-model="confirmParams[ops.name]"
:selection-data-type="ops.contentType || 'text'"
:selection-data="ops.enums"
/>
</ElFormItem>
</template>
<ElFormItem v-if="node.suspendForParameters?.length > 0">
<div class="flex justify-end">
<ElButton
:disabled="confirmBtnLoading"
type="primary"
@click="handleConfirm(node)"
<div class="header-container" v-if="ops.formType !== 'confirm'">
<div class="blue-bar">&nbsp;</div>
<span>{{ ops.formLabel || $t('message.confirmItem') }}</span>
</div>
<div
class="description-container"
v-if="ops.formType !== 'confirm'"
>
{{ $t('button.confirm') }}
</ElButton>
</div>
</ElFormItem>
</ElForm>
</div>
<div v-else>
<ShowJson :value="node.result || node.message" />
</div>
{{ ops.formDescription }}
</div>
<ElFormItem
v-if="ops.formType !== 'confirm'"
:prop="ops.name"
:rules="[{ required: true, message: $t('message.required') }]"
>
<ConfirmItem
v-if="getSelectMode(ops) === 'radio'"
v-model="confirmParams[ops.name]"
:selection-data-type="ops.contentType || 'text'"
:selection-data="ops.enums"
/>
<ConfirmItemMulti
v-else
v-model="confirmParams[ops.name]"
:selection-data-type="ops.contentType || 'text'"
:selection-data="ops.enums"
/>
</ElFormItem>
</template>
<ElFormItem v-if="node.suspendForParameters?.length > 0">
<div class="flex justify-end">
<ElButton
:disabled="confirmBtnLoading"
type="primary"
@click="handleConfirm(node)"
>
{{ $t('button.confirm') }}
</ElButton>
</div>
</ElFormItem>
</ElForm>
</div>
<div v-else>
<ShowJson :value="node.result || node.message" />
</div>
</template>
</ElCollapseItem>
</ElCollapse>
</div>