feat: 添加节点库功能和动态表单支持

- 新增节点库相关接口和类型定义,支持获取节点库列表
- 更新界面,添加节点库展示和动态表单功能,允许用户根据节点类型动态生成表单项
- 修改按钮事件名称以提高代码可读性
This commit is contained in:
2026-05-06 09:59:09 +08:00
parent 415ba67d01
commit 4fe8e15450
2 changed files with 327 additions and 67 deletions

View File

@@ -6,6 +6,34 @@ export interface CreationListParams {
keyword?: string;
}
export interface NodeLibraryFormItem {
field: string;
label: string;
type: 'input' | 'number' | 'textarea' | 'switch' | string;
required: boolean;
default?: string | number | boolean;
}
export interface NodeLibraryItem {
nodeCode: string;
nodeName: string;
form: NodeLibraryFormItem[];
}
export interface NodeLibraryGroup {
group: string;
label: string;
items: NodeLibraryItem[];
}
export interface NodeLibraryListResponse {
code: number;
message: string;
data: {
groups: NodeLibraryGroup[];
};
}
export interface CreationImageItem {
name: string;
url: string;
@@ -71,6 +99,14 @@ export function getCreationList(params: CreationListParams, requestOptions?: Req
}) as Promise<CreationListResponse>;
}
export function getNodeLibraryList(requestOptions?: RequestOptions) {
return request({
url: '/black-deacon/node/library/list',
method: 'get',
requestOptions,
}) as Promise<NodeLibraryListResponse>;
}
export function createCreation(data: CreationSubmitParams, requestOptions?: RequestOptions) {
return request({
url: '/black-deacon/creation/info/creation',

View File

@@ -39,7 +39,7 @@
<div class="actions">
<el-button @click="resetFlow">重置示例</el-button>
<el-button @click="showDsl = !showDsl">{{ showDsl ? '收起 DSL' : '展开 DSL' }}</el-button>
<el-button type="primary" @click="syncDsl">同步 DSL</el-button>
<el-button type="primary" @click="syncDslAction">同步 DSL</el-button>
</div>
</div>
<div class="main">
@@ -47,7 +47,29 @@
<div class="meta">
<span>工作流画布</span><span>节点 {{ flowDsl.nodes.length }} / 连线 {{ flowDsl.edges.length }}</span>
</div>
<div class="canvas-wrap"><div ref="logicFlowRef" class="logicflow-canvas"></div></div>
<div class="canvas-layout">
<div class="node-library">
<div class="title-sm">节点库</div>
<el-empty v-if="nodeLibraryGroups.length === 0" description="暂无节点" :image-size="60" />
<div v-else class="node-library-groups">
<div v-for="group in nodeLibraryGroups" :key="group.group" class="node-group">
<div class="node-group-title">{{ group.label }}</div>
<div class="node-group-items">
<el-button
v-for="item in group.items"
:key="item.nodeCode"
text
class="node-item"
@click="addNodeFromLibrary(item.nodeCode, item.nodeName)"
>
{{ item.nodeName }}
</el-button>
</div>
</div>
</div>
</div>
<div class="canvas-wrap"><div ref="logicFlowRef" class="logicflow-canvas"></div></div>
</div>
</div>
<div class="panel side">
<div class="title-sm">当前选中元素</div>
@@ -59,15 +81,26 @@
<el-form label-position="top" class="prop-form">
<el-form-item v-if="selectedElement.kind === 'node'" label="节点名称"><el-input v-model="formState.text" /></el-form-item>
<el-form-item v-if="selectedElement.kind === 'node'" label="业务类型"><el-input v-model="formState.nodeCode" /></el-form-item>
<el-form-item v-if="selectedElement.kind === 'edge' || formState.nodeCode === 'theme-input'" label="字段"
><el-input v-model="formState.field"
/></el-form-item>
<el-form-item v-if="formState.nodeCode === 'copywriting-agent'" label="模型"><el-input v-model="formState.model" /></el-form-item>
<el-form-item v-if="formState.nodeCode === 'copywriting-agent'" label="Temperature"
><el-input-number v-model="formState.temperature" :min="0" :max="2" :step="0.1" class="w100"
/></el-form-item>
<el-form-item v-if="formState.nodeCode === 'image-agent'" label="图片比例"><el-input v-model="formState.imageRatio" /></el-form-item>
<el-form-item v-if="formState.nodeCode === 'publish-output'" label="发布渠道"><el-input v-model="formState.channel" /></el-form-item>
<el-form-item v-if="selectedElement.kind === 'edge'" label="字段"><el-input v-model="formState.field" /></el-form-item>
<template v-if="selectedElement.kind === 'node'">
<el-form-item v-for="fieldItem in currentNodeForm" :key="fieldItem.field" :label="fieldItem.label">
<el-input
v-if="fieldItem.type === 'input'"
v-model="dynamicFormValues[fieldItem.field]"
:placeholder="fieldItem.required ? '必填' : '选填'"
/>
<el-input-number v-else-if="fieldItem.type === 'number'" v-model="dynamicFormValues[fieldItem.field]" class="w100" />
<el-input
v-else-if="fieldItem.type === 'textarea'"
v-model="dynamicFormValues[fieldItem.field]"
type="textarea"
:rows="3"
:placeholder="fieldItem.required ? '必填' : '选填'"
/>
<el-switch v-else-if="fieldItem.type === 'switch'" v-model="dynamicFormValues[fieldItem.field]" />
<el-input v-else v-model="dynamicFormValues[fieldItem.field]" :placeholder="fieldItem.required ? '必填' : '选填'" />
</el-form-item>
</template>
<el-button type="primary" class="w100" @click="applySelected">应用到当前元素</el-button>
</el-form>
<pre class="json-box">{{ pretty(selectedElement.properties) }}</pre>
@@ -88,13 +121,24 @@
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import LogicFlow from '@logicflow/core';
import { Control, DndPanel, SelectionSelect } from '@logicflow/extension';
import { Control, SelectionSelect } from '@logicflow/extension';
import '@logicflow/core/dist/index.css';
import '@logicflow/extension/lib/style/index.css';
import { downloadToFile, getCreationList, type CreationListParams, type CreationTreeItem } from '/@/api/digitalHuman/creation';
import {
downloadToFile,
getCreationList,
getNodeLibraryList,
type CreationListParams,
type CreationTreeItem,
type NodeLibraryFormItem,
type NodeLibraryGroup,
} from '/@/api/digitalHuman/creation';
type NodeType = 'date' | 'contentType' | 'theme' | 'title' | 'html' | 'image';
type Item = Record<string, any>;
const START_NODE_CODE = '__start__';
const START_NODE_TEXT = '开始';
const JUDGE_KEYWORDS = ['判断', 'judge', 'condition', 'if', 'branch', 'gateway'];
interface TreeNode {
id: string;
label: string;
@@ -117,36 +161,23 @@ const flowDsl = ref<{ nodes: Item[]; edges: Item[] }>({ nodes: [], edges: [] });
const logicFlowRef = ref<HTMLDivElement | null>(null);
const logicFlowInstance = ref<LogicFlow | null>(null);
const showDsl = ref(false);
const formState = reactive({ text: '', nodeCode: '', field: '', model: '', temperature: 0.7, imageRatio: '3:4', channel: '' });
const nodeSpawnIndex = ref(0);
const formState = reactive({ text: '', nodeCode: '', field: '' });
const dynamicFormValues = reactive<Record<string, any>>({});
const nodeSchemaMap = computed(() => {
const map: Record<string, NodeLibraryFormItem[]> = {};
nodeLibraryGroups.value.forEach((group) => {
(group.items || []).forEach((item) => {
map[item.nodeCode] = item.form || [];
});
});
return map;
});
const currentNodeForm = computed<NodeLibraryFormItem[]>(() => nodeSchemaMap.value[formState.nodeCode] || []);
const treeProps = { children: 'children', label: 'label' };
const queryParams: CreationListParams = { keyword: '', pageNum: 1, pageSize: 10 };
const apiBaseUrl = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '');
const nodePalette = [
{ type: 'rect', text: '主题输入', label: '主题输入', properties: { nodeCode: 'theme-input', field: 'theme' } },
{ type: 'rect', text: '文案生成', label: '文案生成', properties: { nodeCode: 'copywriting-agent', model: 'gpt-4o-mini', temperature: 0.7 } },
{ type: 'rect', text: '图片生成', label: '图片生成', properties: { nodeCode: 'image-agent', imageRatio: '3:4' } },
{ type: 'rect', text: '发布输出', label: '发布输出', properties: { nodeCode: 'publish-output', channel: 'xiaohongshu' } },
];
const defaultGraphData = {
nodes: [
{ id: 'theme-node', type: 'rect', x: 160, y: 170, text: '主题输入', properties: { nodeCode: 'theme-input', field: 'theme' } },
{
id: 'copy-node',
type: 'rect',
x: 420,
y: 170,
text: '文案生成',
properties: { nodeCode: 'copywriting-agent', model: 'gpt-4o-mini', temperature: 0.7 },
},
{ id: 'image-node', type: 'rect', x: 680, y: 170, text: '图片生成', properties: { nodeCode: 'image-agent', imageRatio: '3:4' } },
{ id: 'publish-node', type: 'rect', x: 940, y: 170, text: '发布输出', properties: { nodeCode: 'publish-output', channel: 'xiaohongshu' } },
],
edges: [
{ id: 'edge-1', type: 'polyline', sourceNodeId: 'theme-node', targetNodeId: 'copy-node', text: '主题变量', properties: { field: 'theme' } },
{ id: 'edge-2', type: 'polyline', sourceNodeId: 'copy-node', targetNodeId: 'image-node', text: '文案结果', properties: { field: 'copywriting' } },
{ id: 'edge-3', type: 'polyline', sourceNodeId: 'image-node', targetNodeId: 'publish-node', text: '图文结果', properties: { field: 'assets' } },
],
};
const nodeLibraryGroups = ref<NodeLibraryGroup[]>([]);
const workflowDsl = computed(() => ({
version: '1.0.0',
startNodeId: flowDsl.value.nodes[0]?.id || '',
@@ -218,6 +249,15 @@ const getList = async () => {
treeLoading.value = false;
}
};
const getNodeLibrary = async () => {
try {
const res = await getNodeLibraryList({ errorMode: 'page' });
nodeLibraryGroups.value = res.data?.groups || [];
} catch {
nodeLibraryGroups.value = [];
ElMessage.error('获取工作流节点库失败');
}
};
const handleNodeClick = (d: TreeNode) => {
if (d.nodeType !== 'html' && d.nodeType !== 'image') return;
const url = buildAssetUrl(d.fileUrl);
@@ -253,16 +293,72 @@ const syncDsl = () => {
const data = lf.getGraphData() as { nodes?: Item[]; edges?: Item[] };
flowDsl.value = { nodes: data.nodes || [], edges: data.edges || [] };
};
const getNodeText = (node: Item) => (typeof node?.text === 'string' ? node.text : node?.text?.value || '');
const getNodeCode = (node: Item) => String(node?.properties?.nodeCode || '');
const isStartNode = (node: Item) => getNodeCode(node) === START_NODE_CODE || getNodeText(node) === START_NODE_TEXT;
const isJudgeNode = (node: Item) => {
const code = getNodeCode(node).toLowerCase();
const text = getNodeText(node).toLowerCase();
return JUDGE_KEYWORDS.some((k) => code.includes(k) || text.includes(k));
};
const ensureDefaultStartNode = () => {
const lf = logicFlowInstance.value;
if (!lf) return;
const g = lf.getGraphData() as { nodes?: Item[]; edges?: Item[] };
const nodes = g.nodes || [];
if (nodes.some((n) => isStartNode(n))) return;
lf.addNode({
type: 'rect',
x: 220,
y: 140,
text: START_NODE_TEXT,
properties: { nodeCode: START_NODE_CODE },
});
};
const validateFlowConstraints = () => {
const nodes = flowDsl.value.nodes || [];
const edges = flowDsl.value.edges || [];
if (!nodes.length) return { ok: true };
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
const startNode = nodes.find((n) => isStartNode(n));
if (!startNode) return { ok: false, message: '工作流必须包含开始节点' };
for (const e of edges) {
if (e.targetNodeId === startNode.id) {
return { ok: false, message: '开始节点不能被其他节点链接' };
}
if (e.sourceNodeId === startNode.id) {
const target = nodeMap.get(e.targetNodeId);
if (target && isJudgeNode(target)) {
return { ok: false, message: '开始节点下一个节点不能是判断节点' };
}
}
}
const hasOutEdge = new Set(edges.map((e) => e.sourceNodeId));
const endNodes = nodes.filter((n) => !hasOutEdge.has(n.id));
if (endNodes.some((n) => isJudgeNode(n))) {
return { ok: false, message: '结尾节点不能是判断节点' };
}
return { ok: true };
};
watch(
selectedElement,
(e) => {
formState.text = String(e?.text || '');
formState.nodeCode = String(e?.properties?.nodeCode || '');
formState.field = String(e?.properties?.field || '');
formState.model = String(e?.properties?.model || '');
formState.temperature = Number(e?.properties?.temperature ?? 0.7);
formState.imageRatio = String(e?.properties?.imageRatio || '3:4');
formState.channel = String(e?.properties?.channel || '');
Object.keys(dynamicFormValues).forEach((k) => delete dynamicFormValues[k]);
currentNodeForm.value.forEach((fieldItem) => {
const currentValue = e?.properties?.[fieldItem.field];
if (currentValue !== undefined) {
dynamicFormValues[fieldItem.field] = currentValue;
return;
}
if (fieldItem.default !== undefined) {
dynamicFormValues[fieldItem.field] = fieldItem.default;
return;
}
dynamicFormValues[fieldItem.field] = fieldItem.type === 'switch' ? false : '';
});
},
{ immediate: true }
);
@@ -270,12 +366,30 @@ const applySelected = () => {
const lf = logicFlowInstance.value,
cur = selectedElement.value;
if (!lf || !cur) return;
if (cur.kind === 'node') {
const missingField = currentNodeForm.value.find(
(fieldItem) =>
fieldItem.required &&
(dynamicFormValues[fieldItem.field] === '' || dynamicFormValues[fieldItem.field] === undefined || dynamicFormValues[fieldItem.field] === null)
);
if (missingField) {
ElMessage.warning(`请填写必填项:${missingField.label}`);
return;
}
}
const p: Item = { ...cur.properties, nodeCode: formState.nodeCode };
formState.field ? (p.field = formState.field) : delete p.field;
formState.model ? (p.model = formState.model) : delete p.model;
formState.nodeCode === 'copywriting-agent' ? (p.temperature = formState.temperature) : delete p.temperature;
formState.imageRatio ? (p.imageRatio = formState.imageRatio) : delete p.imageRatio;
formState.channel ? (p.channel = formState.channel) : delete p.channel;
if (cur.kind === 'edge') {
formState.field ? (p.field = formState.field) : delete p.field;
} else {
Object.keys(p).forEach((key) => {
if (key !== 'nodeCode') delete p[key];
});
Object.entries(dynamicFormValues).forEach(([key, value]) => {
if (value !== '' && value !== undefined && value !== null) {
p[key] = value;
}
});
}
lf.setProperties(cur.id, p);
if (formState.text) lf.updateText(cur.id, formState.text);
const g = lf.getGraphData() as { nodes: Item[]; edges: Item[] };
@@ -289,11 +403,35 @@ const applySelected = () => {
syncDsl();
ElMessage.success('已更新当前元素配置');
};
const setupDndPanel = () => {
const lf = logicFlowInstance.value as LogicFlow & {
extension: { dndPanel?: { setPatternItems: (items: Array<Record<string, unknown>>) => void } };
};
lf.extension.dndPanel?.setPatternItems(nodePalette);
const syncDslAction = () => {
syncDsl();
const validateResult = validateFlowConstraints();
if (!validateResult.ok) {
ElMessage.warning(validateResult.message);
return;
}
ElMessage.success('DSL 已同步');
};
const addNodeFromLibrary = (nodeCode: string, nodeName: string) => {
const lf = logicFlowInstance.value;
if (!lf || !logicFlowRef.value) return;
const rect = logicFlowRef.value.getBoundingClientRect();
const center = lf.getPointByClient(rect.left + rect.width / 2, rect.top + rect.height / 2);
const x = center?.canvasOverlayPosition?.x ?? 420;
const y = center?.canvasOverlayPosition?.y ?? 220;
const offsetStep = 18;
const offsetCount = nodeSpawnIndex.value % 6;
const spawnX = x + offsetStep * offsetCount;
const spawnY = y + offsetStep * offsetCount;
lf.addNode({
type: 'rect',
x: spawnX,
y: spawnY,
text: nodeName,
properties: { nodeCode },
});
nodeSpawnIndex.value += 1;
syncDsl();
};
const bindEvents = () => {
const lf = logicFlowInstance.value;
@@ -319,12 +457,14 @@ const bindEvents = () => {
lf.on('blank:click', () => {
selectedElement.value = null;
});
lf.on('connection:not-allowed', ({ msg }: { msg?: string }) => {
ElMessage.warning(msg || '当前连线不允许');
});
['history:change', 'node:add', 'edge:add', 'node:delete', 'edge:delete'].forEach((n) => lf.on(n, syncDsl));
};
const initLogicFlow = () => {
if (!logicFlowRef.value) return;
LogicFlow.use(Control);
LogicFlow.use(DndPanel);
LogicFlow.use(SelectionSelect);
const lf = new LogicFlow({
container: logicFlowRef.value,
@@ -334,31 +474,64 @@ const initLogicFlow = () => {
adjustEdge: true,
edgeType: 'polyline',
style: {
rect: { width: 120, height: 54, radius: 10, stroke: '#334155', strokeWidth: 1.4, fill: '#fff' },
rect: { width: 100, height: 44, radius: 8, stroke: '#334155', strokeWidth: 1.4, fill: '#fff' },
polyline: { stroke: '#475569', strokeWidth: 1.4 },
edgeText: { fill: '#64748b', fontSize: 12, textWidth: 120, background: { fill: '#fff' } },
edgeText: { fill: '#64748b', fontSize: 12, textWidth: 100, background: { fill: '#fff' } },
text: { fontSize: 13, fill: '#1f2937' },
},
});
// 先设置实例引用
logicFlowInstance.value = lf;
lf.render(defaultGraphData);
lf.fitView(60, 80);
setupDndPanel();
// 渲染空画布
lf.render({ nodes: [], edges: [] });
// 添加默认开始节点
ensureDefaultStartNode();
// 设置连线验证规则
if (typeof lf.setValidateConnection === 'function') {
lf.setValidateConnection(({ sourceNode, targetNode }: any) => {
if (!sourceNode || !targetNode) return true;
const source = sourceNode?.model || sourceNode;
const target = targetNode?.model || targetNode;
if (isStartNode(target)) {
return { isAllPass: false, msg: '开始节点不能被其他节点链接' } as any;
}
if (isStartNode(source) && isJudgeNode(target)) {
return { isAllPass: false, msg: '开始节点下一个节点不能是判断节点' } as any;
}
return true;
});
}
// 绑定事件
bindEvents();
// 设置固定缩放比例,不使用 fitView 自动缩放
lf.zoom(1);
lf.translateCenter();
// 同步 DSL
syncDsl();
};
const resetFlow = () => {
const lf = logicFlowInstance.value;
if (!lf) return;
lf.render(defaultGraphData);
lf.fitView(60, 80);
lf.render({ nodes: [], edges: [] });
ensureDefaultStartNode();
lf.zoom(1);
lf.translateCenter();
nodeSpawnIndex.value = 0;
selectedElement.value = null;
syncDsl();
ElMessage.success('示例流程已重置');
ElMessage.success('流程已重置');
};
onMounted(async () => {
await getList();
await nextTick();
initLogicFlow();
await getNodeLibrary();
});
onBeforeUnmount(() => {
logicFlowInstance.value?.destroy();
@@ -443,6 +616,58 @@ onBeforeUnmount(() => {
justify-content: space-between;
margin-bottom: 10px;
}
.canvas-layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 12px;
flex: 1;
min-height: 560px;
}
.node-library {
border: 1px solid #e8eef7;
border-radius: 12px;
padding: 12px;
overflow: auto;
background: #f8fafc;
}
.node-library :deep(.el-empty) {
padding: 8px 0;
}
.node-library-groups {
display: flex;
flex-direction: column;
gap: 10px;
}
.node-group {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 10px 10px 8px;
}
.node-group-title {
font-size: 13px;
font-weight: 700;
color: #334155;
margin-bottom: 8px;
padding-left: 2px;
}
.node-group-items {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.node-item {
justify-content: flex-start;
width: 100%;
margin: 0;
padding: 6px 8px;
color: #334155;
border-radius: 6px;
}
.node-item:hover {
background: #eef4ff;
}
.canvas-wrap {
flex: 1;
min-height: 560px;
@@ -483,10 +708,6 @@ onBeforeUnmount(() => {
white-space: pre-wrap;
word-break: break-word;
}
:deep(.lf-dndpanel) {
top: 14px;
left: 14px;
}
:deep(.lf-control) {
right: 14px;
top: 14px;
@@ -503,6 +724,9 @@ onBeforeUnmount(() => {
.creation-page {
grid-template-columns: 260px minmax(0, 1fr);
}
.canvas-layout {
grid-template-columns: 1fr;
}
.main {
grid-template-columns: 1fr;
}