feat: add useSchemaSync, PropertyPanel, useStencil, ComponentPanel
This commit is contained in:
parent
f91bc84911
commit
69ab0a547d
@ -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>
|
||||
@ -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>
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user