feat: 更新数字人创作页面以支持技能选择功能

- 在节点库项中新增技能选择选项,允许用户为节点指定技能
- 更新API请求路径,统一为'/ai-agent'前缀
- 优化动态表单逻辑,确保根据节点类型正确显示技能选择器
- 移除冗余的文件上传函数,改为导入公共上传函数以简化代码结构
This commit is contained in:
2026-05-08 19:06:36 +08:00
parent 0c6cfe5c17
commit 8cc5f4be64
10 changed files with 1251 additions and 2285 deletions

View File

@@ -58,6 +58,20 @@
<el-form-item v-if="selectedModel" label="模型 API Key">
<el-input v-model="dynamicFormValues.modelApiKey" placeholder="请输入模型 API Key" type="password" show-password />
</el-form-item>
<!-- 技能选择如果节点支持 -->
<el-form-item v-if="currentNodeSkillOption" label="选择技能">
<div class="skill-selector-wrapper">
<el-button type="primary" @click="showSkillSelector = true">
<el-icon><Plus /></el-icon>
选择技能
</el-button>
<div v-if="selectedSkill" class="selected-skill-tag">
<el-tag type="success" size="large" closable @close="handleRemoveSkill">
{{ selectedSkill.name }}
</el-tag>
</div>
</div>
</el-form-item>
<!-- 基础表单 + 模型表单 -->
<el-form-item v-for="fieldItem in allFormFields" :key="fieldItem.field" :label="fieldItem.label">
<el-input
@@ -189,10 +203,7 @@
<template v-if="currentWorkflowForCreation?.nodeInputParams">
<div v-for="node in currentWorkflowForCreation.nodeInputParams" :key="node.id" class="node-form-wrapper">
<!-- 跳过开始节点 -->
<div
v-if="node.nodeCode !== '__start__' && (node.config?.formConfig?.length > 0 || hasVisibleFields(node))"
class="node-form-section"
>
<div v-if="node.nodeCode !== '__start__' && (node.formConfig?.length > 0 || hasVisibleFields(node))" class="node-form-section">
<div class="node-form-title">
<el-icon class="node-icon"><Document /></el-icon>
<span>{{ node.name }}</span>
@@ -200,9 +211,9 @@
<div class="form-grid">
<!-- 自定义表单字段 -->
<template v-if="node.config?.formConfig && node.config.formConfig.length > 0">
<template v-if="node.formConfig && node.formConfig.length > 0">
<el-form-item
v-for="field in node.config.formConfig"
v-for="field in node.formConfig"
:key="`${node.id}_${field.label}`"
:label="field.label"
:required="field.required"
@@ -389,17 +400,22 @@
<el-button type="primary" @click="confirmSaveWorkflow" :loading="saving">{{ currentEditingWorkflowId ? '确定更新' : '确定保存' }}</el-button>
</template>
</el-dialog>
<!-- 技能选择器 -->
<SkillSelector v-model="showSkillSelector" :default-skill="selectedSkill" @confirm="handleSkillConfirm" />
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Document } from '@element-plus/icons-vue';
import { Document, Plus } from '@element-plus/icons-vue';
import LogicFlow from '@logicflow/core';
import { Control, SelectionSelect } from '@logicflow/extension';
import '@logicflow/core/dist/index.css';
import '@logicflow/extension/lib/style/index.css';
import SkillSelector from '/@/components/skill/NodeSkillSelector.vue';
import type { SkillItem } from '/@/api/digitalHuman/skill';
import {
downloadToFile,
getCreationList,
@@ -442,6 +458,8 @@ const selectedElement = ref<SelectedState | null>(null);
const customFields = ref<Array<{ label: string; value: string; type: string; required: boolean }>>([]);
const selectedParentParam = ref('');
const selectedModel = ref('');
const showSkillSelector = ref(false);
const selectedSkill = ref<SkillItem | null>(null);
const saving = ref(false);
const leftPanelTab = ref('selected'); // 默认显示"当前选中"Tab
const saveDialogVisible = ref(false);
@@ -489,7 +507,7 @@ const logicFlowRef = ref<HTMLDivElement | null>(null);
const logicFlowInstance = ref<LogicFlow | null>(null);
const nodeSpawnIndex = ref(0);
const formState = reactive({ text: '', nodeCode: '', field: '' });
const dynamicFormValues = reactive<Record<string, any>>({});
const dynamicFormValues = reactive<Record<string, any>>({ modelApiKey: '' });
const nodeSchemaMap = computed(() => {
const map: Record<string, NodeLibraryFormItem[]> = {};
nodeLibraryGroups.value.forEach((group) => {
@@ -512,6 +530,18 @@ const currentNodeModelConfig = computed(() => {
});
return modelConfigs;
});
// 获取当前节点是否支持技能选择
const currentNodeSkillOption = computed(() => {
let skillOption = false;
nodeLibraryGroups.value.forEach((group) => {
(group.items || []).forEach((item) => {
if (item.nodeCode === formState.nodeCode) {
skillOption = item.skillOption || false;
}
});
});
return skillOption;
});
// 获取当前选中模型的表单字段
const currentModelForm = computed<NodeLibraryFormItem[]>(() => {
if (!selectedModel.value) return [];
@@ -595,6 +625,7 @@ const workflowDsl = computed(() => ({
nodeCode: n.properties?.nodeCode || 'unknown',
name: typeof n.text === 'string' ? n.text : n.text?.value || '',
type: n.type || 'rect',
skillName: n.properties?.skillName || null,
config: {
nodeCode: n.properties?.nodeCode || 'unknown',
width: n.properties?.width || 100,
@@ -708,6 +739,38 @@ const handlePageChange = (page: number) => {
workflowPagination.pageNum = page;
fetchWorkflowList();
};
// 处理技能选择确认
const handleSkillConfirm = (skill: SkillItem) => {
selectedSkill.value = skill;
// 将技能名称保存到节点属性中(只保存 skillName
if (selectedElement.value && logicFlowInstance.value) {
const nodeData = logicFlowInstance.value.getNodeModelById(selectedElement.value.id);
if (nodeData) {
logicFlowInstance.value.setProperties(selectedElement.value.id, {
...nodeData.properties,
skillName: skill.name,
});
// 同步更新 selectedElement
selectedElement.value.properties.skillName = skill.name;
}
}
ElMessage.success('技能选择成功');
};
// 移除已选择的技能
const handleRemoveSkill = () => {
selectedSkill.value = null;
// 从节点属性中移除技能信息
if (selectedElement.value && logicFlowInstance.value) {
const nodeData = logicFlowInstance.value.getNodeModelById(selectedElement.value.id);
if (nodeData) {
const props = { ...nodeData.properties };
delete props.skillName;
logicFlowInstance.value.setProperties(selectedElement.value.id, props);
// 同步更新 selectedElement
delete selectedElement.value.properties.skillName;
}
}
};
// 使用工作流
const useWorkflow = async (workflow: WorkflowItem) => {
try {
@@ -724,17 +787,18 @@ const useWorkflow = async (workflow: WorkflowItem) => {
// 根据 nodeInputParams 初始化表单默认值
if (res.data.nodeInputParams && Array.isArray(res.data.nodeInputParams)) {
res.data.nodeInputParams.forEach((node: any) => {
if (node.config?.formConfig && Array.isArray(node.config.formConfig)) {
node.config.formConfig.forEach((field: any) => {
// 从节点根级别的 formConfig 读取(不是 node.config.formConfig
if (node.formConfig && Array.isArray(node.formConfig)) {
node.formConfig.forEach((field: any) => {
const fieldKey = `${node.id}_${field.label}`;
creationFormValues[fieldKey] = field.value || '';
});
}
// 初始化其他配置字段
// 初始化其他配置字段(从 config 中读取)
if (node.config) {
Object.keys(node.config).forEach((key) => {
if (!['nodeCode', 'width', 'height', 'x', 'y', 'formConfig', 'inputSource'].includes(key)) {
if (!['nodeCode', 'width', 'height', 'x', 'y', 'formConfig', 'inputSource', 'fieldMetadata', 'selectedModel'].includes(key)) {
const fieldKey = `${node.id}_${key}`;
creationFormValues[fieldKey] = node.config[key];
}
@@ -979,18 +1043,11 @@ const addParentParam = (value: string) => {
inputSource,
});
// 更新 selectedElement 以触发界面刷新
const g = lf.getGraphData() as { nodes: Item[]; edges: Item[] };
const n = g.nodes.find((x) => x.id === selectedElement.value?.id);
if (n) {
selectedElement.value = {
id: n.id,
type: n.type,
kind: 'node',
properties: n.properties || {},
text: typeof n.text === 'string' ? n.text : n.text?.value,
};
}
// 更新 properties不重新赋值整个 selectedElement避免触发 watch 重置表单
selectedElement.value.properties = {
...currentProps,
inputSource,
};
syncDsl();
ElMessage.success(`已添加上级参数:${paramName}`);
@@ -1022,23 +1079,18 @@ const removeInputSource = (nodeId: string, paramName: string) => {
inputSource.splice(nodeIndex, 1);
}
const newInputSource = inputSource.length > 0 ? inputSource : null;
lf.setProperties(selectedElement.value.id, {
...currentProps,
inputSource: inputSource.length > 0 ? inputSource : null,
inputSource: newInputSource,
});
// 更新 selectedElement 以触发界面刷新
const g = lf.getGraphData() as { nodes: Item[]; edges: Item[] };
const n = g.nodes.find((x) => x.id === selectedElement.value?.id);
if (n) {
selectedElement.value = {
id: n.id,
type: n.type,
kind: 'node',
properties: n.properties || {},
text: typeof n.text === 'string' ? n.text : n.text?.value,
};
}
// 更新 properties不重新赋值整个 selectedElement避免触发 watch 重置表单
selectedElement.value.properties = {
...currentProps,
inputSource: newInputSource,
};
syncDsl();
ElMessage.success(`已删除参数:${paramName}`);
@@ -1067,18 +1119,11 @@ const updateQuoteOutput = (nodeId: string, enabled: boolean) => {
inputSource,
});
// 更新 selectedElement 以触发界面刷新
const g = lf.getGraphData() as { nodes: Item[]; edges: Item[] };
const n = g.nodes.find((x) => x.id === selectedElement.value?.id);
if (n) {
selectedElement.value = {
id: n.id,
type: n.type,
kind: 'node',
properties: n.properties || {},
text: typeof n.text === 'string' ? n.text : n.text?.value,
};
}
// 更新 properties不重新赋值整个 selectedElement避免触发 watch 重置表单
selectedElement.value.properties = {
...currentProps,
inputSource,
};
syncDsl();
@@ -1194,18 +1239,11 @@ const toggleNodeOutput = (nodeId: string, enabled: boolean) => {
inputSource,
});
// 更新 selectedElement 以触发界面刷新
const g = lf.getGraphData() as { nodes: Item[]; edges: Item[] };
const n = g.nodes.find((x) => x.id === selectedElement.value?.id);
if (n) {
selectedElement.value = {
id: n.id,
type: n.type,
kind: 'node',
properties: n.properties || {},
text: typeof n.text === 'string' ? n.text : n.text?.value,
};
}
// 更新 properties不重新赋值整个 selectedElement避免触发 watch 重置表单
selectedElement.value.properties = {
...currentProps,
inputSource,
};
syncDsl();
@@ -1259,14 +1297,20 @@ watch(
formState.text = String(e?.text || '');
formState.nodeCode = String(e?.properties?.nodeCode || '');
formState.field = String(e?.properties?.field || '');
Object.keys(dynamicFormValues).forEach((k) => delete dynamicFormValues[k]);
// 重置 dynamicFormValues不删除键保持响应式
for (const key in dynamicFormValues) {
dynamicFormValues[key] = '';
}
// 获取当前节点的基础表单字段(直接从 nodeSchemaMap 获取,避免响应式延迟)
const currentNodeCode = formState.nodeCode;
const baseFormFields = nodeSchemaMap.value[currentNodeCode] || [];
const baseFieldNames = new Set(baseFormFields.map((f) => f.field));
// 加载自定义字段和基础字段(从 formConfig
customFields.value = [];
if (e?.properties?.formConfig && Array.isArray(e.properties.formConfig)) {
// 分离基础字段和自定义字段
const baseFieldNames = new Set(currentNodeForm.value.map(f => f.field));
e.properties.formConfig.forEach((fieldConfig: any) => {
if (baseFieldNames.has(fieldConfig.field)) {
// 基础字段:加载到 dynamicFormValues
@@ -1304,15 +1348,42 @@ watch(
dynamicFormValues.modelApiKey = e?.properties?.modelApiKey || '';
}
// 获取当前节点的模型配置
let nodeModelConfigs: any[] = [];
nodeLibraryGroups.value.forEach((group) => {
(group.items || []).forEach((item) => {
if (item.nodeCode === currentNodeCode) {
nodeModelConfigs = item.modelConfig || [];
}
});
});
// 如果没有选择模型但有模型配置,选择第一个
if (!selectedModel.value && currentNodeModelConfig.value.length > 0) {
selectedModel.value = currentNodeModelConfig.value[0].modelName;
if (!selectedModel.value && nodeModelConfigs.length > 0) {
selectedModel.value = nodeModelConfigs[0].modelName;
}
// 恢复技能信息(只根据 skillName
if (e?.properties?.skillName) {
// 只保存技能名称用于显示,完整信息在选择时已经保存到节点属性
selectedSkill.value = {
id: 0,
name: e.properties.skillName,
description: '',
category: '',
fileName: '',
fileUrl: '',
createdAt: '',
updatedAt: '',
};
} else {
selectedSkill.value = null;
}
// 初始化所有表单字段(基础 + 模型)- 只设置还没有值的字段
allFormFields.value.forEach((fieldItem) => {
// 如果已经从 formConfig 或 modelConfig 加载过,跳过
if (dynamicFormValues[fieldItem.field] !== undefined) {
if (dynamicFormValues[fieldItem.field] !== undefined && dynamicFormValues[fieldItem.field] !== '') {
return;
}
@@ -1360,7 +1431,7 @@ const applySelected = () => {
formState.field ? (p.field = formState.field) : delete p.field;
} else {
// 保留重要的配置字段,删除其他字段
const keysToKeep = ['nodeCode', 'fieldMetadata', 'modelConfig', 'inputSource', 'formConfig', 'width', 'height'];
const keysToKeep = ['nodeCode', 'fieldMetadata', 'modelConfig', 'inputSource', 'formConfig', 'skillName', 'width', 'height'];
Object.keys(p).forEach((key) => {
if (!keysToKeep.includes(key)) {
delete p[key];
@@ -1414,7 +1485,7 @@ const applySelected = () => {
// 保存 formConfig包含基础字段 + 自定义字段,不包含模型字段)
const formConfig: Array<any> = [];
// 1. 添加基础表单字段(非模型字段)
// 重用上面的 modelFieldNames
currentNodeForm.value.forEach((fieldItem) => {
@@ -1423,11 +1494,11 @@ const applySelected = () => {
type: fieldItem.type,
field: fieldItem.field,
label: fieldItem.label,
value: value !== undefined && value !== null ? value : (fieldItem.default || ''),
value: value !== undefined && value !== null ? value : fieldItem.default || '',
required: fieldItem.required || false,
});
});
// 2. 添加自定义字段
customFields.value.forEach((field) => {
formConfig.push({
@@ -1438,7 +1509,7 @@ const applySelected = () => {
required: field.required,
});
});
// 保存 formConfig
if (formConfig.length > 0) {
p.formConfig = formConfig;
@@ -1672,14 +1743,31 @@ const loadWorkflowFromDsl = (dsl: any) => {
try {
// 转换后端 DSL 为 LogicFlow 格式
const nodes = (dsl.nodes || []).map((n: any) => ({
id: n.id,
type: n.type || 'rect',
x: n.config?.x || 220,
y: n.config?.y || 140,
text: n.name || '',
properties: n.config || {},
}));
const nodes = (dsl.nodes || []).map((n: any) => {
// 判断是否为判断节点
const nodeCode = String(n.nodeCode || '').toLowerCase();
const nodeName = String(n.name || '').toLowerCase();
const isJudge = JUDGE_KEYWORDS.some((k) => nodeCode.includes(k) || nodeName.includes(k));
const nodeType = isJudge ? 'diamond' : 'rect';
return {
id: n.id,
type: nodeType,
x: n.config?.x || 220,
y: n.config?.y || 140,
text: n.name || '',
properties: {
// 保留 config 中的基础配置
...(n.config || {}),
// 添加节点级别的重要字段
nodeCode: n.nodeCode,
skillName: n.skillName || null,
formConfig: n.formConfig || null,
modelConfig: n.modelConfig || null,
inputSource: n.inputSource || null,
},
};
});
const edges = (dsl.edges || []).map((e: any) => ({
id: e.id,
@@ -1695,7 +1783,7 @@ const loadWorkflowFromDsl = (dsl: any) => {
syncDsl();
ElMessage.success('工作流已加载');
} catch (error) {
// 后端错误会自动显示
ElMessage.error('工作流加载失败');
}
};
onMounted(async () => {
@@ -2659,4 +2747,19 @@ onBeforeUnmount(() => {
margin-top: 12px;
flex-shrink: 0;
}
.skill-selector-wrapper {
display: flex;
flex-direction: column;
gap: 12px;
}
.selected-skill-tag {
margin-top: 12px;
}
.selected-skill-tag .el-tag {
font-size: 14px;
padding: 8px 16px;
}
</style>