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

@@ -1,12 +1,14 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { EasyFlowFormModal } from '@easyflow/common-ui';
import { Plus, Remove } from '@element-plus/icons-vue';
import {
ElAlert,
ElButton,
ElForm,
ElFormItem,
ElIcon,
@@ -22,15 +24,28 @@ import { api } from '#/api/request';
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
import { $t } from '#/locales';
const emit = defineEmits(['reload']);
const embeddingLlmList = ref<any>([]);
const rerankerLlmList = ref<any>([]);
const categoryList = ref<any[]>([]);
interface headersType {
interface HeaderItem {
label: string;
value: string;
}
const authTypeList = ref<headersType[]>([
interface WorkflowCandidate {
id: string;
title: string;
}
const emit = defineEmits(['reload']);
const saveForm = ref<FormInstance>();
const dialogVisible = ref(false);
const isAdd = ref(true);
const btnLoading = ref(false);
const categoryList = ref<any[]>([]);
const workflowCandidates = ref<WorkflowCandidate[]>([]);
const tempAddHeaders = ref<HeaderItem[]>([]);
const entity = ref<any>(createDefaultEntity());
const authTypeList = [
{
label: 'None',
value: 'none',
@@ -39,79 +54,211 @@ const authTypeList = ref<headersType[]>([
label: 'Service token / ApiKey',
value: 'apiKey',
},
]);
onMounted(() => {
api.get('/api/v1/plugin/modelList?supportEmbed=true').then((res) => {
embeddingLlmList.value = res.data;
});
api
.get('/api/v1/plugin/modelList?supportRerankerLlmList=true')
.then((res) => {
rerankerLlmList.value = res.data;
});
api.get('/api/v1/pluginCategory/list').then((res) => {
if (res.errorCode === 0) {
categoryList.value = res.data;
}
});
});
defineExpose({
openDialog,
});
const saveForm = ref<FormInstance>();
// variables
const dialogVisible = ref(false);
const isAdd = ref(true);
const tempAddHeaders = ref<headersType[]>([]);
const entity = ref<any>({
alias: '',
categoryIds: [],
deptId: '',
icon: '',
title: '',
authType: 'none',
description: '',
englishName: '',
headers: '',
position: '',
});
const btnLoading = ref(false);
const rules = ref({
];
const pluginTypeOptions = [
{
label: $t('plugin.typeHttp'),
value: 1,
},
{
label: $t('plugin.typeWorkflow'),
value: 2,
},
];
const isWorkflowType = computed(
() => Number(entity.value.type || 1) === 2,
);
const rules = computed<FormRules>(() => ({
name: [{ required: true, message: $t('message.required'), trigger: 'blur' }],
description: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
baseUrl: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
{
validator: (_rule, value, callback) => {
if (!isWorkflowType.value && !value) {
callback(new Error($t('message.required')));
return;
}
callback();
},
trigger: 'blur',
},
],
workflowId: [
{
validator: (_rule, value, callback) => {
if (isWorkflowType.value && !value) {
callback(new Error($t('message.required')));
return;
}
callback();
},
trigger: 'change',
},
],
authType: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
tokenKey: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
tokenValue: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
{
validator: (_rule, value, callback) => {
if (!isWorkflowType.value && !value) {
callback(new Error($t('message.required')));
return;
}
callback();
},
trigger: 'change',
},
],
position: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
{
validator: (_rule, value, callback) => {
if (
!isWorkflowType.value &&
entity.value.authType === 'apiKey' &&
!value
) {
callback(new Error($t('message.required')));
return;
}
callback();
},
trigger: 'change',
},
],
tokenKey: [
{
validator: (_rule, value, callback) => {
if (
!isWorkflowType.value &&
entity.value.authType === 'apiKey' &&
!value
) {
callback(new Error($t('message.required')));
return;
}
callback();
},
trigger: 'blur',
},
],
tokenValue: [
{
validator: (_rule, value, callback) => {
if (
!isWorkflowType.value &&
entity.value.authType === 'apiKey' &&
!value
) {
callback(new Error($t('message.required')));
return;
}
callback();
},
trigger: 'blur',
},
],
}));
watch(
() => entity.value.type,
(type) => {
if (Number(type || 1) === 2) {
entity.value.baseUrl = '';
entity.value.authType = 'none';
entity.value.position = '';
entity.value.tokenKey = '';
entity.value.tokenValue = '';
tempAddHeaders.value = [];
return;
}
entity.value.workflowId = '';
entity.value.workflowTitle = '';
entity.value.available = true;
entity.value.reasonMessage = '';
},
);
onMounted(async () => {
await Promise.all([loadCategories(), loadWorkflowCandidates()]);
});
// functions
function openDialog(row: any) {
tempAddHeaders.value = [];
if (row.id) {
isAdd.value = false;
if (row.headers) {
tempAddHeaders.value = JSON.parse(row.headers);
}
defineExpose({
openDialog,
});
function createDefaultEntity() {
return {
alias: '',
authType: 'none',
available: true,
baseUrl: '',
categoryIds: [],
deptId: '',
description: '',
englishName: '',
headers: '',
icon: '',
name: '',
pluginType: 1,
position: '',
reasonMessage: '',
title: '',
tokenKey: '',
tokenValue: '',
type: 1,
workflowId: '',
workflowTitle: '',
};
}
async function loadCategories() {
const res = await api.get('/api/v1/pluginCategory/list');
if (res.errorCode === 0) {
categoryList.value = res.data;
}
}
async function loadWorkflowCandidates(keyword: string = '') {
const res = await api.get('/api/v1/plugin/workflowCandidates', {
params: {
keyword,
},
});
if (res.errorCode === 0) {
workflowCandidates.value = Array.isArray(res.data) ? res.data : [];
ensureCurrentWorkflowOption();
}
}
function ensureCurrentWorkflowOption() {
if (!entity.value.workflowId || !entity.value.workflowTitle) {
return;
}
const exists = workflowCandidates.value.some(
(item) => String(item.id) === String(entity.value.workflowId),
);
if (!exists) {
workflowCandidates.value.unshift({
id: entity.value.workflowId,
title: entity.value.workflowTitle,
});
}
}
function openDialog(row: any) {
tempAddHeaders.value = row.headers ? JSON.parse(row.headers) : [];
isAdd.value = !row.id;
entity.value = {
...createDefaultEntity(),
...row,
authType: row.authType || 'none',
categoryIds: row.categoryIds?.map((item: any) => item.id) || [],
type: Number(row.type || row.pluginType || 1),
};
ensureCurrentWorkflowOption();
dialogVisible.value = true;
}
@@ -131,83 +278,86 @@ async function syncPluginCategories(pluginId: string, categoryIds: string[]) {
}
}
function normalizePayload() {
const plainEntity = { ...entity.value };
const categoryIds = [...(plainEntity.categoryIds || [])];
delete plainEntity.categoryIds;
if (isWorkflowType.value) {
plainEntity.baseUrl = '';
plainEntity.headers = [];
plainEntity.authType = 'none';
plainEntity.position = '';
plainEntity.tokenKey = '';
plainEntity.tokenValue = '';
}
return {
payload: {
...plainEntity,
headers: isWorkflowType.value ? [] : [...tempAddHeaders.value],
},
categoryIds,
};
}
function save() {
saveForm.value?.validate((valid) => {
if (valid) {
btnLoading.value = true;
const plainEntity = { ...entity.value };
const plainHeaders = [...tempAddHeaders.value];
const categoryIds = [...(plainEntity.categoryIds || [])];
delete plainEntity.categoryIds;
if (isAdd.value) {
api
.post('/api/v1/plugin/plugin/save', {
...plainEntity,
headers: plainHeaders,
})
.then(async (res) => {
if (res.errorCode === 0) {
const pluginId =
res.data?.id || plainEntity.id || entity.value.id;
if (!pluginId) {
throw new Error('插件保存成功但未返回插件ID');
}
await syncPluginCategories(pluginId, categoryIds);
dialogVisible.value = false;
ElMessage.success($t('message.saveOkMessage'));
emit('reload');
} else {
ElMessage.error(res.message);
}
})
.catch((error) => {
ElMessage.error(error?.message || $t('message.saveFailMessage'));
})
.finally(() => {
btnLoading.value = false;
});
} else {
api
.post('/api/v1/plugin/plugin/update', {
...plainEntity,
headers: plainHeaders,
})
.then(async (res) => {
if (res.errorCode === 0) {
await syncPluginCategories(entity.value.id, categoryIds);
dialogVisible.value = false;
ElMessage.success($t('message.updateOkMessage'));
emit('reload');
} else {
ElMessage.error(res.message);
}
})
.catch((error) => {
ElMessage.error(error?.message || $t('message.saveFailMessage'));
})
.finally(() => {
btnLoading.value = false;
});
saveForm.value?.validate(async (valid) => {
if (!valid) {
return;
}
btnLoading.value = true;
const { payload, categoryIds } = normalizePayload();
const requestUrl = isAdd.value
? '/api/v1/plugin/plugin/save'
: '/api/v1/plugin/plugin/update';
try {
const res = await api.post(requestUrl, payload);
if (res.errorCode !== 0) {
ElMessage.error(res.message);
return;
}
const pluginId = res.data?.id || payload.id || entity.value.id;
if (!pluginId) {
throw new Error('插件保存成功但未返回插件ID');
}
await syncPluginCategories(pluginId, categoryIds);
dialogVisible.value = false;
ElMessage.success(
isAdd.value ? $t('message.saveOkMessage') : $t('message.updateOkMessage'),
);
emit('reload');
} catch (error: any) {
ElMessage.error(error?.message || $t('message.saveFailMessage'));
} finally {
btnLoading.value = false;
}
});
}
function closeDialog() {
saveForm.value?.resetFields();
isAdd.value = true;
tempAddHeaders.value = [];
entity.value = {};
entity.value = createDefaultEntity();
dialogVisible.value = false;
}
function addHeader() {
tempAddHeaders.value.push({
label: '',
value: '',
});
}
function removeHeader(index: number) {
tempAddHeaders.value.splice(index, 1);
}
function handleWorkflowChange(value: string) {
const target = workflowCandidates.value.find(
(item) => String(item.id) === String(value),
);
entity.value.workflowTitle = target?.title || '';
}
</script>
<template>
@@ -237,15 +387,22 @@ function removeHeader(index: number) {
>
<UploadAvatar v-model="entity.icon" />
</ElFormItem>
<ElFormItem prop="type" :label="$t('plugin.type')">
<ElSelect v-model="entity.type" :disabled="!isAdd">
<ElOption
v-for="item in pluginTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem prop="name" :label="$t('plugin.name')">
<ElInput
v-model.trim="entity.name"
:placeholder="$t('plugin.placeholder.name')"
/>
</ElFormItem>
<ElFormItem prop="baseUrl" :label="$t('plugin.baseUrl')">
<ElInput v-model.trim="entity.baseUrl" />
</ElFormItem>
<ElFormItem prop="description" :label="$t('plugin.description')">
<ElInput
v-model.trim="entity.description"
@@ -270,61 +427,108 @@ function removeHeader(index: number) {
/>
</ElSelect>
</ElFormItem>
<ElFormItem prop="Headers" label="Headers">
<div
class="headers-container-reduce flex flex-row gap-4"
v-for="(item, index) in tempAddHeaders"
:key="index"
>
<div class="head-con-content flex flex-row gap-4">
<ElInput v-model.trim="item.label" placeholder="header name" />
<ElInput v-model.trim="item.value" placeholder="header value" />
<ElIcon size="20" @click="removeHeader" style="cursor: pointer">
<Remove />
</ElIcon>
<template v-if="isWorkflowType">
<ElAlert
type="info"
:closable="false"
show-icon
class="mb-4"
:title="$t('plugin.workflowPluginHint')"
/>
<ElFormItem prop="workflowId" :label="$t('plugin.workflowId')">
<ElSelect
v-model="entity.workflowId"
filterable
remote
reserve-keyword
:remote-method="loadWorkflowCandidates"
:placeholder="$t('plugin.placeholder.workflow')"
@change="handleWorkflowChange"
>
<ElOption
v-for="item in workflowCandidates"
:key="item.id"
:label="item.title"
:value="item.id"
/>
</ElSelect>
<div class="form-helper-text">
{{ $t('plugin.onlyPublishedWorkflow') }}
</div>
</div>
<ElButton @click="addHeader" class="addHeadersBtn">
<ElIcon size="18" style="margin-right: 4px">
<Plus />
</ElIcon>
{{ $t('button.add') }}headers
</ElButton>
</ElFormItem>
<ElFormItem prop="authType" :label="$t('plugin.authType')">
<ElSelect v-model="entity.authType">
<ElOption
v-for="item in authTypeList"
:key="item.value"
:label="item.label"
:value="item.value || ''"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
prop="position"
:label="$t('plugin.position')"
v-if="entity.authType === 'apiKey'"
>
<ElRadioGroup v-model="entity.position">
<ElRadio value="headers">headers</ElRadio>
<ElRadio value="query">query</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem
prop="tokenKey"
:label="$t('plugin.tokenKey')"
v-if="entity.authType === 'apiKey'"
>
<ElInput v-model.trim="entity.tokenKey" />
</ElFormItem>
<ElFormItem
prop="tokenValue"
:label="$t('plugin.tokenValue')"
v-if="entity.authType === 'apiKey'"
>
<ElInput v-model.trim="entity.tokenValue" />
</ElFormItem>
</ElFormItem>
<ElAlert
v-if="!entity.available && entity.reasonMessage"
type="warning"
:closable="false"
show-icon
:title="$t('plugin.workflowPluginUnavailable')"
:description="entity.reasonMessage"
/>
</template>
<template v-else>
<ElFormItem prop="baseUrl" :label="$t('plugin.baseUrl')">
<ElInput v-model.trim="entity.baseUrl" />
</ElFormItem>
<ElFormItem prop="Headers" label="Headers">
<div
v-for="(item, index) in tempAddHeaders"
:key="index"
class="headers-container-reduce flex flex-row gap-4"
>
<div class="head-con-content flex flex-row gap-4">
<ElInput v-model.trim="item.label" placeholder="header name" />
<ElInput v-model.trim="item.value" placeholder="header value" />
<ElIcon
size="20"
style="cursor: pointer"
@click="removeHeader(index)"
>
<Remove />
</ElIcon>
</div>
</div>
<ElButton class="addHeadersBtn" @click="addHeader">
<ElIcon size="18" style="margin-right: 4px">
<Plus />
</ElIcon>
{{ $t('button.add') }}headers
</ElButton>
</ElFormItem>
<ElFormItem prop="authType" :label="$t('plugin.authType')">
<ElSelect v-model="entity.authType">
<ElOption
v-for="item in authTypeList"
:key="item.value"
:label="item.label"
:value="item.value || ''"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="entity.authType === 'apiKey'"
prop="position"
:label="$t('plugin.position')"
>
<ElRadioGroup v-model="entity.position">
<ElRadio value="headers">headers</ElRadio>
<ElRadio value="query">query</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem
v-if="entity.authType === 'apiKey'"
prop="tokenKey"
:label="$t('plugin.tokenKey')"
>
<ElInput v-model.trim="entity.tokenKey" />
</ElFormItem>
<ElFormItem
v-if="entity.authType === 'apiKey'"
prop="tokenValue"
:label="$t('plugin.tokenValue')"
>
<ElInput v-model.trim="entity.tokenValue" />
</ElFormItem>
</template>
</ElForm>
</EasyFlowFormModal>
</template>
@@ -346,4 +550,11 @@ function removeHeader(index: number) {
align-items: center;
margin-bottom: 8px;
}
.form-helper-text {
margin-top: 6px;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
}
</style>