Explorar el Código

添加一体机流程配置

cc12458 hace 7 meses
padre
commit
8f3a743b9d

+ 7 - 0
@types/typed-router.d.ts

@@ -37,6 +37,13 @@ declare module 'vue-router/auto-routes' {
     '//tcmRecuperation/institution': RouteRecordInfo<'//tcmRecuperation/institution', '/tcmRecuperation/institution', Record<never, never>, Record<never, never>>,
     '//tcmRecuperation/preserve': RouteRecordInfo<'//tcmRecuperation/preserve', '/tcmRecuperation/preserve', Record<never, never>, Record<never, never>>,
     '//tcmRecuperation/system': RouteRecordInfo<'//tcmRecuperation/system', '/tcmRecuperation/system', Record<never, never>, Record<never, never>>,
+    '/aio/flow-config/': RouteRecordInfo<'/aio/flow-config/', '/aio/flow-config', Record<never, never>, Record<never, never>>,
+    '/aio/flow-config/nodes/FlowNode': RouteRecordInfo<'/aio/flow-config/nodes/FlowNode', '/aio/flow-config/nodes/FlowNode', Record<never, never>, Record<never, never>>,
+    '/aio/flow-config/nodes/FlowNodeInlay': RouteRecordInfo<'/aio/flow-config/nodes/FlowNodeInlay', '/aio/flow-config/nodes/FlowNodeInlay', Record<never, never>, Record<never, never>>,
+    '/aio/flow-config/panel/RegisterPanel': RouteRecordInfo<'/aio/flow-config/panel/RegisterPanel', '/aio/flow-config/panel/RegisterPanel', Record<never, never>, Record<never, never>>,
+    '/aio/flow-config/panel/ReportPanel': RouteRecordInfo<'/aio/flow-config/panel/ReportPanel', '/aio/flow-config/panel/ReportPanel', Record<never, never>, Record<never, never>>,
+    '/aio/flow-config/panel/StartPanel': RouteRecordInfo<'/aio/flow-config/panel/StartPanel', '/aio/flow-config/panel/StartPanel', Record<never, never>, Record<never, never>>,
+    '/aio/FlowConfigDemo': RouteRecordInfo<'/aio/FlowConfigDemo', '/aio/FlowConfigDemo', Record<never, never>, Record<never, never>>,
     '/login': RouteRecordInfo<'/login', '/login', Record<never, never>, Record<never, never>>,
   }
 }

+ 1 - 0
package.json

@@ -38,6 +38,7 @@
     "vue-router": "^4.3.3",
     "vue-virtual-scroller": "2.0.0-beta.8",
     "vue3-slider": "^1.10.1",
+    "vuedraggable": "^4.1.0",
     "vxe-pc-ui": "^4.6.42",
     "vxe-table": "^4.7.62"
   },

+ 27 - 6
src/libs/logic-flow/VLogicFlow.vue

@@ -11,7 +11,7 @@ import type { LogicFlowInstance, LogicFlowOptions } from './types';
 defineOptions({
   name: 'VLogicFlow',
 });
