首页调试工作流管理

This commit is contained in:
2026-08-13 18:02:35 +08:00
parent 2ae59c9252
commit 8e7948cff5
8 changed files with 967 additions and 135 deletions
+40 -6
View File
@@ -57,6 +57,7 @@
>
<el-icon><Promotion /></el-icon>
{{ wf.name }}
<span v-if="wf.isTemplate" class="pill-tag">模板</span>
</button>
</div>
</div>
@@ -75,13 +76,15 @@ interface Props {
interface Emits {
(e: 'send', message: string): void;
(e: 'workflow-select', workflowId: string | null): void;
(e: 'workflow-select', workflowId: string | null, isTemplate?: boolean): void;
}
interface Workflow {
id: string;
name: string;
prefix: string;
isTemplate: boolean;
raw?: any;
}
const props = withDefaults(defineProps<Props>(), {
@@ -104,11 +107,18 @@ const visibleWorkflows = computed(() => {
const fetchWorkflows = async () => {
try {
const res = await getWorkflowList();
const workflows = res.data?.listFlowUserRes?.list || [];
const userList = res.data?.listFlowUserRes?.list || [];
const tplList = res.data?.listFlowTemplateRes?.list || [];
const workflows = [
...userList.map((w) => ({ ...w, isTemplate: false })),
...tplList.map((w) => ({ ...w, isTemplate: true })),
];
commonWorkflows.value = workflows.map((w) => ({
id: String(w.id),
name: w.flowName || '未命名',
prefix: '[工作流] ' + (w.flowName || '') + ':\n',
name: w.flowName || w.flowTemplateName || '未命名',
prefix: '[工作流] ' + (w.flowName || w.flowTemplateName || '') + ':\n',
isTemplate: !!w.isTemplate,
raw: w,
}));
} catch {
commonWorkflows.value = [];
@@ -129,9 +139,10 @@ const handleAttachment = () => {
const toggleWorkflow = (id: string) => {
if (props.workflowLocked) return;
const item = commonWorkflows.value.find((w) => w.id === id);
const newId = selectedWorkflowId.value === id ? null : id;
selectedWorkflowId.value = newId;
emit('workflow-select', newId);
emit('workflow-select', newId, item?.isTemplate || false);
};
const resetAll = () => {
@@ -146,11 +157,17 @@ const resetAll = () => {
emit('workflow-select', null);
};
const selectWorkflow = (id: string | null) => {
if (props.workflowLocked) return;
selectedWorkflowId.value = id;
emit('workflow-select', id, false);
};
onMounted(() => {
fetchWorkflows();
});
defineExpose({ resetAll, clearWorkflow, selectedWorkflowId, commonWorkflows });
defineExpose({ resetAll, clearWorkflow, selectWorkflow, fetchWorkflows, selectedWorkflowId, commonWorkflows });
</script>
<style scoped lang="scss">
@@ -371,4 +388,21 @@ onMounted(() => {
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
}
}
.pill-tag {
font-size: 10px;
line-height: 1;
padding: 2px 4px;
border-radius: 4px;
background: #fff7e6;
color: #d97706;
border: 1px solid #fde68a;
white-space: nowrap;
.shortcut-pill.active & {
background: rgba(255, 247, 230, 0.2);
color: #fff;
border-color: rgba(255, 255, 255, 0.4);
}
}
</style>
+29 -59
View File
@@ -190,6 +190,7 @@ import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import PatchTemplateEditor from '/@/components/patchTemplate/PatchTemplateEditor.vue';
import { uploadFile } from '/@/api/common/upload';
import { collectHomeFormFields } from '../utils/flowDsl';
interface Props {
activeMenu: string;
@@ -205,42 +206,12 @@ const fieldFiles = reactive<Record<string, { name: string; url: string }[]>>({})
const uploadingFields = reactive<Record<string, boolean>>({});
const templates = ref<any[]>([]);
const getFieldKey = (node: any, field: any): string => {
if (field?.__isHttpBodyChild) {
return `${node.id}_body_${field.bodyKey}`;
}
return (node.id || node.nodeCode) + '_' + (field.field || field.label);
const id = node.id || node.nodeCode;
return `${id}|${field.path || field.field || field.label}`;
};
const getVisibleFields = (node: any): any[] => {
const fields = Array.isArray(node?.formConfig) ? node.formConfig : [];
const result: any[] = [];
fields.forEach((field: any) => {
if (!field) return;
if (field.expand && typeof field.expand === 'object' && field.expand.editable === false) return;
if (String(node?.nodeCode || '').toLowerCase() === 'http') {
if (field.field !== 'body') return;
const bodyVal = field.value;
if (!bodyVal || typeof bodyVal !== 'object' || Array.isArray(bodyVal)) return;
Object.entries(bodyVal).forEach(([bodyKey, bodyItem]: [string, any]) => {
if (!bodyItem || bodyItem.showInForm !== true) return;
result.push({
__isHttpBodyChild: true,
bodyKey,
field: `body.${bodyKey}`,
label: bodyItem.key || bodyKey,
required: false,
type: bodyItem.fieldType || 'input',
fieldType: bodyItem.fieldType || 'string',
fieldConstraint: bodyItem.fieldConstraint || {},
});
});
return;
}
result.push(field);
});
return result;
return collectHomeFormFields(node);
};
const isFileField = (field: any): boolean => {
@@ -355,13 +326,13 @@ const currentWorkflowHasPatchLayout = computed(() => {
});
const hasFormConfig = (node: any): boolean => {
return node.nodeCode !== '__start__' && node.formConfig && node.formConfig.length > 0;
return node.nodeCode !== '__start__' && collectHomeFormFields(node).length > 0;
};
const hasFormFields = computed(() => {
if (!props.workflowDetail?.nodeInputParams) return false;
return props.workflowDetail.nodeInputParams.some(
(node: any) => node.nodeCode !== '__start__' && node.formConfig?.length > 0
(node: any) => node.nodeCode !== '__start__' && collectHomeFormFields(node).length > 0
);
});
@@ -396,43 +367,42 @@ watch(
templates.value = Array.isArray(restoredTemplates) ? restoredTemplates : [];
if (!detail?.nodeInputParams) return;
detail.nodeInputParams.forEach((node: any) => {
if (!node.formConfig) return;
node.formConfig.forEach((field: any) => {
if (String(node.nodeCode || '').toLowerCase() === 'http' && field.field === 'body' && field.value && typeof field.value === 'object') {
Object.entries(field.value).forEach(([bodyKey, bodyItem]: [string, any]) => {
if (!bodyItem || bodyItem.showInForm !== true) return;
const bodyFieldKey = `${node.id}_body_${bodyKey}`;
if (bodyItem.fieldType === 'number') {
formValues[bodyFieldKey] = (bodyItem.value !== undefined && bodyItem.value !== null && bodyItem.value !== '')
? Number(bodyItem.value) : null;
} else if (bodyItem.fieldType === 'fileUpload') {
formValues[bodyFieldKey] = Array.isArray(bodyItem.value) ? bodyItem.value
: bodyItem.value ? [bodyItem.value] : [];
} else {
formValues[bodyFieldKey] = bodyItem.value || '';
}
});
return;
}
const nodes = detail.nodeInputParams as any[];
// 1) 初始化表单值(model → modelRequestParams runtimeShowform → outputConfig;旧数据 → formConfig
nodes.forEach((node) => {
collectHomeFormFields(node).forEach((field) => {
const key = getFieldKey(node, field);
const hasValue = field.value !== undefined && field.value !== null;
const hasValue = field.value !== undefined && field.value !== null && field.value !== '';
if (field.type === 'number' || field.type === 'inputNumber') {
formValues[key] = hasValue ? Number(field.value) : (field.default ?? null);
} else if (field.type === 'switch') {
formValues[key] = hasValue ? Boolean(field.value) : (field.default ?? false);
} else if (field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload') {
formValues[key] = hasValue ? field.value : (field.default ?? (field.type === 'upload' ? '' : []));
if (field.type === 'fileUpload') {
formValues[key] = hasValue
? Array.isArray(field.value)
? field.value
: field.value
? [field.value]
: []
: Array.isArray(field.default)
? field.default
: field.default
? [field.default]
: [];
} else {
formValues[key] = hasValue ? field.value : (field.default ?? (field.type === 'upload' ? '' : []));
}
} else {
formValues[key] = hasValue ? field.value : (field.default ?? '');
}
});
});
detail.nodeInputParams.forEach((node: any) => {
if (!node.formConfig) return;
node.formConfig.forEach((field: any) => {
// 2) 文件字段回填已上传文件列表
nodes.forEach((node) => {
collectHomeFormFields(node).forEach((field) => {
if (!isFileField(field)) return;
const key = getFieldKey(node, field);
const rawValue = formValues[key];
@@ -0,0 +1,384 @@
<template>
<div class="template-complete-dialog">
<el-dialog
:model-value="modelValue"
title="完善系统工作流"
width="760px"
top="6vh"
:close-on-click-modal="false"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
>
<div v-loading="loading" class="tc-body">
<el-alert
type="info"
:closable="false"
show-icon
title="系统模板缺少运行所需配置,补全后将保存为你的个人工作流,之后可直接复用。"
class="tc-alert"
/>
<template v-if="!loading">
<el-form label-position="top" class="tc-form">
<el-form-item label="工作流名称">
<el-input v-model="flowName" placeholder="请输入工作流名称" maxlength="50" />
</el-form-item>
</el-form>
<el-empty v-if="modelNodes.length === 0" description="该模板无需补全模型参数" :image-size="60" />
<div v-for="node in modelNodes" :key="node.id" class="tc-node">
<div class="tc-node-title">
<span class="tc-node-name">{{ node.name || node.nodeCode }}</span>
<el-tag v-if="!node.modelConfig?.modelId" type="warning" size="small">未选择模型</el-tag>
<el-tag v-else-if="node.modelConfig?.modelName" type="success" size="small">{{ node.modelConfig.modelName }}</el-tag>
</div>
<div class="tc-node-model">
<el-button
v-if="node.modelConfig?.modelId"
type="warning"
plain
size="small"
:loading="converting"
@click="openApiKeyDialog(node)"
>
补全 API Key
</el-button>
<el-button type="primary" plain size="small" @click="openModelSelector(node)">
{{ node.modelConfig?.modelId ? '重新选择模型' : '选择模型' }}
</el-button>
</div>
</div>
</template>
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="saving" :disabled="!canSave" @click="handleSave">保存并使用</el-button>
</template>
<!-- 内嵌模型选择器自身 el-dialog teleport body层级高于本弹窗 -->
<ModelSelector
v-model="modelSelectorVisible"
:default-model="selectorDefaultModel"
:same-type-model-type="selectorTargetNode?.modelConfig?.modelType"
@confirm="handleModelConfirm"
/>
<!-- 基于模板已有模型补全 API Key系统模型 Key 转用户模型后绑定 -->
<el-dialog
v-model="apiKeyDialogVisible"
title="补全 API Key"
width="480px"
append-to-body
:close-on-click-modal="false"
@close="handleApiKeyClose"
>
<el-alert
type="info"
:closable="false"
show-icon
title="该模型为系统内置模型,填写你的 API Key 后将创建一条用户模型并绑定到当前节点。"
class="api-key-alert"
/>
<el-form label-position="top" class="api-key-form">
<el-form-item label="API Key" required>
<el-input
v-model="apiKeyForm.apiKey"
type="password"
show-password
placeholder="请输入你的 API Key"
@keyup.enter="handleApiKeyConfirm"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleApiKeyClose">取消</el-button>
<el-button type="primary" :loading="converting" @click="handleApiKeyConfirm">确认并转换</el-button>
</template>
</el-dialog>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue';
import { ElMessage } from 'element-plus';
import ModelSelector from '/@/views/settings/workflow/component/ModelSelector.vue';
import { stripReadonlyFields, deepClone } from '/@/views/settings/workflow/component/modelParamUtils';
import { getWorkflowDetail, saveWorkflow } from '/@/api/settings/creation';
import { getModelManage, updateModelManage } from '/@/api/settings/modelConfigV2';
interface Props {
modelValue: boolean;
template?: any; // { id, flowName, raw }
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'saved', newId: string, flowName: string): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const loading = ref(false);
const saving = ref(false);
const flowName = ref('');
const nodes = ref<any[]>([]);
const templateFlowContent = ref<any>(null);
const modelSelectorVisible = ref(false);
const selectorTargetNode = ref<any>(null);
const selectorDefaultModel = ref<any>(null);
// 补全 API Key:基于模板已有模型(系统模型)填 Key 转用户模型
const apiKeyDialogVisible = ref(false);
const converting = ref(false);
const apiKeyForm = reactive({ apiKey: '' });
const apiKeyTargetNode = ref<any>(null);
const apiKeySourceModel = ref<any>(null);
const modelNodes = computed(() => nodes.value.filter((n) => String(n?.nodeCode || '').toLowerCase() === 'model'));
const canSave = computed(() => {
if (!flowName.value.trim()) return false;
return modelNodes.value.every((n) => !!n.modelConfig?.modelId);
});
watch(
() => props.modelValue,
async (val) => {
if (!val) return;
loading.value = true;
saving.value = false;
flowName.value = '我的-' + (props.template?.flowName || '系统模板');
nodes.value = [];
templateFlowContent.value = null;
try {
// 优先拉取模板完整 DSL(get 接口),失败则回退列表项 flowContent
const res = await getWorkflowDetail(props.template?.id);
const detail = res.data;
templateFlowContent.value = detail?.flowContent || null;
nodes.value = JSON.parse(JSON.stringify(detail?.flowContent?.nodes || detail?.nodeInputParams || []));
if (detail?.flowName) flowName.value = '我的-' + detail.flowName;
} catch {
const fc = props.template?.raw?.flowContent || props.template?.flowContent;
templateFlowContent.value = fc || null;
nodes.value = JSON.parse(JSON.stringify(fc?.nodes || []));
} finally {
loading.value = false;
}
}
);
const openModelSelector = (node: any) => {
selectorTargetNode.value = node;
selectorDefaultModel.value = null;
modelSelectorVisible.value = true;
};
// 选模型:复刻工作流管理 handleModelConfirm 的初始化逻辑
const handleModelConfirm = (model: any) => {
const node = selectorTargetNode.value;
if (node) {
node.modelConfig = node.modelConfig || {};
node.modelConfig.modelId = model.id || '';
node.modelConfig.modelName = model.modelName;
node.modelConfig.modelType = model.modelType;
// 深拷贝模型 requestBodyMapping 作为参数模板,剔除只读字段
node.modelConfig.modelRequestParams = stripReadonlyFields(deepClone(model.requestBodyMapping ?? null));
node.modelConfig.modelResponseBodyMapping = model.responseBodyMapping ?? null;
}
modelSelectorVisible.value = false;
selectorTargetNode.value = null;
};
// 补全 API Key:拉取当前节点绑定模型详情,仅系统模型可转换
const openApiKeyDialog = async (node: any) => {
const modelId = node?.modelConfig?.modelId;
if (!modelId) {
ElMessage.warning('请先选择模型');
return;
}
converting.value = true;
try {
const res = await getModelManage(modelId);
const model = res?.data;
if (!model?.id) throw new Error('模型详情获取失败');
// 已是用户模型则无需补全
if (model.systemModel !== true) {
ElMessage.info('该模型已是用户模型,无需补全 API Key');
return;
}
apiKeyTargetNode.value = node;
apiKeySourceModel.value = model;
apiKeyForm.apiKey = '';
apiKeyDialogVisible.value = true;
} catch (e: any) {
ElMessage.error(e?.message || '获取模型详情失败,请重试');
} finally {
converting.value = false;
}
};
// 确认补全:调修改接口,后端克隆出用户模型并返回新 id,前端立即绑定
const handleApiKeyConfirm = async () => {
if (!apiKeyForm.apiKey.trim()) {
ElMessage.warning('请输入 API Key');
return;
}
const node = apiKeyTargetNode.value;
const src = apiKeySourceModel.value;
if (!node || !src?.id) return;
converting.value = true;
try {
const res = await updateModelManage({
id: src.id,
// 传递系统模型全部配置,供后端克隆用户模型时继承
modelName: src.modelName,
modelType: src.modelType,
modelSupplier: src.modelSupplier,
baseUrl: src.baseUrl,
responseType: src.responseType ?? src.invokeType,
apiKey: apiKeyForm.apiKey.trim(),
enabled: src.enabled,
chatModel: src.ChatModel ?? src.chatModel,
maxConcurrency: src.maxConcurrency,
maxTokens: src.maxTokens,
tokenPredictPrice: src.tokenPredictPrice,
requestHeadMapping: src.requestHeadMapping ?? src.requestMapping,
requestBodyMapping: src.requestBodyMapping,
responseMapping: src.responseMapping,
responseBodyMapping: src.responseBodyMapping,
...(src.tokenMapping ? { tokenMapping: src.tokenMapping } : {}),
...(src.asyncTaskMapping ? { asyncTaskMapping: src.asyncTaskMapping } : {}),
...(src.lastFrame ? { lastFrame: src.lastFrame } : {}),
...(src.maxDuration ? { maxDuration: src.maxDuration } : {}),
...(src.tokenPredictPriceUnit ? { tokenPredictPriceUnit: src.tokenPredictPriceUnit } : {}),
} as any);
// 返回结构为 { data: { modelManage: {...} } },新用户模型 id 在 modelManage 中
const newModelManage = res?.data?.modelManage || res?.data;
if (!newModelManage?.id) throw new Error('接口未返回新的用户模型');
// 更新节点模型配置:替换为新用户模型 id,其余沿用原系统模型信息
node.modelConfig = node.modelConfig || {};
node.modelConfig.modelId = newModelManage.id;
node.modelConfig.modelName = src.modelName;
node.modelConfig.modelType = src.modelType;
apiKeyDialogVisible.value = false;
apiKeyTargetNode.value = null;
apiKeySourceModel.value = null;
ElMessage.success('已创建用户模型并绑定');
} catch (e: any) {
ElMessage.error(e?.message || '模型转换失败,请重试');
} finally {
converting.value = false;
}
};
const handleApiKeyClose = () => {
apiKeyDialogVisible.value = false;
apiKeyTargetNode.value = null;
apiKeySourceModel.value = null;
};
const handleSave = async () => {
const name = flowName.value.trim();
if (!name) {
ElMessage.warning('请输入工作流名称');
return;
}
const missing = modelNodes.value.find((n) => !n.modelConfig?.modelId);
if (missing) {
ElMessage.warning(`节点「${missing.name || missing.nodeCode}」未选择模型,请先选择`);
return;
}
saving.value = true;
try {
const flowContent = {
...(templateFlowContent.value || {}),
nodes: nodes.value,
};
const res = await saveWorkflow({
flowName: name,
description: '',
flowContent,
});
const data = res?.data;
const newId = data?.id ?? (typeof data === 'string' || typeof data === 'number' ? data : null);
if (!newId) throw new Error('保存失败:未返回工作流ID');
ElMessage.success('已保存为用户工作流');
emit('saved', String(newId), name);
emit('update:modelValue', false);
} catch (e: any) {
ElMessage.error(e?.message || '保存失败,请重试');
} finally {
saving.value = false;
}
};
const handleClose = () => {
emit('update:modelValue', false);
};
</script>
<style scoped lang="scss">
.tc-body {
min-height: 120px;
max-height: 62vh;
overflow-y: auto;
padding: 2px 4px 4px 0;
}
.tc-alert {
margin-bottom: 16px;
}
.tc-form {
:deep(.el-form-item) {
margin-bottom: 14px;
}
}
.tc-node {
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 12px 14px;
margin-bottom: 12px;
background: #fbfcfe;
&:last-child {
margin-bottom: 0;
}
}
.tc-node-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
.tc-node-name {
font-size: 13px;
font-weight: 600;
color: #334155;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.tc-node-model {
margin-bottom: 10px;
}
.api-key-alert {
margin-bottom: 16px;
}
.api-key-form {
margin-bottom: 4px;
}
</style>