diff --git a/apps/web-antd/src/views/workflow/forms/designer/components/ComponentPanel.vue b/apps/web-antd/src/views/workflow/forms/designer/components/ComponentPanel.vue new file mode 100644 index 0000000..a576bba --- /dev/null +++ b/apps/web-antd/src/views/workflow/forms/designer/components/ComponentPanel.vue @@ -0,0 +1,44 @@ + + + diff --git a/apps/web-antd/src/views/workflow/forms/designer/components/PropertyPanel.vue b/apps/web-antd/src/views/workflow/forms/designer/components/PropertyPanel.vue new file mode 100644 index 0000000..b98ad1a --- /dev/null +++ b/apps/web-antd/src/views/workflow/forms/designer/components/PropertyPanel.vue @@ -0,0 +1,286 @@ + + + diff --git a/apps/web-antd/src/views/workflow/forms/designer/composables/useSchemaSync.ts b/apps/web-antd/src/views/workflow/forms/designer/composables/useSchemaSync.ts new file mode 100644 index 0000000..bc4f24b --- /dev/null +++ b/apps/web-antd/src/views/workflow/forms/designer/composables/useSchemaSync.ts @@ -0,0 +1,168 @@ +import type { Graph } from '@antv/x6'; + +import type { Ref } from 'vue'; + +import { ref } from 'vue'; + +import { portConfig } from '../config/port-config'; + +export function useSchemaSync( + graph: Ref, + initialSchema?: string, +) { + const schemaJson = ref(initialSchema ?? '{"type":"object","properties":{}}'); + const nodeCount = ref(0); + const edgeCount = ref(0); + + function schemaToCanvas() { + const g = graph.value; + if (!g) return; + + g.clearCells(); + + let schema: { properties: Record; type: string; }; + try { + schema = JSON.parse(schemaJson.value); + } catch { + return; + } + + if (!schema.properties) return; + + let yOffset = 40; + const xOffset = 80; + + for (const [key, value] of Object.entries(schema.properties)) { + const nodeData: Record = { + fieldKey: key, + title: value.title ?? key, + componentType: value['x-component'] ?? 'Input', + required: value.required ?? false, + schemaNode: value, + }; + + g.addNode({ + shape: 'form-field-node', + id: key, + x: xOffset, + y: yOffset, + data: nodeData, + ports: portConfig, + }); + + yOffset += 90; + } + + // Recreate edges from x-reactions + for (const [sourceKey, value] of Object.entries(schema.properties)) { + const reactions = value['x-reactions'] as any[] | undefined; + if (!reactions) continue; + for (const reaction of reactions) { + const targetKey = reaction.target; + if (!targetKey) continue; + const type = reaction.type === 'data' ? 'data' : 'condition'; + const color = type === 'data' ? '#52c41a' : '#1677ff'; + const dasharray = type === 'data' ? '' : '5,5'; + const label = type === 'data' ? '数据' : '条件'; + try { + g.addEdge({ + source: sourceKey, + target: targetKey, + attrs: { + line: { + stroke: color, + strokeWidth: 2, + strokeDasharray: dasharray, + targetMarker: { name: 'block', width: 12, height: 8 }, + }, + }, + labels: [ + { + position: 0.5, + attrs: { + label: { text: label, fill: color, fontSize: 12 }, + rect: { + fill: '#fff', + stroke: color, + strokeWidth: 1, + rx: 4, + ry: 4, + }, + }, + }, + ], + data: { + linkageType: type, + when: reaction.when, + expression: reaction.expression, + }, + }); + } catch { + // edge target may not exist + } + } + } + + updateCounts(); + } + + function canvasToSchema(): string { + const g = graph.value; + if (!g) return '{}'; + + const properties: Record = {}; + + g.getNodes().forEach((node) => { + const data = node.getData() ?? {}; + const key = data.fieldKey || node.id; + const schemaNode = data.schemaNode ? { ...data.schemaNode } : {}; + schemaNode.title = data.title; + schemaNode.required = data.required; + if (data.componentType) schemaNode['x-component'] = data.componentType; + properties[key] = schemaNode; + }); + + // Build x-reactions from edges + const reactionMap = new Map(); + for (const edge of g.getEdges()) { + const sourceCell = edge.getSourceCell(); + const targetCell = edge.getTargetCell(); + if (!sourceCell || !targetCell) continue; + const data = edge.getData() ?? {}; + const sourceKey = sourceCell.id; + if (!reactionMap.has(sourceKey)) reactionMap.set(sourceKey, []); + const reaction: any = { + type: data.linkageType ?? 'condition', + target: targetCell.id, + }; + if (data.when) reaction.when = data.when; + if (data.expression) reaction.expression = data.expression; + if (data.action) reaction.action = data.action; + reactionMap.get(sourceKey)?.push(reaction); + } + + for (const [sourceKey, reactions] of reactionMap) { + if (properties[sourceKey]) { + properties[sourceKey]['x-reactions'] = reactions; + } + } + + return JSON.stringify({ type: 'object', properties }, null, 2); + } + + function updateCounts() { + const g = graph.value; + if (!g) return; + nodeCount.value = g.getNodes().length; + edgeCount.value = g.getEdges().length; + } + + return { + schemaJson, + nodeCount, + edgeCount, + schemaToCanvas, + canvasToSchema, + updateCounts, + }; +} diff --git a/apps/web-antd/src/views/workflow/forms/designer/composables/useStencil.ts b/apps/web-antd/src/views/workflow/forms/designer/composables/useStencil.ts new file mode 100644 index 0000000..22f0a1c --- /dev/null +++ b/apps/web-antd/src/views/workflow/forms/designer/composables/useStencil.ts @@ -0,0 +1,99 @@ +import type { Graph } from '@antv/x6'; + +import type { Ref } from 'vue'; + +import type { FormComponentDto } from '#/api/core'; + +import { onMounted, ref } from 'vue'; + +import { Stencil } from '@antv/x6-plugin-stencil'; + +import { getFormComponentsApi } from '#/api/core'; + +import { NODE_HEIGHT, NODE_WIDTH } from '../config/graph-config'; +import { portConfig } from '../config/port-config'; + +export function useStencil( + stencilContainerRef: Ref, + graph: Ref, +) { + const components = ref([]); + const categories = ref([]); + const loading = ref(false); + + async function loadComponents() { + loading.value = true; + try { + const res = await getFormComponentsApi({ isActive: true }); + components.value = res ?? []; + categories.value = [...new Set(res.map((c) => c.category))]; + } catch { + components.value = []; + } finally { + loading.value = false; + } + } + + function initStencil() { + if (!stencilContainerRef.value || !graph.value) return; + + const g = graph.value; + const groups = categories.value.map((cat) => ({ + name: cat, + title: cat, + collapsable: true, + })); + + const stencil = new Stencil({ + title: '表单组件', + target: g, + groups, + search: (cell: any, keyword: string) => { + if (!keyword) return true; + const data = cell.getData?.(); + return data?.displayName?.includes(keyword) ?? false; + }, + stencilGraphWidth: 220, + }); + + for (const category of categories.value) { + const catComponents = components.value.filter( + (c) => c.category === category, + ); + const nodes = catComponents.map((comp) => { + let schema: Record = {}; + try { + schema = JSON.parse(comp.defaultSchema); + } catch { + /* empty */ + } + return g.createNode({ + shape: 'form-field-node', + width: NODE_WIDTH, + height: NODE_HEIGHT, + data: { + componentId: comp.id, + componentType: comp.name, + displayName: comp.displayName, + icon: comp.icon, + fieldKey: '', + title: schema.title ?? '未命名', + required: schema.required ?? false, + schemaNode: schema, + supportedProps: comp.supportedProps, + }, + ports: portConfig, + }); + }); + stencil.load(nodes, category); + } + + stencilContainerRef.value.append(stencil.container); + } + + onMounted(async () => { + await loadComponents(); + }); + + return { components, categories, loading, initStencil, loadComponents }; +}