-const props = defineProps<{ options?: LogicFlowOptions }>();
+const props = defineProps<{ options?: LogicFlowOptions; loading?: boolean }>();
 const emits = defineEmits<{ loaded: [LogicFlowInstance] }>();
 const containerRef = useTemplateRef('container');
 tryOnMounted(() => {
@@ -31,15 +31,36 @@ tryOnMounted(() => {
 </script>
 
 <template>
-  <div class="v-logic-flow" v-bind="$attrs">
-    <main ref="container"></main>
-    <slot name="top"></slot>
-    <slot name="panel"></slot>
-    <slot name="float"></slot>
+  <div class="v-logic-flow-wrapper">
+    <a-spin :spinning="props.loading" :delay="200">
+      <div class="v-logic-flow" v-bind="$attrs">
+        <main ref="container"></main>
+        <slot name="top"></slot>
+        <slot name="panel" v-if="!props.loading"></slot>
+        <slot name="float" v-if="!props.loading"></slot>
+      </div>
+    </a-spin>
   </div>
 </template>
 
 <style scoped lang="scss">
+.v-logic-flow-wrapper {
+  :deep(.ant-spin-nested-loading) {
+    position: relative;
+    height: 100%;
+    .ant-spin {
+      max-height: 100%;
+    }
+
+    > div {
+      position: absolute;
+      top: 0;
+      right: 0;
+      bottom: 0;
+      left: 0;
+    }
+  }
+}
 .v-logic-flow {
   width: 100%;
   height: 100%;

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
src/pages/aio/flow-config/assets/config.svg


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
src/pages/aio/flow-config/assets/pulse.svg


+ 1 - 0
src/pages/aio/flow-config/assets/questionnaire.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1760325595137" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="13876" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M895.946667 734.048l1.066666 1.013333a29.824 29.824 0 0 1 0 43.413334l-162.261333 152.96a31.925333 31.925333 0 0 1-22.762667 8.704 31.925333 31.925333 0 0 1-22.773333-8.704l-93.184-87.84a29.824 29.824 0 0 1 0-43.413334l1.077333-1.013333a32 32 0 0 1 43.904 0l70.976 66.901333 140.053334-132.021333a32 32 0 0 1 43.904 0zM768 85.333333c64.8 0 117.333333 52.533333 117.333333 117.333334v394.666666a32 32 0 0 1-64 0V202.666667a53.333333 53.333333 0 0 0-53.333333-53.333334H256a53.333333 53.333333 0 0 0-53.333333 53.333334v618.666666a53.333333 53.333333 0 0 0 53.333333 53.333334h234.666667a32 32 0 0 1 0 64H256c-64.8 0-117.333333-52.533333-117.333333-117.333334V202.666667c0-64.8 52.533333-117.333333 117.333333-117.333334zM554.666667 544a32 32 0 0 1 0 64H341.333333a32 32 0 0 1 0-64z m128-170.666667a32 32 0 0 1 0 64H341.333333a32 32 0 0 1 0-64z" p-id="13877"></path></svg>

+ 1 - 0
src/pages/aio/flow-config/assets/report.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1760324635575" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9612" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M351.3 669.7h62.4v163.8h-62.4z m-87.6 53.5h62.4v110.3h-62.4z m175-24.6h62.4v134.9h-62.4z m87.4-67.5h62.4v202.4h-62.4z m87.3 38.6h62.4v163.8h-62.4z m87.4-96.4h62.4v260.2h-62.4zM360.8 649.5L335 621.1l85.5-79.1 53.9 39.2 88.6-79.5 71.1 40.5 118-102.2 24.9 29.1-138.4 120-70.6-40.4-91 81.8-53.6-38.9z" p-id="9613"></path><path d="M744.4 1024H92V0h707.6C872.6 0 932 57.1 932 127.2v650.5h-38.2V127.2c0-48.9-42.3-88.7-94.2-88.7H130.2v947h614.2v38.5z" p-id="9614"></path><path d="M197.6 141.6h50V191h-50z m0 115.3h50v49.4h-50z m100-98.8h483.3V191H297.6z m0 115.3h383.3v32.9H297.6z m-100 98.8h50v49.4h-50z m100 16.5h299.9v32.9H297.6z" p-id="9615"></path></svg>

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
src/pages/aio/flow-config/assets/start.svg


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
src/pages/aio/flow-config/assets/tongue.svg


+ 5 - 0
src/pages/aio/flow-config/index.ts

@@ -0,0 +1,5 @@
+import __index_vue from './index.vue';
+
+export { __index_vue as AioFlowConfig };
+export { analysisRequestData } from './tool';
+export type { FlowRequestData } from './tool';

+ 394 - 0
src/pages/aio/flow-config/index.vue

@@ -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>

+ 111 - 0
src/pages/aio/flow-config/nodes/FlowNode.model.ts

@@ -0,0 +1,111 @@
+import { RectNodeModel } from '@logicflow/core';
+import { END_ID } from '@/libs/logic-flow/constant';
+import type { FlowNodeAnchor, FlowNodeConnectRuleResult, FlowNodeProperties } from './index';
+
+export default class FlowNodeModel extends RectNodeModel<FlowNodeProperties> {
+  override getTextStyle() {
+    const style = super.getTextStyle();
+    style.color = 'transparent';
+    return style;
+  }
+
+  private isAllowConnected(source: FlowNodeModel, target: FlowNodeModel, sourceAnchor: FlowNodeAnchor, targetAnchor: FlowNodeAnchor, edgeID?: string): FlowNodeConnectRuleResult {
+    // 自身节点禁止连接
+    if (source === target) return { isAllPass: false, msg: `节点不能连接自身` };
+
+    // 获取当前节点禁止直接连接
+    const forbidDirectTarget = source.getProperties().forbidDirectTarget ?? [];
+    if (forbidDirectTarget.includes(target.id))
+      return {
+        isAllPass: false,
+        msg: `当前 [${source.text.value}] 节点不能直接连接 [${forbidDirectTarget.map((id) => this.graphModel.getNodeModelById(id)?.text.value).join(' | ')}] 节点`,
+      };
+
+    // 获取目标节点 forbidSource
+    let forbidSource = target.getProperties().forbidSource ?? [];
+    if (forbidSource.includes(source.id)) return { isAllPass: false, msg: `目标 [${target.text.value}] 节点不能在当前 [${source.text.value}] 节点之后` };
+
+    // 获取当前节点的上游节点
+    const visited = new Set([source.id, target.id]);
+    const prev = [source.id];
+    do {
+      const sourceId = prev.shift()!;
+      visited.add(sourceId);
+      const directNodes = this.graphModel.getNodeIncomingNode(sourceId);
+      for (const directNode of directNodes) {
+        if (forbidSource.includes(directNode.id))
+          return {
+            isAllPass: false,
+            msg: `当前 [${source.text.value}] 节点上流路径存在:[${directNode.text.value}] 节点不能在目标 [${target.text.value}] 节点之前`,
+          };
+        if (!visited.has(directNode.id)) prev.push(directNode.id);
+      }
+    } while (prev.length);
+
+    // 获取目标节点的下游节点
+    const next = [target.id];
+    do {
+      const targetId = next.shift()!;
+      visited.add(targetId);
+      const directNodes = target.graphModel.getNodeOutgoingNode(targetId);
+      for (const directNode of directNodes) {
+        forbidSource = (directNode.getProperties().forbidSource as string[]) ?? [];
+        if (forbidSource.includes(source.id))
+          return {
+            isAllPass: false,
+            msg: `目标 [${target.text.value}] 节点下流路径存在:[${directNode.text.value}] 节点不能在当前 [${source.text.value}] 节点之后`,
+          };
+        if (!visited.has(directNode.id)) next.push(directNode.id);
+      }
+    } while (next.length);
+
+    return { isAllPass: true };
+  }
+
+  override isAllowConnectedAsTarget(...args: any[]): FlowNodeConnectRuleResult {
+    // 获取目标节点连接到的所有起始节点
+    const directNodes = this.graphModel.getNodeIncomingNode(this.id);
+    if (directNodes.length >= 1)
+      return {
+        isAllPass: false,
+        msg: `目标 [${this.text.value}] 节点已存在 ${directNodes.length}个输入 (${directNodes.map((node) => node.text.value)})`,
+      };
+
+    // 获取目标节点 onlySource
+    const onlySource = (this.getProperties().onlySource as string[]) ?? [];
+    if (onlySource.length && !onlySource.includes(args[0].id))
+      return {
+        isAllPass: false,
+        msg: `目标 [${this.text.value}] 节点仅允许 [${onlySource.map((id) => this.graphModel.getNodeModelById(id)?.text.value).join(' | ')}] 节点作为输入`,
+      };
+
+    // 当前节点是否开始
+    if (this.getProperties().start)
+      return {
+        isAllPass: false,
+        msg: `当前 [${this.text.value}] 为开始节点不允许输入`,
+      };
+
+
+    return this.isAllowConnected(args[0], this, args[1], args[2], args[3]);
+  }
+
+  override isAllowConnectedAsSource(...args: any[]): FlowNodeConnectRuleResult {
+    // 获取当前节点所有的下一级节点
+    const directNodes = this.graphModel.getNodeOutgoingNode(this.id).filter((node) => node.id !== END_ID);
+    if (directNodes.length >= 1 && args[0].id !== END_ID)
+      return {
+        isAllPass: false,
+        msg: `当前 [${this.text.value}] 节点已存在 ${directNodes.length}个输出 (${directNodes.map((node) => node.text.value)})`,
+      };
+
+    // 当前节点是否结束
+    if (this.getProperties().finish && args[0].id !== END_ID)
+      return {
+        isAllPass: false,
+        msg: `当前 [${this.text.value}] 为结束节点不允许输出`,
+      };
+
+    return this.isAllowConnected(this, args[0], args[1], args[2], args[3]);
+  }
+}

+ 65 - 0
src/pages/aio/flow-config/nodes/FlowNode.vue

@@ -0,0 +1,65 @@
+<script setup lang="ts">
+import { tryOnMounted } from '@vueuse/core';
+import { GraphModel } from '@logicflow/core';
+import { VueNodeModel } from '@logicflow/vue-node-registry';
+
+import { useEventListener } from '@/libs/logic-flow/use';
+
+import type { FlowNodeProperties } from './index';
+import FlowNodeInlay from './FlowNodeInlay.vue';
+
+type NodeModelInstance = InstanceType<typeof VueNodeModel>;
+type GraphModelInstance = InstanceType<typeof GraphModel>;
+
+defineOptions({
+  name: 'LogicFlowNode',
+});
+
+const getNode = inject<() => NodeModelInstance>('getNode');
+const getGraph = inject<() => GraphModelInstance>('getGraph');
+
+const text = ref('');
+const id = ref('');
+
+const properties = ref<FlowNodeProperties>({
+  width: 0,
+  height: 0,
+  radius: 0,
+  iconBackground: 'transparent',
+  iconColor: '#000',
+});
+
+tryOnMounted(() => {
+  const model = getNode?.()!;
+  const graph = getGraph?.()!;
+
+  id.value = model.id;
+  text.value = model.text.value;
+
+  updateProperties(model.getProperties());
+  useEventListener(
+    graph.eventCenter,
+    'node:properties-change',
+    (event) => updateProperties(event.properties),
+    (event) => event.id === id.value
+  );
+});
+
+function updateProperties(props: FlowNodeProperties) {
+  properties.value = Object.assign(properties.value, props);
+}
+</script>
+
+<template>
+  <FlowNodeInlay class="node" :id :text v-bind="properties"></FlowNodeInlay>
+</template>
+
+<style scoped lang="scss">
+.node {
+  --width: calc(v-bind(properties.width) * 1px);
+  --height: calc(v-bind(properties.height) * 1px);
+  --radius: calc(v-bind(properties.radius) * 1px);
+  --icon-background: v-bind(properties.iconBackground);
+  --icon-color: v-bind(properties.iconColor);
+}
+</style>

+ 113 - 0
src/pages/aio/flow-config/nodes/FlowNodeInlay.vue

@@ -0,0 +1,113 @@
+<script setup lang="ts">
+import { h } from 'vue';
+import type { FlowNodeProperties } from './index';
+
+import IconStart from '@/pages/aio/flow-config/assets/start.svg';
+import IconConfig from '@/pages/aio/flow-config/assets/config.svg';
+import IconReport from '@/pages/aio/flow-config/assets/report.svg';
+import IconPulse from '@/pages/aio/flow-config/assets/pulse.svg';
+import IconTongue from '@/pages/aio/flow-config/assets/tongue.svg';
+import IconQuestionnaire from '@/pages/aio/flow-config/assets/questionnaire.svg';
+
+interface Props extends FlowNodeProperties {
+  id: string;
+  text?: string;
+}
+
+defineOptions({
+  name: 'FlowNodeInlay',
+});
+
+const Icon = {
+  start: IconStart,
+  config: IconConfig,
+  report: IconReport,
+  pulse: IconPulse,
+  tongue: IconTongue,
+  questionnaire: IconQuestionnaire,
+};
+
+const { id, icon, text, configurable } = defineProps<Props>();
+
+const iconComponent = computed(() => (icon ? Icon[icon] : void 0));
+</script>
+
+<template>
+  <div :id="id" class="node-container" :class="{ configurable }">
+    <div v-if="iconComponent" class="icon-container flex flex-center">
+      <component :is="h(iconComponent)" class="icon" />
+    </div>
+    <div v-if="text" class="text-container flex flex-center">
+      <div class="text">{{ text }}</div>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+$height: 40px;
+$radius: 8px;
+$icon: 22px;
+
+.flex {
+  display: flex;
+}
+.flex-center {
+  justify-content: center;
+  align-items: center;
+}
+
+.node-container {
+  position: relative;
+  width: var(--width, 120px);
+  height: var(--height, $height);
+  user-select: none;
+
+  &.configurable {
+    cursor: pointer;
+
+    .text-container::after {
+      content: '';
+      position: absolute;
+      top: 6px;
+      right: 6px;
+      width: 5px;
+      height: 5px;
+      border-radius: 50%;
+      background-color: #ff4d4f;
+    }
+  }
+}
+
+.icon-container {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: var(--height, $height);
+  height: 100%;
+  border-top-left-radius: var(--radius, $radius);
+  border-bottom-left-radius: var(--radius, $radius);
+  background-color: var(--icon-background, v-bind(iconBackground));
+
+  & + .text-container {
+    padding-left: var(--height, $height);
+  }
+
+  svg {
+    height: $icon;
+    fill: var(--icon-color, v-bind(iconColor));
+  }
+}
+
+.text-container {
+  line-height: $icon;
+  height: 100%;
+  border-radius: var(--radius, $radius);
+  background-color: #fff;
+  border: 2px solid #111;
+
+  .text {
+    height: $icon;
+    font-size: 14px;
+  }
+}
+</style>

+ 187 - 0
src/pages/aio/flow-config/nodes/config.ts

@@ -0,0 +1,187 @@
+import { END_ID, START_ID } from '@/libs/logic-flow/constant';
+import type { FlowNodeProperties } from './index';
+
+interface CreateNodeOptions extends FlowNodeProperties {
+  point?: Partial<{
+    x: number;
+    y: number;
+  }>;
+  rect?: Partial<{
+    radius: number;
+    width: number;
+    height: number;
+  }>;
+}
+
+export const DEFAULT_NODE_WIDTH = 160;
+export const DEFAULT_NODE_HEIGHT = 40;
+
+export const ID_Start = START_ID;
+export const ID_End = END_ID;
+export const ID_Register = 'register';
+export const ID_Analysis_Pulse = 'pulseAnalysis';
+export const ID_Analysis_TongueAndFace = 'tongueAndFaceAnalysis';
+export const ID_Analysis_Health = 'healthAnalysis';
+
+export const ID_Report_Pulse = 'pulseAnalysisReport';
+export const ID_Report_TongueAndFace = 'tongueAndFaceAnalysisReport';
+export const ID_Report_Alcohol = 'alcoholAnalysisReport';
+export const ID_Report_Health = 'healthAnalysisReport';
+export const ID_Scheme_Health = 'healthAnalysisScheme';
+
+const factory = (type: 'StartNode' | 'EndNode' | 'FlowNode', id: string, text: string, options?: CreateNodeOptions) => {
+  const { point, rect, ...properties } = options ?? {};
+  return {
+    type,
+    id,
+    text,
+    x: point?.x ?? 100,
+    y: point?.y ?? 100,
+    properties: {
+      radius: 8,
+      width: rect?.width ?? DEFAULT_NODE_WIDTH,
+      height: rect?.height ?? DEFAULT_NODE_HEIGHT,
+      ...properties,
+    },
+  };
+};
+
+const textRef = {
+  [ID_Start]: '开始检测',
+  [ID_End]: '返回首页',
+  [ID_Register]: '建档',
+  [ID_Analysis_Pulse]: '脉象分析',
+  [ID_Analysis_TongueAndFace]: '舌面象分析',
+  [ID_Analysis_Health]: '问诊',
+  [ID_Report_Pulse]: '脉象分析报告',
+  [ID_Report_TongueAndFace]: '舌面象分析报告',
+  [ID_Report_Health]: '健康分析报告',
+  [ID_Scheme_Health]: '调理方案',
+  [ID_Report_Alcohol]: '黄酒建议',
+};
+
+export function formatText(value: string | { text?: string | { value?: string } }) {
+  let text = '未命名节点';
+  if (typeof value === 'string') text = textRef[<keyof typeof textRef>value] ?? text;
+  else if (typeof value.text === 'string') text = value.text ?? text;
+  else if (typeof value.text === 'object') text = value.text?.value ?? text;
+  return text;
+}
+
+export function start(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Start, textRef[ID_Start], {
+    icon: 'start',
+    iconBackground: '#cf1322',
+    iconColor: '#fff',
+    start: true,
+    configurable: true,
+    ...options,
+  });
+}
+
+export function end(options?: CreateNodeOptions) {
+  return factory('EndNode', ID_End, textRef[ID_End], {
+    radius: 20,
+    ...options,
+  });
+}
+
+export function register(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Register, textRef[ID_Register], {
+    icon: 'config',
+    iconBackground: '#c41d7f',
+    iconColor: '#fff',
+    onlySource: [START_ID],
+    configurable: true,
+    ...options,
+  });
+}
+
+export function pulseAnalysis(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Analysis_Pulse, textRef[ID_Analysis_Pulse], {
+    icon: 'pulse',
+    iconBackground: '#d46b08',
+    iconColor: '#fff',
+    forbidSource: [ID_Report_Pulse],
+    ...options,
+  });
+}
+export function tongueAndFaceAnalysis(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Analysis_TongueAndFace, textRef[ID_Analysis_TongueAndFace], {
+    icon: 'tongue',
+    iconBackground: '#d46b08',
+    iconColor: '#fff',
+    forbidSource: [ID_Report_TongueAndFace, ID_Analysis_Health, ID_Report_Alcohol, ID_Report_Health, ID_Scheme_Health],
+    ...options,
+  });
+}
+
+export function healthAnalysis(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Analysis_Health, textRef[ID_Analysis_Health], {
+    icon: 'questionnaire',
+    iconBackground: '#d46b08',
+    iconColor: '#fff',
+    forbidSource: [ID_Report_Alcohol, ID_Report_Health, ID_Scheme_Health],
+    ...options,
+  });
+}
+
+export function pulseAnalysisReport(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Report_Pulse, textRef[ID_Report_Pulse], {
+    icon: 'report',
+    iconBackground: '#08979c',
+    iconColor: '#fff',
+    forbidDirectTarget: [ID_End],
+    ...options,
+  });
+}
+
+export function tongueAndFaceAnalysisReport(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Report_TongueAndFace, textRef[ID_Report_TongueAndFace], {
+    icon: 'report',
+    iconBackground: '#08979c',
+    iconColor: '#fff',
+    forbidDirectTarget: [ID_End],
+    ...options,
+  });
+}
+
+export function healthAnalysisReport(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Report_Health, textRef[ID_Report_Health], {
+    icon: 'report',
+    iconBackground: '#08979c',
+    iconColor: '#fff',
+    finish: true,
+    configurable: true,
+    ...options,
+  });
+}
+
+export function healthAnalysisScheme(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Scheme_Health, textRef[ID_Scheme_Health], {
+    icon: 'report',
+    iconBackground: '#08979c',
+    iconColor: '#fff',
+    finish: true,
+    configurable: true,
+    ...options,
+  });
+}
+
+export function alcoholAnalysisReport(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Report_Alcohol, textRef[ID_Report_Alcohol], {
+    icon: 'report',
+    iconBackground: '#08979c',
+    iconColor: '#fff',
+    ...options,
+  });
+}
+
+export function edge(sourceNodeId: string, targetNodeId: string, properties?: Record<string, any>) {
+  return {
+    type: 'custom-polyline-edge',
+    sourceNodeId,
+    targetNodeId,
+    properties: properties ?? {},
+  };
+}

