feat: add useSchemaSync, PropertyPanel, useStencil, ComponentPanel

This commit is contained in:
向宁 2026-05-25 14:35:49 +08:00
parent f91bc84911
commit 69ab0a547d
4 changed files with 597 additions and 0 deletions

View File

@ -0,0 +1,44 @@
<script lang="ts" setup>
import type { Ref } from 'vue';
import { ref, watch } from 'vue';
import { Input, Spin } from 'ant-design-vue';
import { useStencil } from '../composables/useStencil';
interface Props {
stencilContainer: Ref<HTMLElement | null>;
graph: Ref<any>;
}
const props = defineProps<Props>();
const searchText = ref('');
const stencilDiv = ref<HTMLElement | null>(null);
const { loading, initStencil } = useStencil(stencilDiv, props.graph);
watch(
() => props.graph,
(g) => {
if (g) initStencil();
},
{ immediate: true },
);
</script>
<template>
<div class="flex h-full w-60 flex-col border-r bg-gray-50">
<div class="border-b p-3">
<Input
v-model:value="searchText"
placeholder="搜索组件..."
size="small"
allow-clear
/>
</div>
<Spin :spinning="loading">
<div ref="stencilDiv" class="flex-1 overflow-auto"></div>
</Spin>
</div>
</template>

View File

