diff --git a/src/api/digitalHuman/creation/index.ts b/src/api/digitalHuman/creation/index.ts index 6602e8c..65ed5db 100644 --- a/src/api/digitalHuman/creation/index.ts +++ b/src/api/digitalHuman/creation/index.ts @@ -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; } +export function getNodeLibraryList(requestOptions?: RequestOptions) { + return request({ + url: '/black-deacon/node/library/list', + method: 'get', + requestOptions, + }) as Promise; +} + export function createCreation(data: CreationSubmitParams, requestOptions?: RequestOptions) { return request({ url: '/black-deacon/creation/info/creation', diff --git a/src/views/digitalHuman/creation/index.vue b/src/views/digitalHuman/creation/index.vue index 71f4f75..d202efb 100644 --- a/src/views/digitalHuman/creation/index.vue +++ b/src/views/digitalHuman/creation/index.vue @@ -39,7 +39,7 @@
重置示例 {{ showDsl ? '收起 DSL' : '展开 DSL' }} - 同步 DSL + 同步 DSL
@@ -47,7 +47,29 @@
工作流画布节点 {{ flowDsl.nodes.length }} / 连线 {{ flowDsl.edges.length }}
-
+
+
+
节点库
+ +
+
+
{{ group.label }}
+
+ + {{ item.nodeName }} + +
+
+
+
+
+
当前选中元素
@@ -59,15 +81,26 @@ - - - - - + + 应用到当前元素
{{ pretty(selectedElement.properties) }}
@@ -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; +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(null); const logicFlowInstance = ref(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>({}); +const nodeSchemaMap = computed(() => { + const map: Record = {}; + nodeLibraryGroups.value.forEach((group) => { + (group.items || []).forEach((item) => { + map[item.nodeCode] = item.form || []; + }); + }); + return map; +}); +const currentNodeForm = computed(() => 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([]); 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>) => 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; }