+ 34 - 0
src/pages/aio/flow-config/nodes/index.ts

@@ -0,0 +1,34 @@
+import type { IRectNodeProperties, Model, RectNodeModel } from '@logicflow/core';
+import type { LogicFlowNode } from '@/libs/logic-flow';
+
+import __FlowNode_component from './FlowNode.vue';
+import __FlowNodeInlay_component from './FlowNodeInlay.vue';
+import __FlowNode_model from './FlowNode.model';
+
+export {
+  __FlowNode_component as FlowNodeView,
+  __FlowNode_model as FlowNodeViewModel,
+  __FlowNodeInlay_component as FlowNodeComponent,
+};
+
+export * as Node from './config';
+
+export interface FlowNodeProperties extends IRectNodeProperties {
+  icon?: 'start' | 'config' | 'report' | 'pulse' | 'tongue' | 'questionnaire';
+  iconBackground?: string;
+  iconColor?: string;
+
+  forbidDirectTarget?: string[];
+  forbidSource?: string[];
+  onlySource?: string[];
+
+  start?: boolean;
+  finish?: boolean;
+
+  configurable?: boolean;
+  requestData?: Record<string, any>;
+}
+export type FlowNodeProps = LogicFlowNode<FlowNodeProperties>;
+export type FlowNodeModel = RectNodeModel<FlowNodeProperties>;
+export type FlowNodeAnchor = Model.AnchorConfig & { type?: 'outgoing' | 'incoming'};
+export type FlowNodeConnectRuleResult  = Model.ConnectRuleResult;

