Files
EasyFlow/easyflow-ui-admin/app/src/views/ai/plugin/AddPluginModal.vue
陈子默 47655a728b feat: 支持工作流插件复用与试运行
- 新增工作流插件类型、发布快照同步、实时可用性与下线影响检查

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

- 补齐管理端工作流插件配置、详情与试运行界面及定向测试
2026-04-12 13:15:13 +08:00

561 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus';
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,
ElInput,
ElMessage,
ElOption,
ElRadio,
ElRadioGroup,
ElSelect,
} from 'element-plus';
import { api } from '#/api/request';
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
import { $t } from '#/locales';
interface HeaderItem {
label: string;
value: string;
}
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',
},
{
label: 'Service token / ApiKey',
value: 'apiKey',
},
];
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: [
{
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: [
{
validator: (_rule, value, callback) => {
if (!isWorkflowType.value && !value) {
callback(new Error($t('message.required')));
return;
}
callback();
},
trigger: 'change',
},
],
position: [
{
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()]);
});
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;
}
async function syncPluginCategories(pluginId: string, categoryIds: string[]) {
if (!pluginId) {
return;
}
const relationRes = await api.post(
'/api/v1/pluginCategoryMapping/updateRelation',
{
pluginId,
categoryIds,
},
);
if (relationRes.errorCode !== 0) {
throw new Error(relationRes.message || 'sync categories failed');
}
}
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(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 = 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>
<EasyFlowFormModal
v-model:open="dialogVisible"
:title="isAdd ? $t('button.add') : $t('button.edit')"
:before-close="closeDialog"
align-center
:confirm-loading="btnLoading"
:confirm-text="$t('button.save')"
:submitting="btnLoading"
width="lg"
@confirm="save"
>
<ElForm
ref="saveForm"
:model="entity"
status-icon
:rules="rules"
label-position="top"
class="easyflow-modal-form easyflow-modal-form--compact"
>
<ElFormItem
prop="icon"
:label="$t('plugin.icon')"
style="display: flex; align-items: center"
>
<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="description" :label="$t('plugin.description')">
<ElInput
v-model.trim="entity.description"
:rows="4"
type="textarea"
:placeholder="$t('plugin.placeholder.description')"
/>
</ElFormItem>
<ElFormItem prop="categoryIds" :label="$t('plugin.category')">
<ElSelect
v-model="entity.categoryIds"
multiple
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="3"
>
<ElOption
v-for="item in categoryList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
<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>
</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>
<style scoped>
.headers-container-reduce {
align-items: center;
}
.addHeadersBtn {
width: 100%;
margin-top: 8px;
border-color: var(--el-color-primary);
border-style: dashed;
border-radius: 8px;
}
.head-con-content {
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>