|
|
@@ -0,0 +1,394 @@
|
|
|
+<script setup lang="ts">
|
|
|
+import { h } from 'vue';
|
|
|
+
|
|
|
+import { tryOnUnmounted, useParentElement } from '@vueuse/core';
|
|
|
+import { VxeUI } from 'vxe-pc-ui';
|
|
|
+
|
|
|
+import { notification } from 'ant-design-vue';
|
|
|
+import { ArrowDownOutlined, ArrowRightOutlined, CloseOutlined, FullscreenExitOutlined, MenuOutlined, SearchOutlined, ToolOutlined } from '@ant-design/icons-vue';
|
|
|
+
|
|
|
+import type { CallbackArgs } from '@logicflow/core';
|
|
|
+import { ProximityConnect } from '@logicflow/extension';
|
|
|
+import { Dagre } from '@logicflow/layout';
|
|
|
+import VLogicFlowInit, { type LogicFlowInstance, type LogicFlowOptions, VLogicFlow, type VLogicFlowInstance } from '@/libs/logic-flow';
|
|
|
+
|
|
|
+import { FlowNodeComponent, type FlowNodeProps, FlowNodeView, FlowNodeViewModel, Node } from './nodes';
|
|
|
+import { type FlowRequestData, fromFlowRequestData, type Gather, toFlowRequestData } from './tool';
|
|
|
+import RegisterPanel from './panel/RegisterPanel.vue';
|
|
|
+import ReportPanel from './panel/ReportPanel.vue';
|
|
|
+
|
|
|
+import { withResolvers } from '@/tools/promise';
|
|
|
+import StartPanel from '@/pages/aio/flow-config/panel/StartPanel.vue';
|
|
|
+
|
|
|
+defineOptions({
|
|
|
+ name: 'FlowConfig',
|
|
|
+});
|
|
|
+
|
|
|
+const requestData = defineModel<FlowRequestData>('requestData', {
|
|
|
+ default: () => ({}),
|
|
|
+});
|
|
|
+
|
|
|
+const options = reactive<LogicFlowOptions>({
|
|
|
+ grid: { visible: true, size: 20, type: 'mesh', config: { color: '#ababab', thickness: 1 } },
|
|
|
+ snapToGrid: true,
|
|
|
+ snapline: false,
|
|
|
+ textEdit: false,
|
|
|
+ plugins: [Dagre, ProximityConnect],
|
|
|
+ pluginsOptions: {
|
|
|
+ proximityConnect: {
|
|
|
+ enable: true, // 插件是否启用
|
|
|
+ distance: 100, // 渐进连线阈值
|
|
|
+ reverseDirection: true, // 连线方向
|
|
|
+ },
|
|
|
+ },
|
|
|
+});
|
|
|
+const nodes = ref<string[]>([]);
|
|
|
+const nodeGroup = shallowRef<FlowNodeProps[][]>([]);
|
|
|
+
|
|
|
+const parentRef = useParentElement();
|
|
|
+const el = computed(() => parentRef.value?.querySelector(`.v-logic-flow`));
|
|
|
+
|
|
|
+const scope = effectScope();
|
|
|
+let instance!: VLogicFlowInstance;
|
|
|
+const init = (lf: LogicFlowInstance): void => {
|
|
|
+ instance = VLogicFlowInit(lf, {
|
|
|
+ register: [{ category: 'node', type: 'FlowNode', view: FlowNodeView, model: FlowNodeViewModel }],
|
|
|
+ });
|
|
|
+
|
|
|
+ scope.run(() => {
|
|
|
+ watchPostEffect(() => {
|
|
|
+ const value = requestData.value;
|
|
|
+ console.log('[AioFlowConfig] 接收 request-data 数据', value);
|
|
|
+ update(value);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ instance.listener('connection:not-allowed', (event) => {
|
|
|
+ notification.warning({
|
|
|
+ key: 'connection:not-allowed',
|
|
|
+ message: '无法连接节点',
|
|
|
+ description: event.msg,
|
|
|
+ top: '12px',
|
|
|
+ getContainer: () => (el.value as HTMLElement) ?? document.body,
|
|
|
+ });
|
|
|
+ });
|
|
|
+ // @ts-ignore
|
|
|
+ instance.listener('node:click:300', (event) => openNodeConfig(event.data));
|
|
|
+ instance.listener('node:properties-change', (event) => {
|
|
|
+ if (event.keys.includes('requestData')) {
|
|
|
+ // 更新面板属性
|
|
|
+ for (const group of nodeGroup.value) {
|
|
|
+ const node = group.find((node) => node.id === event.id);
|
|
|
+ if (node) {
|
|
|
+ Object.assign(node.properties!, event.properties);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ // @ts-ignore
|
|
|
+ instance.listener('node:add,node:dnd-add', (event: CallbackArgs<'data'>) => {
|
|
|
+ dragPanelNodeId.value = '';
|
|
|
+ // 判断是否存在节点
|
|
|
+ const length = instance.lf.getGraphRawData().nodes.filter((node) => Node.formatText(node) === Node.formatText(event.data)).length;
|
|
|
+ if (length > 1) {
|
|
|
+ notification.warning({
|
|
|
+ key: 'node:add',
|
|
|
+ message: '无法添加节点',
|
|
|
+ description: `已存在 [${Node.formatText(event.data)}] 节点`,
|
|
|
+ top: '12px',
|
|
|
+ getContainer: () => (el.value as HTMLElement) ?? document.body,
|
|
|
+ });
|
|
|
+ instance.lf.deleteNode(event.data.id);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!nodes.value.includes(event.data.id)) nodes.value.push(event.data.id);
|
|
|
+ });
|
|
|
+ instance.listener('node:delete', (event) => {
|
|
|
+ if ([Node.ID_Start, Node.ID_End].includes(event.data.id)) return instance.lf.addNode(event.data);
|
|
|
+
|
|
|
+ const index = nodes.value.indexOf(event.data.id);
|
|
|
+ if (index > -1) nodes.value.splice(index, 1);
|
|
|
+ });
|
|
|
+ // @ts-ignore
|
|
|
+ instance.listener('edge:proximity-connect', () => {
|
|
|
+ updateLayout();
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+const updateLayout = (dir?: 'LR' | 'TB' | 'center') => {
|
|
|
+ if (dir === 'center') {
|
|
|
+ instance.lf.fitView(100, 0);
|
|
|
+ instance.lf.translateCenter();
|
|
|
+ } else {
|
|
|
+ const dagre = instance?.getDagre();
|
|
|
+ dagre?.layout({
|
|
|
+ // @ts-ignore
|
|
|
+ rankdir: dir ?? dagre?.option?.['rankdir'],
|
|
|
+ align: 'UL',
|
|
|
+ ranker: 'longest-path',
|
|
|
+ nodesep: 60,
|
|
|
+ ranksep: 80,
|
|
|
+ acyclicer: 'greedy',
|
|
|
+ isDefaultAnchor: true,
|
|
|
+ });
|
|
|
+
|
|
|
+ if (instance.lf.getGraphRawData().nodes.length > 8) instance.lf.fitView(24, 24);
|
|
|
+ else instance.lf.translateCenter();
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const openPanel = ref(true);
|
|
|
+const dragPanelNodeId = ref('');
|
|
|
+const startDragPanelNode = (node: any, event: MouseEvent) => {
|
|
|
+ let el = event.target as HTMLElement;
|
|
|
+ do {
|
|
|
+ if (el.id === node.id) break;
|
|
|
+ el = el.parentElement as HTMLElement;
|
|
|
+ } while (el);
|
|
|
+
|
|
|
+ if (el.classList.contains('disabled')) {
|
|
|
+ /*notification.warning({
|
|
|
+ key: 'node:add',
|
|
|
+ message: '无法添加节点',
|
|
|
+ description: `已存在 [${node.text}] 节点`,
|
|
|
+ top: '12px',
|
|
|
+ getContainer: () => (el.value as HTMLElement) ?? document.body,
|
|
|
+ });*/
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ dragPanelNodeId.value = node.id;
|
|
|
+ instance.lf.dnd.startDrag(node);
|
|
|
+ window.addEventListener(
|
|
|
+ 'click',
|
|
|
+ () => {
|
|
|
+ dragPanelNodeId.value = '';
|
|
|
+ },
|
|
|
+ { once: true }
|
|
|
+ );
|
|
|
+};
|
|
|
+const getPanelNodeDisabled = (node: FlowNodeProps) => nodes.value.includes(node.id!);
|
|
|
+
|
|
|
+const openNodeConfig = (node: FlowNodeProps) => {
|
|
|
+ let panel;
|
|
|
+ switch (node.id) {
|
|
|
+ case Node.ID_Start:
|
|
|
+ panel = StartPanel;
|
|
|
+ break;
|
|
|
+ case Node.ID_Register:
|
|
|
+ panel = RegisterPanel;
|
|
|
+ break;
|
|
|
+ case Node.ID_Report_TongueAndFace:
|
|
|
+ case Node.ID_Report_Health:
|
|
|
+ case Node.ID_Scheme_Health:
|
|
|
+ panel = ReportPanel;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ if (panel) {
|
|
|
+ VxeUI.modal.open({
|
|
|
+ id: `node:config`,
|
|
|
+ title: `${Node.formatText(node)} 节点`,
|
|
|
+ escClosable: true,
|
|
|
+ zIndex: 9999,
|
|
|
+ slots: {
|
|
|
+ default: () =>
|
|
|
+ h(panel, {
|
|
|
+ id: node.id,
|
|
|
+ requestData: (node.properties?.requestData as string[]) ?? [],
|
|
|
+ 'onUpdate:requestData'(value: any) {
|
|
|
+ instance.lf.getNodeModelById(node.id!)?.setProperties({ requestData: value });
|
|
|
+ VxeUI.modal.close(`node:config`);
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const update = (data?: FlowRequestData) => {
|
|
|
+ const { graph, group } = fromFlowRequestData(data ?? requestData.value);
|
|
|
+ nodes.value = graph.nodes?.map((node) => node.id!) ?? [];
|
|
|
+ nodeGroup.value = group;
|
|
|
+
|
|
|
+ instance.lf.renderRawData(graph);
|
|
|
+ updateLayout('TB');
|
|
|
+ if (graph.nodes && graph.nodes.length > 2) updateLayout('center');
|
|
|
+};
|
|
|
+const validate = (tips = true) => {
|
|
|
+ const preNodeRules = {
|
|
|
+ [Node.ID_Report_Pulse]: Node.ID_Analysis_Pulse,
|
|
|
+ [Node.ID_Report_TongueAndFace]: Node.ID_Analysis_TongueAndFace,
|
|
|
+ [Node.ID_Analysis_Health]: Node.ID_Analysis_TongueAndFace,
|
|
|
+ [Node.ID_Report_Alcohol]: Node.ID_Analysis_Health,
|
|
|
+ [Node.ID_Report_Health]: Node.ID_Analysis_Health,
|
|
|
+ [Node.ID_Scheme_Health]: Node.ID_Analysis_Health,
|
|
|
+ };
|
|
|
+ const map = (node: any, level = 0, gather: Gather = []) => {
|
|
|
+ const sourceNodeId = node.id;
|
|
|
+ const directNodes = instance.lf.getNodeOutgoingNode(sourceNodeId);
|
|
|
+ const edges = instance.lf.getNodeOutgoingEdge(sourceNodeId);
|
|
|
+ for (const directNode of directNodes) {
|
|
|
+ const targetNodeId = directNode.id;
|
|
|
+
|
|
|
+ const rule = preNodeRules[targetNodeId as keyof typeof preNodeRules];
|
|
|
+ (typeof rule === 'function'
|
|
|
+ ? rule
|
|
|
+ : (node: any, gather: Gather) => {
|
|
|
+ if (rule && !gather.find((item) => item.targetNodeId === rule)) {
|
|
|
+ throw { gather, node, message: `目标 [${Node.formatText(node)}] 节点上流路径:必须存在 [${Node.formatText(rule)}] 节点` };
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ })(directNode, gather);
|
|
|
+
|
|
|
+ // @ts-ignore
|
|
|
+ gather.push({ level, sourceNodeId, targetNodeId, edgeId: edges.find((edge) => edge.targetNodeId === directNode.id)?.id });
|
|
|
+ if (node.id !== Node.ID_End) map(directNode, level + 1, gather);
|
|
|
+ }
|
|
|
+ if (gather.length) return gather;
|
|
|
+ else throw { gather, message: `请连接 [${Node.formatText(node)}] 节点` };
|
|
|
+ };
|
|
|
+
|
|
|
+ const start = instance.lf.getNodeModelById(Node.ID_Start);
|
|
|
+
|
|
|
+ let gather;
|
|
|
+ const { promise, resolve, reject } = withResolvers<{ gather: Gather; data?: FlowRequestData; message?: string }>();
|
|
|
+ try {
|
|
|
+ gather = map(start);
|
|
|
+
|
|
|
+ const data: Record<string, any> = {
|
|
|
+ [Node.ID_Start]: instance.lf.getNodeModelById(Node.ID_Start)?.getProperties().requestData,
|
|
|
+ };
|
|
|
+ for (const group of nodeGroup.value) for (const node of group) if (node.id) data[node.id] = node.properties?.requestData;
|
|
|
+ requestData.value = toFlowRequestData(gather, data);
|
|
|
+ resolve({ gather, data: requestData.value });
|
|
|
+ console.log('[AioFlowConfig] 更新 request-data 数据: ', requestData.value);
|
|
|
+ } catch (error: any) {
|
|
|
+ if (tips) {
|
|
|
+ notification.error({
|
|
|
+ key: 'graph:validate',
|
|
|
+ message: '检测连接错误',
|
|
|
+ description: error.message,
|
|
|
+ top: '12px',
|
|
|
+ getContainer: () => (el.value as HTMLElement) ?? document.body,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ gather = error.gather;
|
|
|
+ reject(error);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (Array.isArray(gather) && gather.length) {
|
|
|
+ for (const { edgeId } of gather) {
|
|
|
+ instance.lf.setProperties(edgeId, { isAnimation: true });
|
|
|
+ instance.lf.openEdgeAnimation(edgeId);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ for (const edge of instance.lf.getGraphRawData().edges) {
|
|
|
+ const edgeId = edge.id;
|
|
|
+ instance.lf.closeEdgeAnimation(edgeId);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return promise;
|
|
|
+};
|
|
|
+
|
|
|
+tryOnUnmounted(scope.stop);
|
|
|
+
|
|
|
+defineExpose({
|
|
|
+ validate,
|
|
|
+ update,
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <v-logic-flow :class="{ 'flow-notification-container': el }" @loaded="init($event)" :options="options">
|
|
|
+ <template #top>
|
|
|
+ <div class="top-wrapper">{{ dragPanelNodeId ? `拖拽 [节点] 到画布进行配置` : `单击 [节点] 进行编辑, 双击 [节点][边] 进行删除;` }}</div>
|
|
|
+ </template>
|
|
|
+ <template #panel>
|
|
|
+ <a-dropdown class="panel-wrapper" :trigger="['click']" :open="openPanel">
|
|
|
+ <a-button type="primary" size="large" shape="circle" @click.prevent="openPanel = !openPanel">
|
|
|
+ <template #icon>
|
|
|
+ <CloseOutlined v-if="openPanel"></CloseOutlined>
|
|
|
+ <MenuOutlined v-else />
|
|
|
+ </template>
|
|
|
+ </a-button>
|
|
|
+ <template #overlay>
|
|
|
+ <a-card size="small" style="width: 370px">
|
|
|
+ <div class="flex justify-between m-y-2" v-for="(group, g) in nodeGroup" :key="g">
|
|
|
+ <FlowNodeComponent
|
|
|
+ :class="{ selected: dragPanelNodeId === node.id, disabled: getPanelNodeDisabled(node) }"
|
|
|
+ v-for="node in group"
|
|
|
+ :key="node.id"
|
|
|
+ :id="node.id"
|
|
|
+ :text="node.text"
|
|
|
+ v-bind="node.properties"
|
|
|
+ @mousedown="startDragPanelNode(node, $event)"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </a-card>
|
|
|
+ </template>
|
|
|
+ </a-dropdown>
|
|
|
+ </template>
|
|
|
+ <template #float>
|
|
|
+ <a-float-button-group trigger="hover" type="primary" :style="{ right: '24px' }">
|
|
|
+ <template #icon>
|
|
|
+ <ToolOutlined />
|
|
|
+ </template>
|
|
|
+ <a-float-button tooltip="检测连接" @click="validate().catch()">
|
|
|
+ <template #icon><SearchOutlined /></template>
|
|
|
+ </a-float-button>
|
|
|
+ <a-float-button tooltip="居中显示" @click="updateLayout('center')">
|
|
|
+ <template #icon><FullscreenExitOutlined /></template>
|
|
|
+ </a-float-button>
|
|
|
+ <a-float-button tooltip="布局从左到右" @click="updateLayout('LR')">
|
|
|
+ <template #icon><ArrowRightOutlined /></template>
|
|
|
+ </a-float-button>
|
|
|
+ <a-float-button tooltip="布局从上到下" @click="updateLayout('TB')">
|
|
|
+ <template #icon><ArrowDownOutlined /></template>
|
|
|
+ </a-float-button>
|
|
|
+ </a-float-button-group>
|
|
|
+ </template>
|
|
|
+ </v-logic-flow>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+.top-wrapper {
|
|
|
+ position: absolute;
|
|
|
+ top: 12px;
|
|
|
+ left: max(10%, 300px);
|
|
|
+ right: max(10%, 300px);
|
|
|
+ text-align: center;
|
|
|
+ color: #bbbbbb;
|
|
|
+}
|
|
|
+.panel-wrapper {
|
|
|
+ position: absolute;
|
|
|
+ top: 24px;
|
|
|
+ left: 24px;
|
|
|
+ z-index: 1;
|
|
|
+}
|
|
|
+.ant-float-btn-group {
|
|
|
+ position: absolute !important;
|
|
|
+ bottom: 24px;
|
|
|
+}
|
|
|
+.flow-notification-container {
|
|
|
+ :deep(.ant-notification) {
|
|
|
+ position: absolute !important;
|
|
|
+ }
|
|
|
+}
|
|
|
+:deep(.node-container) {
|
|
|
+ --width: 160px;
|
|
|
+
|
|
|
+ &.selected {
|
|
|
+ .text-container {
|
|
|
+ border-style: dashed;
|
|
|
+ border-color: #1a82ca;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &.disabled {
|
|
|
+ opacity: 0.5;
|
|
|
+ cursor: no-drop;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|