+ 179 - 0
src/pages/aio/flow-config/panel/RegisterPanel.vue

@@ -0,0 +1,179 @@
+<script setup lang="ts">
+import draggable from 'vuedraggable';
+import { CloseOutlined, HolderOutlined } from '@ant-design/icons-vue';
+import { analysisRegisterFields, Field_Card, Field_Phone } from '../tool';
+
+interface Option {
+  id: string;
+  name: string;
+  required?: boolean;
+}
+const props = defineProps<{ id: string }>();
+const requestData = defineModel<string[]>('requestData', { default: [] });
+
+let getConfigItemLabel = (id: string) => id;
+
+const options = ref<Option[]>([]);
+const selected = ref<Option[]>([]);
+
+watchEffect(() => reset());
+
+const append = (option: Option, index: number) => {
+  options.value.splice(index, 1);
+  if (option.required) selected.value.unshift(option);
+  else selected.value.push(option);
+};
+
+const remove = (option: Option, index: number) => {
+  selected.value.splice(index, 1);
+  if (option.required) options.value.unshift(option);
+  else options.value.push(option);
+};
+
+const error = ref('');
+watch(
+  [selected, options],
+  () => {
+    error.value = '';
+  },
+  { deep: true }
+);
+function save() {
+  error.value = '';
+  const fieldCard = selected.value.find((option) => option.id === Field_Card);
+  const fieldPhone = selected.value.find((option) => option.id === Field_Phone);
+
+  if (fieldCard && !fieldCard.required && fieldPhone && !fieldPhone.required) {
+    error.value = `字段 [${getConfigItemLabel(Field_Card)}] [${getConfigItemLabel(Field_Phone)}] 至少需要一个是必填的`;
+  } else if (fieldCard && !fieldCard.required && !fieldPhone) {
+    fieldCard.required = true;
+    nextTick(() => {
+      error.value = `字段 [${getConfigItemLabel(Field_Card)}] 需要是必填的 (请重试)`;
+    });
+  } else if (fieldPhone && !fieldPhone.required && !fieldCard) {
+    fieldPhone.required = true;
+    nextTick(() => {
+      error.value = `字段 [${getConfigItemLabel(Field_Phone)}] 需要是必填的 (请重试)`;
+    });
+  } else if (!fieldCard && !fieldPhone) {
+    error.value = `至少需要 [${getConfigItemLabel(Field_Card)}] 或 [${getConfigItemLabel(Field_Card)}] 字段,且有一个需要是必填的`;
+  } else {
+    requestData.value = selected.value.map((option) => [option.id, option.required ? 'required' : ''].filter(Boolean).join(':'));
+  }
+}
+
+function reset() {
+  const values = analysisRegisterFields(requestData.value);
+  selected.value = values.selected;
+  options.value = values.options;
+  getConfigItemLabel = (id: string) => values.config.find((item) => item[0] === id)?.[1] ?? id;
+}
+</script>
+
+<template>
+  <div>
+    <draggable class="draggable-list selected" :class="{ empty: selected.length === 0 }" :list="selected" group="fields" item-key="id">
+      <template #item="{ element, index }">
+        <div class="draggable-list-item has-icon">
+          <div>
+            <HolderOutlined class="icon left" />
+            <span>{{ element.name }}</span>
+          </div>
+          <div>
+            <a-switch v-model:checked="element.required" checked-children="必填" un-checked-children="可选" />
+            <CloseOutlined class="icon right" style="cursor: pointer" @click.prevent="remove(element, index)" />
+          </div>
+        </div>
+      </template>
+      <template #header>
+        <div class="draggable-list-header">配置字段</div>
+      </template>
+    </draggable>
+    <draggable class="draggable-list options" :class="{ empty: options.length === 0 }" :list="options" group="fields" item-key="id">
+      <template #header>
+        <div class="draggable-list-header">备选字段</div>
+      </template>
+      <template #item="{ element, index }">
+        <a-tag :color="element.required ? '#f50' : '#108ee9'" class="draggable-list-item" @click="append(element, index)">
+          {{ element.name }}
+        </a-tag>
+      </template>
+    </draggable>
+    <div class="flex items-center" style="margin-top: 10px">
+      <div class="flex-auto error">{{ error }}</div>
+      <div class="flex-none">
+        <vxe-button type="reset" content="重置" @click="reset()"></vxe-button>
+        <vxe-button type="submit" status="primary" content="确定" @click="save()"></vxe-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.draggable-list-header {
+  padding: 8px 0;
+  font-size: 16px;
+}
+.draggable-list.empty {
+  border-radius: 8px;
+  border: 1px #111 dashed;
+  height: 100px;
+  > .draggable-list-header {
+    display: none;
+  }
+
+  &::after {
+    content: var(--empty);
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    height: 100%;
+    color: rgba(0, 0, 0, 0.45);
+  }
+}
+
+.draggable-list.selected {
+  --empty: '点击或拖拽下方标签至此区域以配置';
+
+  .draggable-list-item {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 8px 16px;
+    &.has-icon {
+      padding-left: 8px;
+      padding-right: 8px;
+    }
+    cursor: move;
+    border-collapse: collapse;
+    border: 1px solid rgba(0, 0, 0, 0.5);
+    .icon {
+      &.left {
+        padding-right: 8px;
+      }
+      &.right {
+        padding-left: 8px;
+      }
+    }
+  }
+}
+
+.draggable-list.options {
+  --empty: '拖拽至此区域以取消配置';
+  .draggable-list-header {
+    padding-bottom: 0;
+  }
+  &.empty {
+    margin-top: 12px;
+  }
+  .draggable-list-item {
+    margin-top: 8px;
+    cursor: pointer;
+  }
+}
+
+.error {
+  font-size: 12px;
+  color: #ff4d4f;
+}
+</style>

