From 69ab0a547deacbb12f0868080789fdfd73d85036 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=90=91=E5=AE=81?= <1772105645@qq.com>
Date: Mon, 25 May 2026 14:35:49 +0800
Subject: [PATCH] feat: add useSchemaSync, PropertyPanel, useStencil,
ComponentPanel
---
.../designer/components/ComponentPanel.vue | 44 +++
.../designer/components/PropertyPanel.vue | 286 ++++++++++++++++++
.../designer/composables/useSchemaSync.ts | 168 ++++++++++
.../forms/designer/composables/useStencil.ts | 99 ++++++
4 files changed, 597 insertions(+)
create mode 100644 apps/web-antd/src/views/workflow/forms/designer/components/ComponentPanel.vue
create mode 100644 apps/web-antd/src/views/workflow/forms/designer/components/PropertyPanel.vue
create mode 100644 apps/web-antd/src/views/workflow/forms/designer/composables/useSchemaSync.ts
create mode 100644 apps/web-antd/src/views/workflow/forms/designer/composables/useStencil.ts
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 };
+}