@ -0,0 +1,286 @@
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import {
Button,
Col,
Divider,
Form,
Input,
InputNumber,
message,
Row,
Select,
Switch,
} from 'ant-design-vue';
interface Props {
nodeId: null | string;
nodeData: null | Record<string, any>;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: 'update', nodeId: string, data: Record<string, any>): void;
(e: 'delete', nodeId: string): void;
}>();
const formState = reactive({
fieldKey: '',
title: '',
placeholder: '',
required: false,
});
const validationState = reactive({
minLength: undefined as number | undefined,
maxLength: undefined as number | undefined,
pattern: '',
customMessage: '',
});
const dataSourceType = ref<'remote' | 'static'>('static');
const staticOptions = reactive<{ label: string; value: string }[]>([]);
const remoteConfig = reactive({
url: '',
method: 'GET',
labelField: 'name',
valueField: 'id',
});
const supportedProps = computed(() => {
if (!props.nodeData?.supportedProps) return null;
try {
return JSON.parse(props.nodeData.supportedProps);
} catch {
return null;
}
});
const hasDataSource = computed(
() => supportedProps.value?.dataSource?.supported === true,
);
const hasValidation = computed(
() =>
Array.isArray(supportedProps.value?.validation) &&
supportedProps.value.validation.length > 0,
);
watch(
() => props.nodeData,
(data) => {
if (!data) return;
formState.fieldKey = data.fieldKey ?? '';
formState.title = data.title ?? '';
formState.required = data.required ?? false;
const schemaNode = data.schemaNode ?? {};
const compProps = schemaNode['x-component-props'] ?? {};
formState.placeholder = compProps.placeholder ?? '';
const ds = schemaNode['x-data-source'];
if (ds) {
dataSourceType.value = ds.type ?? 'static';
if (ds.type === 'static' && Array.isArray(ds.options)) {
staticOptions.splice(0, staticOptions.length, ...ds.options);
} else if (ds.type === 'remote') {
Object.assign(remoteConfig, {
url: ds.url ?? '',
method: ds.method ?? 'GET',
labelField: ds.labelField ?? 'name',
valueField: ds.valueField ?? 'id',
});
}
} else {
staticOptions.splice(0);
}
},
{ immediate: true, deep: true },
);
function handleSave() {
if (!props.nodeId || !props.nodeData) return;
const schemaNode = {
...props.nodeData.schemaNode,
title: formState.title,
required: formState.required,
};
if (formState.placeholder) {
schemaNode['x-component-props'] = {
...schemaNode['x-component-props'],
placeholder: formState.placeholder,
};
}
if (hasDataSource.value) {
schemaNode['x-data-source'] =
dataSourceType.value === 'static'
? {
type: 'static',
options: [...staticOptions],
}
: { type: 'remote', ...remoteConfig };
}
const newData = {
...props.nodeData,
fieldKey: formState.fieldKey,
title: formState.title,
required: formState.required,
schemaNode,
};
emit('update', props.nodeId, newData);
message.success('属性已更新');
}
function addOption() {
staticOptions.push({ label: '', value: '' });
}
function removeOption(index: number) {
staticOptions.splice(index, 1);
}
function handleDelete() {
if (props.nodeId) emit('delete', props.nodeId);
}
</script>
<template>
<div
v-if="nodeId && nodeData"
class="flex h-full w-80 flex-col border-l bg-white"
>
<div class="flex items-center justify-between border-b px-4 py-3">
<span class="text-sm font-semibold">属性配置</span>
<Button size="small" danger @click="handleDelete">删除</Button>
</div>
<div class="flex-1 overflow-auto p-4">
<div class="mb-2 text-xs font-medium text-gray-500">基础属性</div>
<Form layout="vertical" size="small">
<Form.Item label="组件ID (字段名)" required>
<Input
v-model:value="formState.fieldKey"
placeholder="如: leaveType"
/>
</Form.Item>
<Form.Item label="标题" required>
<Input v-model:value="formState.title" placeholder="如: 请假类型" />
</Form.Item>
<Form.Item
v-if="
supportedProps?.basic?.some((p: any) => p.key === 'placeholder')
"
label="提示文字"
>
<Input v-model:value="formState.placeholder" />
</Form.Item>
<Form.Item label="必填">
<Switch v-model:checked="formState.required" />
</Form.Item>
</Form>
<template v-if="hasValidation">
<Divider style="margin: 12px 0" />
<div class="mb-2 text-xs font-medium text-gray-500">校验规则</div>
<Form layout="vertical" size="small">
<Row :gutter="8">
<Col
v-if="
supportedProps?.validation?.some(
(p: any) => p.key === 'minLength',
)
"
:span="12"
>
<Form.Item label="最小长度">
<InputNumber
v-model:value="validationState.minLength"
class="w-full"
/>
</Form.Item>
</Col>
<Col
v-if="
supportedProps?.validation?.some(
(p: any) => p.key === 'maxLength',
)
"
:span="12"
>
<Form.Item label="最大长度">
<InputNumber
v-model:value="validationState.maxLength"
class="w-full"
/>
</Form.Item>
</Col>
</Row>
<Form.Item
v-if="
supportedProps?.validation?.some((p: any) => p.key === 'pattern')
"
label="正则"
>
<Input v-model:value="validationState.pattern" />
</Form.Item>
</Form>
</template>
<template v-if="hasDataSource">
<Divider style="margin: 12px 0" />
<div class="mb-2 text-xs font-medium text-gray-500">数据源</div>
<Form layout="vertical" size="small">
<Form.Item label="来源类型">
<Select v-model:value="dataSourceType">
<Select.Option value="static">静态选项</Select.Option>
<Select.Option value="remote">远程API</Select.Option>
</Select>
</Form.Item>
<template v-if="dataSourceType === 'static'">
<div
v-for="(opt, i) in staticOptions"
:key="i"
class="mb-2 flex gap-1"
>
<Input
v-model:value="opt.label"
placeholder="Label"
class="flex-1"
/>
<Input
v-model:value="opt.value"
placeholder="Value"
class="flex-1"
/>
<Button size="small" danger @click="removeOption(i)">×</Button>
</div>
<Button size="small" type="dashed" block @click="addOption">
+ 添加选项
</Button>
</template>
<template v-if="dataSourceType === 'remote'">
<Form.Item label="API URL">
<Input v-model:value="remoteConfig.url" placeholder="/api/xxx" />
</Form.Item>
<Row :gutter="8">
<Col :span="12">
<Form.Item label="Label字段">
<Input v-model:value="remoteConfig.labelField" />
</Form.Item>
</Col>
<Col :span="12">
<Form.Item label="Value字段">
<Input v-model:value="remoteConfig.valueField" />
</Form.Item>
</Col>
</Row>
</template>
</Form>
</template>
<Divider style="margin: 12px 0" />
<Button type="primary" block @click="handleSave">保存属性</Button>
</div>
</div>
<div
v-else
class="flex h-full w-80 items-center justify-center border-l bg-gray-50 text-sm text-gray-400"
>
点击节点查看属性
</div>
</template>

View File

@ -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<Graph | null>,
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<string, any>; 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<string, any> = {
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<string, any> = {};
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<string, any[]>();
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,
};
}

View File

@ -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<HTMLElement | null>,
graph: Ref<Graph | null>,
) {
const components = ref<FormComponentDto[]>([]);
const categories = ref<string[]>([]);
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<string, any> = {};
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 };
}