+ 101 - 0
src/pages/aio/flow-config/panel/ReportPanel.vue

@@ -0,0 +1,101 @@
+<script setup lang="ts">
+import { ID_Report_Health } from '../nodes/config';
+import { Key_jumpable, Key_miniProgramCode, Key_payLock, Key_printable } from '../tool';
+
+interface Model {
+  payLock: boolean;
+  miniProgramCode: boolean;
+
+  jumpable?: boolean;
+  printable?: boolean;
+}
+
+const props = defineProps<{ id: string }>();
+const requestData = defineModel<{ key: string; elements: string[] }>('requestData', { default: { elements: [] } });
+
+const isHealthAnalysisReport = computed(() => props.id === ID_Report_Health);
+
+const model = ref<Model>({
+  payLock: true,
+  miniProgramCode: false,
+});
+
+watchEffect(() => reset());
+
+const updatePayLockAndMiniProgramCode = (key: 'payLock' | 'miniProgramCode', value: boolean) => {
+  if (key === 'payLock' && value) model.value.miniProgramCode = true;
+  else if (key === 'miniProgramCode' && !value) model.value.payLock = false;
+};
+
+const error = ref('');
+watch(
+  model,
+  () => {
+    error.value = '';
+  },
+  { deep: true }
+);
+function save() {
+  error.value = '';
+
+  if (model.value.payLock && !model.value.miniProgramCode) {
+    model.value.miniProgramCode = true;
+    nextTick(() => {
+      error.value = `[扫码查看] 需要配置 [小程序码] (请重试)`;
+    });
+  } else {
+    const key = requestData.value.key;
+    const elements: string[] = [];
+    if (model.value.miniProgramCode) elements.push(`${key}_${Key_miniProgramCode}`);
+    if (model.value.payLock) elements.push(`${key}_${Key_payLock}`);
+    if (!model.value.jumpable && isHealthAnalysisReport.value) elements.push(`${key}_${Key_jumpable}`);
+    if (!model.value.printable && isHealthAnalysisReport.value) elements.push(`${key}_${Key_printable}`);
+    requestData.value = { key, elements };
+  }
+}
+function reset() {
+  Object.assign(model.value, { payLock: false, miniProgramCode: false, jumpable: true, printable: true });
+  const { key, elements } = requestData.value;
+  for (const element of elements) {
+    if (!element.startsWith(key)) continue;
+    if (element.endsWith(Key_miniProgramCode)) model.value.miniProgramCode = true;
+    if (element.endsWith(Key_payLock)) model.value.payLock = true;
+    if (element.endsWith(Key_jumpable)) model.value.jumpable = false;
+    if (element.endsWith(Key_printable)) model.value.printable = false;
+  }
+}
+</script>
+
+<template>
+  <div>
+    <a-form-item label="查看报告">
+      <a-switch v-model:checked="model.payLock" checked-children="扫码查看" un-checked-children="完整展示" @change="updatePayLockAndMiniProgramCode('payLock', model.payLock)" />
+    </a-form-item>
+    <a-form-item label="按钮配置">
+      <a-checkbox v-model:checked="model.miniProgramCode" @change="updatePayLockAndMiniProgramCode('miniProgramCode', model.miniProgramCode)">小程序码</a-checkbox>
+      <template v-if="isHealthAnalysisReport">
+        <a-space :size="12">
+          <a-checkbox v-model:checked="model.printable">打印分析报告</a-checkbox>
+          <a-checkbox v-model:checked="model.jumpable">加载调理方案</a-checkbox>
+        </a-space>
+      </template>
+    </a-form-item>
+    <div class="flex items-center" style="margin-top: 10px">
+      <div class="flex-auto error">{{ error }}</div>
+      <div class="flex-none">
+        <vxe-button type="reset" content="重置" @click="reset()"></vxe-button>
+        <vxe-button type="submit" status="primary" content="确定" @click="save()"></vxe-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.ant-form-item {
+  margin-bottom: 12px;
+}
+.error {
+  font-size: 12px;
+  color: #ff4d4f;
+}
+</style>

