feat: add X6 designer configs, FormFieldNode, and useGraph composable

This commit is contained in:
向宁 2026-05-25 14:25:43 +08:00
parent 069172bb00
commit f91bc84911
5 changed files with 278 additions and 0 deletions

View File

@ -0,0 +1,55 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { Tag } from 'ant-design-vue';
interface FieldNodeProps {
fieldKey: string;
title: string;
componentType: string;
required?: boolean;
}
const props = defineProps<FieldNodeProps>();
const componentLabel = computed(() => {
const map: Record<string, string> = {
Input: '输入框',
'Input.TextArea': '文本域',
'Input.Password': '密码框',
'Input.Number': '数字',
Select: '下拉选择',
'Radio.Group': '单选',
'Checkbox.Group': '多选',
Switch: '开关',
DatePicker: '日期',
TimePicker: '时间',
Upload: '上传',
FormGrid: '栅格',
Card: '卡片',
};
return map[props.componentType] ?? props.componentType;
});
</script>
<template>
<div
class="flex items-center gap-2 rounded-lg border bg-white px-3 py-2 shadow-sm"
style="min-width: 180px"
>
<div class="flex flex-1 flex-col">
<div class="flex items-center gap-1">
<span v-if="required" class="text-red-500">*</span>
<span class="text-sm font-medium">{{ title || '未命名' }}</span>
</div>
<div class="flex items-center gap-1 text-xs text-gray-400">
<span>{{ fieldKey }}</span>
<Tag :bordered="false" class="text-xs" color="blue">
{{
componentLabel
}}
</Tag>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,121 @@
import type { Graph, Node } from '@antv/x6';
import type { Ref } from 'vue';
import { onBeforeUnmount, ref, shallowRef } from 'vue';
import { Graph as X6Graph } from '@antv/x6';
import { register, getTeleport } from '@antv/x6-vue-shape';
import { Selection } from '@antv/x6-plugin-selection';
import { Snapline } from '@antv/x6-plugin-snapline';
import FormFieldNode from '../components/nodes/FormFieldNode.vue';
import { graphOptions, NODE_WIDTH, NODE_HEIGHT } from '../config/graph-config';
import { portConfig } from '../config/port-config';
let registered = false;
function ensureVueShapeRegistered() {
if (registered) return;
register({
shape: 'form-field-node',
width: NODE_WIDTH,
height: NODE_HEIGHT,
component: FormFieldNode,
ports: portConfig,
});
registered = true;
}
export function useGraph(containerRef: Ref<HTMLElement | null>) {
const graph = shallowRef<Graph | null>(null);
const selectedNodeId = ref<string | null>(null);
const selectedEdgeId = ref<string | null>(null);
function initGraph() {
if (!containerRef.value) return;
ensureVueShapeRegistered();
const g = new X6Graph({
...graphOptions,
container: containerRef.value,
autoResize: true,
});
g.use(
new Selection({
enabled: true,
rubberband: true,
showNodeSelectionBox: true,
}),
);
g.use(new Snapline({ enabled: true }));
g.on('node:click', ({ node }: { node: Node }) => {
selectedNodeId.value = node.id;
selectedEdgeId.value = null;
});
g.on('edge:click', ({ edge }: { edge: any }) => {
selectedEdgeId.value = edge.id;
selectedNodeId.value = null;
});
g.on('blank:click', () => {
selectedNodeId.value = null;
selectedEdgeId.value = null;
});
graph.value = g;
}
function addFieldNode(params: {
fieldKey: string;
title: string;
componentType: string;
required?: boolean;
x: number;
y: number;
schemaNode: Record<string, any>;
}): Node {
const g = graph.value;
if (!g) throw new Error('Graph not initialized');
return g.addNode({
shape: 'form-field-node',
x: params.x,
y: params.y,
width: NODE_WIDTH,
height: NODE_HEIGHT,
id: params.fieldKey,
data: {
fieldKey: params.fieldKey,
title: params.title,
componentType: params.componentType,
required: params.required ?? false,
schemaNode: params.schemaNode,
},
ports: portConfig,
});
}
function removeNode(nodeId: string) {
graph.value?.getCellById(nodeId)?.remove();
if (selectedNodeId.value === nodeId) selectedNodeId.value = null;
}
function clearCanvas() {
graph.value?.clearCells();
selectedNodeId.value = null;
selectedEdgeId.value = null;
}
onBeforeUnmount(() => {
graph.value?.dispose();
});
return {
graph,
selectedNodeId,
selectedEdgeId,
initGraph,
addFieldNode,
removeNode,
clearCanvas,
TeleportContainer: getTeleport(),
};
}

View File

@ -0,0 +1,42 @@
import { Graph } from '@antv/x6';
export type LinkageType = 'condition' | 'data';
export const edgeStyles: Record<
LinkageType,
{ stroke: string; strokeDasharray: string; label: string }
> = {
condition: { stroke: '#1677ff', strokeDasharray: '5,5', label: '条件' },
data: { stroke: '#52c41a', strokeDasharray: '', label: '数据' },
};
export function createLinkageEdge(type: LinkageType) {
const style = edgeStyles[type];
return new Graph.ShapeEdge({
attrs: {
line: {
stroke: style.stroke,
strokeWidth: 2,
strokeDasharray: style.strokeDasharray,
targetMarker: { name: 'block', width: 12, height: 8 },
},
},
labels: [
{
position: 0.5,
attrs: {
label: { text: style.label, fill: style.stroke, fontSize: 12 },
rect: {
fill: '#fff',
stroke: style.stroke,
strokeWidth: 1,
rx: 4,
ry: 4,
},
},
},
],
router: { name: 'manhattan' },
connector: { name: 'rounded', args: { radius: 8 } },
});
}

View File

@ -0,0 +1,36 @@
import type { GraphOptions } from '@antv/x6';
export const graphOptions: Partial<GraphOptions> = {
grid: {
visible: true,
type: 'doubleMesh',
args: [
{ color: '#eee', thickness: 1 },
{ color: '#ddd', thickness: 1, factor: 4 },
],
},
background: { color: '#f8f9fa' },
snapline: { enabled: true },
scroller: { enabled: true, pannable: true },
mousewheel: { enabled: true, modifiers: ['ctrl', 'meta'] },
selecting: { enabled: true },
connecting: {
router: 'manhattan',
connector: { name: 'rounded', args: { radius: 8 } },
anchor: 'center',
connectionPoint: 'anchor',
allowBlank: false,
allowLoop: false,
allowMulti: true,
snap: { radius: 30 },
},
highlighting: {
magnetAdsorbed: {
name: 'stroke',
args: { attrs: { fill: '#fff', stroke: '#1677ff' } },
},
},
};
export const NODE_WIDTH = 200;
export const NODE_HEIGHT = 60;

View File

@ -0,0 +1,24 @@
const portAttrs = {
circle: {
r: 5,
magnet: true,
stroke: '#1677ff',
fill: '#fff',
strokeWidth: 1,
},
};
export const portConfig = {
groups: {
top: { position: 'top', attrs: portAttrs },
bottom: { position: 'bottom', attrs: portAttrs },
left: { position: 'left', attrs: portAttrs },
right: { position: 'right', attrs: portAttrs },
},
items: [
{ group: 'top', id: 'top' },
{ group: 'bottom', id: 'bottom' },
{ group: 'left', id: 'left' },
{ group: 'right', id: 'right' },
],
};