+ 154 - 0
src/pages/aio/flow-config/panel/StartPanel.vue

@@ -0,0 +1,154 @@
+<script setup lang="ts">
+interface Model {
+  copyright?: string;
+
+  elements?: string[];
+
+  homeType?: number;
+  homeValue?: string;
+}
+
+const Preset_Image_1 = `preset:1;el:scan|btn;`;
+const Preset_Image_2 = `preset:2;el:scan;`;
+
+const props = defineProps<{ id: string }>();
+const requestData = defineModel<{ partner?: string; technicalSupporter?: string; tabletDrainageImage?: string }>('requestData', { default: {} });
+
+const model = ref<Model>({});
+
+watchEffect(() => reset());
+
+const error = ref('');
+watch(
+  [model],
+  () => {
+    error.value = '';
+  },
+  { deep: true }
+);
+function save() {
+  error.value = '';
+  const values = model.value;
+  if (values.homeType === 99 && !values.homeValue?.trim()) {
+    error.value = `[自定义] 起始页需要配置值`;
+  } else {
+    let image = values.homeValue;
+    if (values.homeType === 1) image = Preset_Image_1.split(';')[0];
+    else if (values.homeType === 2) image = Preset_Image_2.split(';')[0];
+    else if (values.homeType === 99 && !image?.includes('preset:')) image = `preset:99;${image}`;
+
+    const snippets = image?.replace(/\s/g, ';')?.split(';').filter(Boolean) ?? [];
+    const elIndex = snippets.findIndex((s) => s.startsWith('el'));
+
+    if (elIndex > -1) {
+      const els = snippets[elIndex].split('|').filter((k) => k != 'btn' && k != 'scan');
+      els.push(...values.elements!);
+      snippets[elIndex] = els.join('|');
+    } else {
+      snippets.push(`el:${values.elements?.join('|')}`);
+    }
+
+    const [partner, technicalSupporter] = values.copyright?.trim()?.split(/\s/) ?? [];
+    requestData.value = { partner, technicalSupporter, tabletDrainageImage: snippets.join(';') };
+  }
+}
+function reset() {
+  Object.assign(model.value, { copyright: '', homeType: 1, homeValue: '', elements: [] });
+  const { partner, technicalSupporter, tabletDrainageImage } = requestData.value;
+  let image = tabletDrainageImage;
+  if (!image || /^preset:1;?$/.test(image)) image = Preset_Image_1;
+  else if (/^preset:2;?$/.test(image)) image = Preset_Image_2;
+
+  model.value.copyright = [partner, technicalSupporter].filter(Boolean).join('\n');
+  const elements = [];
+  const snippets = [];
+  for (const snippet of image.split(';')) {
+    const [key, config] = snippet.split(':');
+    if (key === 'preset') model.value.homeType = +config || 1;
+    else if (key === 'el') {
+      const els = config.split('|');
+      if (els.includes('btn')) elements.push('btn');
+      if (els.includes('scan')) elements.push('scan');
+
+      const rest = els.filter((k) => k != 'btn' && k != 'scan').join('|');
+      if (rest) snippets.push(`${key}:${rest}`);
+    } else snippets.push(snippet);
+  }
+  model.value.elements = [...elements];
+  model.value.homeValue = snippets.join(';');
+}
+
+const updateHomeType = () => {
+  let image = '';
+  if (model.value.homeType === 1) image = Preset_Image_1;
+  else if (model.value.homeType === 2) image = Preset_Image_2;
+  if (image) {
+    const [_, el] = image.match(/el:([^;]*)/) ?? [];
+    model.value.elements = el?.split('|') ?? [];
+  }
+};
+</script>
+
+<template>
+  <div>
+    <a-form-item label="起始页">
+      <a-radio-group v-model:value="model.homeType" @change="updateHomeType()">
+        <a-radio :value="1">预设:默认</a-radio>
+        <a-radio :value="2">预设:萧山</a-radio>
+        <a-radio :value="99">
+          <template v-if="model.homeType === 99">
+            <div class="flex-none">自定义:</div>
+            <a-textarea class="small" v-model:value="model.homeValue" :auto-size="{ minRows: 1, maxRows: 10 }" placeholder="page:~;com:~;img:~;"></a-textarea>
+          </template>
+          <template v-else>自定义</template>
+        </a-radio>
+      </a-radio-group>
+      <div class="mt-2 pt-2" style="border-top: 1px #d9d9d9 dashed">
+        <a-checkbox-group
+          v-model:value="model.elements"
+          :options="[
+            { label: '显示检测按钮', value: 'btn' },
+            { label: '支持扫码开始', value: 'scan' },
+          ]"
+        />
+      </div>
+    </a-form-item>
+    <a-form-item label="版权信息">
+      <a-textarea v-model:value="model.copyright" placeholder="最多显示两行文本" :auto-size="{ minRows: 2, maxRows: 2 }" />
+    </a-form-item>
+    <div class="flex items-center" style="margin-top: 10px">
+      <div class="flex-auto error">{{ error }}</div>
+      <div class="flex-none">
+        <vxe-button type="reset" content="重置" @click="reset()"></vxe-button>
+        <vxe-button type="submit" status="primary" content="确定" @click="save()"></vxe-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.ant-form-item {
+  margin-bottom: 12px;
+  ::placeholder {
+    color: #d4d5d7;
+    opacity: 1;
+  }
+}
+.ant-radio-group {
+  width: 100%;
+}
+.ant-radio-wrapper {
+  display: flex;
+  width: 100%;
+  line-height: 30px;
+  :deep(span:not(.ant-radio)) {
+    flex: auto;
+    display: flex;
+    align-items: center;
+  }
+}
+.error {
+  font-size: 12px;
+  color: #ff4d4f;
+}
+</style>

+ 226 - 0
src/pages/aio/flow-config/tool.ts

@@ -0,0 +1,226 @@
+import {
+  alcoholAnalysisReport,
+  edge,
+  end,
+  healthAnalysis,
+  healthAnalysisReport,
+  healthAnalysisScheme,
+  ID_Analysis_Health,
+  ID_Analysis_Pulse,
+  ID_Analysis_TongueAndFace,
+  ID_End,
+  ID_Register,
+  ID_Report_Alcohol,
+  ID_Report_Health,
+  ID_Report_Pulse,
+  ID_Report_TongueAndFace,
+  ID_Scheme_Health,
+  ID_Start,
+  pulseAnalysis,
+  pulseAnalysisReport,
+  register,
+  start,
+  tongueAndFaceAnalysis,
+  tongueAndFaceAnalysisReport,
+} from './nodes/config';
+import type { LogicFlowGraphData } from '@/libs/logic-flow';
+
+const nodeRef = {
+  [ID_Start]: 'screen',
+  [ID_End]: 'screen',
+  [ID_Register]: /* 建档页 */ 'patient_file',
+  [ID_Analysis_Pulse]: /* 脉诊页 */ 'pulse_upload',
+  [ID_Analysis_TongueAndFace]: /*拍照页*/ 'tongueface_upload',
+  [ID_Analysis_Health]: /* 问卷页 */ 'tongueface_analysis',
+  [ID_Report_Pulse]: /* 脉诊结果页 */ 'pulse_upload_result',
+  [ID_Report_TongueAndFace]: 'tongueface_analysis_result',
+  [ID_Report_Health]: /* 健康报告页 */ 'health_analysis',
+  [ID_Scheme_Health]: /* 调理方案页 */ 'health_analysis_scheme',
+  [ID_Report_Alcohol]: /* 酒精结果页 */ 'alcohol_upload_result',
+} as const;
+
+export type NodeId = keyof typeof nodeRef;
+export type Gather = { level: number; sourceNodeId: NodeId; targetNodeId: NodeId; edgeId?: string }[];
+
+const refNode = ((ref) => Object.keys(ref).reduce((record, key) => ((record[ref[<NodeId>key]] = <NodeId>key), record), {} as Record<string, NodeId>))(nodeRef);
+
+export function toFlowRequestData(gather: Gather, data: Record<string, any>) {
+  const value: string[] = [];
+  const _gather = [...gather].sort((g1, g2) => g1.level - g2.level || +(g1.targetNodeId === ID_End) - +(g2.targetNodeId === ID_End));
+  for (const { targetNodeId } of _gather) {
+    if (targetNodeId === ID_End) {
+      value.push(value.pop()!.replace(/\?*$/, '?'));
+    } else value.push(nodeRef[targetNodeId]);
+  }
+  const getReportRequestData = (key: string) => data[key]?.elements ?? [];
+  return {
+    tabletProcessModules: value,
+    tabletFileFields: data[ID_Register] ?? [],
+    tabletRequiredPageOperationElements: [
+      ...getReportRequestData(ID_Report_TongueAndFace),
+      ...getReportRequestData(ID_Report_Health),
+      ...getReportRequestData(ID_Scheme_Health),
+    ] as string[],
+    ...(data[ID_Start] as {
+      partner: string;
+      technicalSupporter: string;
+      tabletDrainageImage: string;
+    }),
+  } as const;
+}
+
+export type FlowRequestData = Partial<ReturnType<typeof toFlowRequestData>>;
+
+type ReportPrefixKey = 'tongueface_upload_report_page' | 'health_analysis_report_page' | 'health_analysis_scheme_page';
+
+export function fromFlowRequestData(data?: FlowRequestData) {
+  const getReportRequestData = (key: ReportPrefixKey) => ({
+    key,
+    elements: Array.isArray(data?.tabletRequiredPageOperationElements) ? data.tabletRequiredPageOperationElements.filter((item) => item.startsWith(key)) : [],
+  });
+
+  const group = [
+    [register({ requestData: Array.isArray(data?.tabletFileFields) ? data.tabletFileFields : [] })],
+    [pulseAnalysis(), pulseAnalysisReport()],
+    [tongueAndFaceAnalysis(), tongueAndFaceAnalysisReport({ requestData: getReportRequestData('tongueface_upload_report_page') })],
+    [healthAnalysis()],
+    [alcoholAnalysisReport()],
+    [
+      healthAnalysisReport({ requestData: getReportRequestData('health_analysis_report_page') }),
+      healthAnalysisScheme({ requestData: getReportRequestData('health_analysis_scheme_page') }),
+    ],
+  ];
+
+  const nodes = new Set<string>([ID_Start, ID_End]);
+  const edges: LogicFlowGraphData['edges'] = [];
+  const flow = Array.isArray(data?.tabletProcessModules) ? [...data.tabletProcessModules] : [];
+  if (flow.length && flow[0] === nodeRef[ID_Start]) flow.shift();
+  if (flow.length && flow[flow.length - 1] === nodeRef[ID_End]) flow.pop();
+  flow.unshift(ID_Start);
+  for (let i = 1; i < flow.length; i++) {
+    const [source] = flow[i - 1].split(/[?:]/).filter(Boolean);
+    const [target, title, countDown] = flow[i].split(/[?:]/).filter(Boolean);
+    const optional = flow[i].includes('?');
+
+    const sourceNodeId = refNode[source] ?? source;
+    const targetNodeId = refNode[target] ?? target;
+
+    nodes.add(sourceNodeId).add(targetNodeId);
+
+    edges.push(edge(sourceNodeId, targetNodeId));
+    if (optional) edges.push(edge(sourceNodeId, ID_End, { title, countDown }));
+  }
+
+  return {
+    graph: {
+      nodes: Array.from(nodes, (id) => {
+        if (id === ID_Start)
+          return start({
+            requestData: {
+              partner: data?.partner,
+              technicalSupporter: data?.technicalSupporter,
+              tabletDrainageImage: data?.tabletDrainageImage,
+            },
+          });
+        if (id === ID_End) return end();
+        for (const items of group) for (const node of items) if (node.id == id) return node;
+        return void 0;
+      }).filter(Boolean),
+      edges,
+    } as LogicFlowGraphData,
+    group,
+  };
+}
+
+export const Field_Card = 'cardno';
+export const Field_Phone = 'phone';
+export function analysisRegisterFields(fields?: string[]) {
+  const config = [
+    [Field_Card, '身份证号', true],
+    [Field_Phone, '手机号码', true],
+    ['name', '姓名'],
+    ['age', '年龄'],
+    ['sex', '性别'],
+    ['height', '身高'],
+    ['weight', '体重'],
+    ['womenSpecialPeriod', '女性特殊期'],
+    ['isEasyAllergy', '容易过敏'],
+    ['foodAllergy', '食物过敏'],
+    ['hobbyFlavor', '喜好口味'],
+    ['drinkState', '饮酒情况'],
+    ['smokeState', '吸烟情况'],
+    ['address', '现住址'],
+    ['detailAddress', '详细地址'],
+    ['job', '职业'],
+  ] as const;
+
+  if (!Array.isArray(fields)) fields = [];
+  type Option = { id: string; name: string; required?: boolean };
+  const options: Option[] = config.map(([id, name, required = false]) => ({ id, name, required }));
+  const selected: Option[] = [];
+  for (const item of fields) {
+    const [id, required] = item.split(':');
+    if (id === 'code') continue;
+    const index = options.findIndex((item) => item.id === id);
+    if (index > -1) selected.push({ ...options.splice(index, 1)[0], required: required === 'required' || required === 'true' });
+  }
+
+  return { config, options, selected };
+}
+
+export const Key_miniProgramCode = 'appletbutton';
+export const Key_payLock = 'appletscan';
+export const Key_jumpable = 'notjump';
+export const Key_printable = 'notprint';
+
+export function analysisRequestData(data?: FlowRequestData): Record<
+  NodeId,
+  {
+    has: boolean;
+    optional: boolean;
+    format: string;
+    fields?: { id: string; name: string; required?: boolean }[];
+    payLock?: boolean;
+    miniProgramCode?: boolean;
+    jumpable?: boolean;
+    printable?: boolean;
+  }
+> {
+  const get = (id: NodeId) => {
+    const key = data?.tabletProcessModules?.find((key) => key.startsWith(nodeRef[id])) ?? '';
+    const has = !!key;
+    const optional = key.includes('?');
+    return { has, optional, format: has ? `有${optional ? '(可选)' : ''}` : '无' };
+  };
+
+  const report = (id: NodeId, prefix: ReportPrefixKey) => {
+    const values = get(id);
+    if (values.has) {
+      const payLock = data?.tabletRequiredPageOperationElements?.includes(`${prefix}_${Key_payLock}`);
+      const mini = data?.tabletRequiredPageOperationElements?.includes(`${prefix}_${Key_miniProgramCode}`);
+      values.format += `(${mini && payLock ? '扫码查看' : '完整展示'})`;
+      Object.assign(values, { payLock: mini && payLock, miniProgramCode: mini });
+      if (id === ID_Report_Health) {
+        const jumpable = !data?.tabletRequiredPageOperationElements?.includes(`${prefix}_${Key_jumpable}`);
+        const printable = !data?.tabletRequiredPageOperationElements?.includes(`${prefix}_${Key_printable}`);
+        Object.assign(values, { jumpable, printable });
+      }
+    }
+    return values;
+  };
+
+  return {
+    [ID_Register]: {
+      ...get(ID_Register),
+      fields: analysisRegisterFields(data?.tabletFileFields).selected,
+    },
+    [ID_Analysis_Pulse]: get(ID_Analysis_Pulse),
+    [ID_Analysis_TongueAndFace]: get(ID_Analysis_TongueAndFace),
+    [ID_Analysis_Health]: get(ID_Analysis_Health),
+    [ID_Report_Pulse]: get(ID_Report_Pulse),
+    [ID_Report_TongueAndFace]: report(ID_Report_TongueAndFace, 'tongueface_upload_report_page'),
+    [ID_Report_Alcohol]: get(ID_Report_Alcohol),
+    [ID_Report_Health]: report(ID_Report_Health, 'health_analysis_report_page'),
+    [ID_Scheme_Health]: report(ID_Scheme_Health, 'health_analysis_scheme_page'),
+  } as any;
+}

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio