Sfoglia il codice sorgente

Merge branch 'develop' of ssh://121.43.162.141:10022/six.fe/health.admin into develop

# Conflicts:
#	src/pages/index/equipment/configured.vue
张田田 6 mesi fa
parent
commit
c8b13aaf8c

+ 18 - 0
@types/global.d.ts

@@ -0,0 +1,18 @@
+export {};
+
+declare global {
+  /**
+   * Promise 扩展
+   */
+  interface PromiseConstructor {
+    withResolvers<T>(): {
+      promise: Promise<T>;
+      resolve: (value: T | PromiseLike<T>) => void;
+      reject: (reason?: any) => void;
+    };
+  }
+
+  interface Array<T> {
+    at(index: number): T;
+  }
+}

+ 15 - 17
src/components/EditMoreConfigured.vue

@@ -1,4 +1,5 @@
 <script setup lang="ts">
+import { tryOnMounted } from '@vueuse/core';
 import { useRequest } from 'alova/client';
 import { batchUpdateDeviceManageMethod, getDeviceManageDetailMethod, updateDeviceManageMethod } from '@/request/api/device.api';
 import { notification } from 'ant-design-vue';
@@ -35,7 +36,7 @@ const tableData = computed<DeviceManageModel[]>(() => {
 
 // 设备ID增删在批量配置不需要
 let ids = ref<string[]>([]);
-onBeforeMount(async () => {
+tryOnMounted(async () => {
   if (props.data && Array.isArray(props.data) && props.data.length > 1) {
     props.data?.forEach((item: any) => {
       ids.value.push(item.id);
@@ -48,15 +49,12 @@ onBeforeMount(async () => {
 // 初始化数据
 const mock = () => {
   return {
-    tabletProcessModules: ['patient_file', 'tongueface_upload', 'tongueface_analysis', 'health_analysis?'],
-    tabletFileFields: ['phone:required', 'cardno', 'name', 'sex', 'age', 'height', 'weight', 'is_easy_allergy'],
+    tabletProcessModules: ['patient_file', 'tongueface_upload', 'tongueface_analysis', 'health_analysis'],
+    tabletFileFields: ['phone:required', 'sex', 'age', 'isEasyAllergy'],
     tabletRequiredPageOperationElements: [
-      'tongueface_upload_report_page_appletscan',
-      'health_analysis_report_page_appletbutton',
-      'health_analysis_report_page_appletscan',
-      'health_analysis_scheme_page_appletbutton',
+      'health_analysis_report_page_appletbutton'
     ],
-    technicalSupporter: '杭州六智科技有限公司',
+    technicalSupporter: '',
   };
 };
 const flowRef = useTemplateRef<InstanceType<typeof AioFlowConfig>>('flow');
@@ -76,8 +74,8 @@ const reset = () => {
 </script>
 
 <template>
-  <div class="two-pane">
-    <div class="left form-container">
+  <div class="two-pane h-full">
+    <div class="left form-container flex flex-col">
       <!-- 流程配置 -->
       <div class="flex-auto content-container">
         <div class="title">流程配置</div>
@@ -92,9 +90,9 @@ const reset = () => {
       </div>
     </div>
     <!-- 右侧设备列表 -->
-    <div class="right table-container">
-      <div class="table-title">待配置设备</div>
-      <div class="table-wrapper">
+    <div class="right table-container flex flex-col">
+      <div class="table-title flex-none">待配置设备</div>
+      <div class="table-wrapper flex-auto">
         <table class="simple-table">
           <thead>
             <tr>
@@ -119,7 +117,7 @@ const reset = () => {
 <style scoped lang="scss">
 .content-container {
   width: 100%;
-  height: 600px;
+  //height: 600px;
   display: flex;
   flex-direction: column;
   > div:not(.title) {
@@ -140,7 +138,7 @@ const reset = () => {
   flex: 1;
 }
 .right {
-  width: 40%;
+  width: 30%;
   min-width: 420px;
 }
 .table-title {
@@ -149,7 +147,7 @@ const reset = () => {
 }
 .table-wrapper {
   border: 1px solid #eee;
-  height: 100%;
+  //height: 100%;
   overflow: auto;
 }
 .simple-table {
@@ -163,7 +161,7 @@ const reset = () => {
   text-align: left;
 }
 .form-container {
-  padding: 20px;
+  //padding: 20px;
 }
 .section-divider {
   height: 1px;

+ 3 - 0
src/libs/logic-flow/constant.ts

@@ -1,2 +1,5 @@
 export const START_ID = `start`;
 export const END_ID = `end`;
+
+export const START_NODE = `StartNode`
+export const END_NODE = `EndNode`

+ 34 - 0
src/libs/logic-flow/index.ts

@@ -15,6 +15,8 @@ export { VLogicFlow };
 
 export type GraphData = LogicFlow.GraphConfigData;
 
+const Threshold = 100;
+
 export default function init(
   lf: LogicFlowInstance,
   config?: {
@@ -46,6 +48,38 @@ export default function init(
     }, 300);
   });
 
+  let insertNodeInPolylineTime = 0;
+  let delayInsertNodeInPolyline: ReturnType<typeof setTimeout>;
+  let cacheEdgePoint: string[] | void = void 0
+  listener('edge:delete', () => {
+    insertNodeInPolylineTime = Date.now();
+    cacheEdgePoint = [];
+    setTimeout(() => { cacheEdgePoint = void 0; }, Threshold * 2)
+  });
+  listener('edge:add', (event) => {
+    clearTimeout(delayInsertNodeInPolyline);
+    if (Array.isArray(cacheEdgePoint)) cacheEdgePoint.push(event.data.sourceNodeId, event.data.targetNodeId);
+    if (Date.now() - insertNodeInPolylineTime < Threshold)
+      delayInsertNodeInPolyline = setTimeout(() => {
+        if (Array.isArray(cacheEdgePoint) && cacheEdgePoint.length === 4 && cacheEdgePoint[1] === cacheEdgePoint[2]) {
+          const id = cacheEdgePoint[1];
+          lf.graphModel.eventCenter.emit('node:insert', { data: lf.getNodeModelById(id)?.getData() });
+        }
+      }, Threshold);
+  });
+  /**
+   * 拖拽节点插入边报错
+   */
+  let notConnectionId: string | void;
+  listener('connection:not-allowed', (event) => {
+    notConnectionId = event.data.id;
+    setTimeout(() => { notConnectionId = void 0 }, Threshold);
+    clearTimeout(delayInsertNodeInPolyline);
+  });
+  listener('node:dnd-add', (event) => {
+    if (event.data.id === notConnectionId) setTimeout(() => lf.graphModel.eventCenter.emit('node:delete', event as any), Threshold)
+  });
+
   /**
    * 增强功能
    * @description 渐进连线成功触发 [edge:proximity-connect] 事件

+ 1 - 1
src/main.ts

@@ -1,6 +1,6 @@
 import 'virtual:uno.css';
 import '@/themes/index.scss';
-
+import './polyfill'
 import vxe    from '@/libs/vxe';
 import router from '@/router';
 import pinia  from '@/stores';

File diff suppressed because it is too large
+ 0 - 0
src/pages/aio/flow-config/assets/finish.svg


+ 75 - 13
src/pages/aio/flow-config/index.vue

@@ -4,11 +4,11 @@ import { h } from 'vue';
 import { tryOnUnmounted, useParentElement } from '@vueuse/core';
 import { VxeUI } from 'vxe-pc-ui';
 
-import { notification } from 'ant-design-vue';
+import { Button, 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 { ProximityConnect, InsertNodeInPolyline } from '@logicflow/extension';
 import { Dagre } from '@logicflow/layout';
 import VLogicFlowInit, { type LogicFlowInstance, type LogicFlowOptions, VLogicFlow, type VLogicFlowInstance } from '@/libs/logic-flow';
 
@@ -33,7 +33,7 @@ const options = reactive<LogicFlowOptions>({
   snapToGrid: true,
   snapline: false,
   textEdit: false,
-  plugins: [Dagre, ProximityConnect],
+  plugins: [Dagre, ProximityConnect, InsertNodeInPolyline],
   pluginsOptions: {
     proximityConnect: {
       enable: true, // 插件是否启用
@@ -54,7 +54,10 @@ let instance!: VLogicFlowInstance;
 let isUnmounted = false;
 const hasInstance = () => !isUnmounted && !!instance && !!instance.lf;
 // end
+let oldValue: FlowRequestData;
+const maxPanelHeight = ref(0);
 const init = (lf: LogicFlowInstance): void => {
+  maxPanelHeight.value = lf.container.getBoundingClientRect().height - 80;
   instance = VLogicFlowInit(lf, {
     register: [{ category: 'node', type: 'FlowNode', view: FlowNodeView, model: FlowNodeViewModel }],
   });
@@ -63,7 +66,7 @@ const init = (lf: LogicFlowInstance): void => {
     watchPostEffect(() => {
       const value = requestData.value;
       console.log('[AioFlowConfig] 接收 request-data 数据', value);
-      update(value);
+      if (!oldValue || oldValue?.timestamp !== value?.timestamp) update(value);
     });
   });
 
@@ -115,8 +118,51 @@ const init = (lf: LogicFlowInstance): void => {
     if (index > -1) nodes.value.splice(index, 1);
   });
   // @ts-ignore
-  instance.listener('edge:proximity-connect', () => {
+  /*instance.listener('edge:proximity-connect', () => {
     updateLayout();
+  });*/
+  // @ts-ignore
+  instance.listener('node:insert', (event: CallbackArgs<'data'>) => {
+    notification.success({
+      key: 'connection:node:insert',
+      message: '插入节点成功',
+      description: () =>
+        h(
+          Button,
+          {
+            type: 'primary',
+            size: 'small',
+            onClick: () => {
+              validate().catch();
+              notification.close('connection:node:insert');
+            },
+          },
+          { default: () => '检测连接' }
+        ),
+      top: '12px',
+      getContainer: () => (el.value as HTMLElement) ?? document.body,
+    });
+  });
+  // @ts-ignore
+  instance.listener('connection:quick', (event: any) => {
+    const add = (id: string) => {
+      for (const group of nodeGroup.value) {
+        const node = group.find((node) => node.id === id);
+        if (node) return instance.lf.addNode(node);
+      }
+      return void 0;
+    };
+    const { sourceNodeId, targetNodeId } = event;
+    const source = instance.lf.getNodeModelById(sourceNodeId) ?? add(sourceNodeId);
+    const target = instance.lf.getNodeModelById(targetNodeId) ?? add(targetNodeId);
+    if (source && target) {
+      const sourceEdge = instance.lf.getNodeOutgoingEdge(sourceNodeId);
+      const targetEdge = instance.lf.getNodeIncomingEdge(targetNodeId);
+      for (const edge of sourceEdge) instance.lf.deleteEdge(edge.id);
+      for (const edge of targetEdge) instance.lf.deleteEdge(edge.id);
+      instance.lf.addEdge({ sourceNodeId, targetNodeId });
+      updateLayout();
+    }
   });
 };
 
@@ -220,7 +266,7 @@ const update = (data?: FlowRequestData) => {
 
   instance.lf.renderRawData(graph);
   updateLayout('TB');
-  if (graph.nodes && graph.nodes.length > 2) updateLayout('center');
+  if (graph.nodes && graph.nodes.length > 2) setTimeout(() => updateLayout('center'), 100);
 };
 const validate = (tips = true) => {
   if (!hasInstance()) return Promise.reject(new Error('LogicFlow 已销毁'));
@@ -259,18 +305,19 @@ const validate = (tips = true) => {
 
   const start = instance.lf.getNodeModelById(Node.ID_Start);
 
-  let gather;
+  let gather: Gather;
   const { promise, resolve, reject } = withResolvers<{ gather: Gather; data?: FlowRequestData; message?: string }>();
   try {
-    gather = map(start);
+    gather = map(start).sort((g1, g2) => g1.level - g2.level || +(g1.targetNodeId === Node.ID_Back) - +(g2.targetNodeId === Node.ID_Back));
 
     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);
+    oldValue = toFlowRequestData(gather, data);
+    requestData.value = oldValue;
+    resolve({ gather, data: oldValue });
+    console.log('[AioFlowConfig] 更新 request-data 数据: ', oldValue);
   } catch (error: any) {
     if (tips) {
       notification.error({
@@ -286,9 +333,12 @@ const validate = (tips = true) => {
   }
 
   if (Array.isArray(gather) && gather.length) {
+    const last = gather.at(-1);
+    const notOpenEdgeId = last.targetNodeId === Node.ID_Back ? last.edgeId : void 0;
     for (const { edgeId } of gather) {
       instance.lf.setProperties(edgeId, { isAnimation: true });
-      instance.lf.openEdgeAnimation(edgeId);
+      if (notOpenEdgeId === edgeId) instance.lf.closeEdgeAnimation(notOpenEdgeId);
+      else instance.lf.openEdgeAnimation(edgeId);
     }
   } else {
     for (const edge of instance.lf.getGraphRawData().edges) {
@@ -326,7 +376,7 @@ defineExpose({
           </template>
         </a-button>
         <template #overlay>
-          <a-card size="small" style="width: 370px">
+          <a-card size="small" style="width: 370px; overflow-y: auto;" :style="{ maxHeight: maxPanelHeight + 'px' }">
             <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) }"
@@ -338,6 +388,12 @@ defineExpose({
                 @mousedown="startDragPanelNode(node, $event)"
               />
             </div>
+            <a-space direction="vertical" class="tips-wrapper">
+              <div>添加流程:从左侧拖入</div>
+              <div>编辑流程:单击</div>
+              <div>删除流程和连线:双击</div>
+              <div>连接流程:从上流节点中拖拽锚点到下流节点以连接</div>
+            </a-space>
           </a-card>
         </template>
       </a-dropdown>
@@ -379,6 +435,12 @@ defineExpose({
   left: 24px;
   z-index: 1;
 }
+.tips-wrapper {
+  width: 100%;
+  padding: 4px;
+  color: rgba(0, 0, 0, 0.8);
+  border: 1px #bbbbbb dashed;
+}
 .ant-float-btn-group {
   position: absolute !important;
   bottom: 24px;

+ 39 - 19
src/pages/aio/flow-config/nodes/FlowNode.model.ts

@@ -1,5 +1,5 @@
 import { RectNodeModel } from '@logicflow/core';
-import { END_ID } from '@/libs/logic-flow/constant';
+import { END_ID, END_NODE, START_ID } from '@/libs/logic-flow/constant';
 import type { FlowNodeAnchor, FlowNodeConnectRuleResult, FlowNodeProperties } from './index';
 
 export default class FlowNodeModel extends RectNodeModel<FlowNodeProperties> {
@@ -14,15 +14,22 @@ export default class FlowNodeModel extends RectNodeModel<FlowNodeProperties> {
     if (source === target) return { isAllPass: false, msg: `节点不能连接自身` };
 
     // 获取当前节点禁止直接连接
-    const forbidDirectTarget = source.getProperties().forbidDirectTarget ?? [];
+    const forbidDirectTarget = getProperties(source).forbidDirectTarget ?? [];
     if (forbidDirectTarget.includes(target.id))
       return {
         isAllPass: false,
         msg: `当前 [${source.text.value}] 节点不能直接连接 [${forbidDirectTarget.map((id) => this.graphModel.getNodeModelById(id)?.text.value).join(' | ')}] 节点`,
       };
+    // 获取目标节点禁止直接连接
+    const forbidDirectSource = getProperties(target).forbidDirectSource ?? [];
+    if (forbidDirectSource.includes(source.id))
+      return {
+        isAllPass: false,
+        msg: `目标 [${target.text.value}] 节点不能直接连接 [${forbidDirectSource.map((id) => this.graphModel.getNodeModelById(id)?.text.value).join(' | ')}] 节点`,
+      };
 
     // 获取目标节点 forbidSource
-    let forbidSource = target.getProperties().forbidSource ?? [];
+    let forbidSource = getProperties(target).forbidSource ?? [];
     if (forbidSource.includes(source.id)) return { isAllPass: false, msg: `目标 [${target.text.value}] 节点不能在当前 [${source.text.value}] 节点之后` };
 
     // 获取当前节点的上游节点
@@ -47,9 +54,9 @@ export default class FlowNodeModel extends RectNodeModel<FlowNodeProperties> {
     do {
       const targetId = next.shift()!;
       visited.add(targetId);
-      const directNodes = target.graphModel.getNodeOutgoingNode(targetId);
+      const directNodes = this.graphModel.getNodeOutgoingNode(targetId);
       for (const directNode of directNodes) {
-        forbidSource = (directNode.getProperties().forbidSource as string[]) ?? [];
+        forbidSource = (getProperties(directNode).forbidSource as string[]) ?? [];
         if (forbidSource.includes(source.id))
           return {
             isAllPass: false,
@@ -63,6 +70,12 @@ export default class FlowNodeModel extends RectNodeModel<FlowNodeProperties> {
   }
 
   override isAllowConnectedAsTarget(...args: any[]): FlowNodeConnectRuleResult {
+    // 当前节点是否开始
+    if (this.id === START_ID)
+      return {
+        isAllPass: false,
+        msg: `当前 [${this.text.value}] 为开始节点不允许输入`,
+      };
     // 获取目标节点连接到的所有起始节点
     const directNodes = this.graphModel.getNodeIncomingNode(this.id);
     if (directNodes.length >= 1)
@@ -72,40 +85,47 @@ export default class FlowNodeModel extends RectNodeModel<FlowNodeProperties> {
       };
 
     // 获取目标节点 onlySource
-    const onlySource = (this.getProperties().onlySource as string[]) ?? [];
+    const onlySource = (getProperties(this).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 {
+    // 当前节点是否结束
+    if (this.id === END_ID)
+      return {
+        isAllPass: false,
+        msg: `当前 [${this.text.value}] 为结束节点不允许输出`,
+      };
     // 获取当前节点所有的下一级节点
-    const directNodes = this.graphModel.getNodeOutgoingNode(this.id).filter((node) => node.id !== END_ID);
-    if (directNodes.length >= 1 && args[0].id !== END_ID)
+    const directNodes = this.graphModel.getNodeOutgoingNode(this.id).filter((node) => (node.type as string) !== END_NODE);
+    if (directNodes.length >= 1 && args[0].type !== END_NODE)
       return {
         isAllPass: false,
         msg: `当前 [${this.text.value}] 节点已存在 ${directNodes.length}个输出 (${directNodes.map((node) => node.text.value)})`,
       };
 
-    // 当前节点是否结束
-    if (this.getProperties().finish && args[0].id !== END_ID)
+    // 获取目标节点 onlyTarget
+    const onlyTarget = (getProperties(this).onlyTarget as string[]) ?? [];
+    if (onlyTarget.length && !onlyTarget.includes(args[0].id))
       return {
         isAllPass: false,
-        msg: `当前 [${this.text.value}] 为结束节点不允许输出`,
+        msg: `当前 [${this.text.value}] 节点仅允许 [${onlyTarget.map((id) => this.graphModel.getNodeModelById(id)?.text.value).join(' | ')}] 节点作为输出`,
       };
 
     return this.isAllowConnected(this, args[0], args[1], args[2], args[3]);
   }
 }
+
+function getProperties(model: FlowNodeModel | { properties: FlowNodeProperties }) {
+  try {
+    return model instanceof FlowNodeModel ? model.getProperties() : model?.properties ?? {};
+  } catch {
+    return model?.properties ?? {};
+  }
+}

+ 104 - 1
src/pages/aio/flow-config/nodes/FlowNode.vue

@@ -5,6 +5,7 @@ import { VueNodeModel } from '@logicflow/vue-node-registry';
 
 import { useEventListener } from '@/libs/logic-flow/use';
 
+import { ID_End, ID_Start } from './config';
 import type { FlowNodeProperties } from './index';
 import FlowNodeInlay from './FlowNodeInlay.vue';
 
@@ -21,6 +22,8 @@ const getGraph = inject<() => GraphModelInstance>('getGraph');
 const text = ref('');
 const id = ref('');
 
+const popover = ref(false);
+
 const properties = ref<FlowNodeProperties>({
   width: 0,
   height: 0,
@@ -29,6 +32,10 @@ const properties = ref<FlowNodeProperties>({
   iconColor: '#000',
 });
 
+const appendNodes = shallowRef<{ id: string; text: string }[]>([]);
+const linkNodes = shallowRef<{ id: string; text: string; disabled?: boolean }[]>([]);
+
+let mouseenterTimer: ReturnType<typeof setTimeout>;
 tryOnMounted(() => {
   const model = getNode?.()!;
   const graph = getGraph?.()!;
@@ -43,15 +50,102 @@ tryOnMounted(() => {
     (event) => updateProperties(event.properties),
     (event) => event.id === id.value
   );
+  useEventListener(
+    graph.eventCenter,
+    'node:dnd-drag',
+    () => {
+      popover.value = false;
+      clearTimeout(mouseenterTimer);
+    },
+    (event) => event.data?.id === id.value
+  );
+  useEventListener(
+    graph.eventCenter,
+    'node:drag',
+    () => {
+      popover.value = false;
+      clearTimeout(mouseenterTimer);
+    },
+    (event) => event.data?.id === id.value
+  );
+  useEventListener(
+    graph.eventCenter,
+    'node:mouseenter',
+    (event) => {
+      mouseenterTimer = setTimeout(() => {
+        popover.value = true;
+        const quickNodes = event.data.properties?.quickNodes ?? [];
+        appendNodes.value = [...quickNodes];
+        linkNodes.value = [];
+        const outgoing = graph.getNodeOutgoingNode(event.data.id).map((node) => node.id);
+        for (const { id } of graph.nodes) {
+          const index = appendNodes.value.findIndex((node) => node.id === id);
+          if (index !== -1) {
+            const node = appendNodes.value.splice(index, 1).at(0);
+            linkNodes.value.push({ ...node, disabled: outgoing.includes(node.id) });
+          }
+        }
+        triggerRef(appendNodes);
+        triggerRef(linkNodes);
+      }, 100);
+    },
+    (event) => event.data?.id === id.value
+  );
 });
 
 function updateProperties(props: FlowNodeProperties) {
   properties.value = Object.assign(properties.value, props);
 }
+
+function configure(event: MouseEvent) {
+  const data = getNode?.().getData();
+  // @ts-ignore
+  if (data) getGraph?.()?.eventCenter?.emit('node:click', { data, e: event, position: void 0 });
+  popover.value = false;
+}
+
+function remove() {
+  const data = getNode?.().getData();
+  if (data) getGraph?.()?.deleteNode(data.id);
+  popover.value = false;
+}
+
+function link(targetNodeId: string) {
+  const data = getNode?.().getData();
+  if (data)
+    getGraph?.()?.eventCenter?.emit('connection:quick', {
+      sourceNodeId: data.id,
+      targetNodeId,
+    });
+  popover.value = false;
+}
 </script>
 
 <template>
-  <FlowNodeInlay class="node" :id :text v-bind="properties"></FlowNodeInlay>
+  <FlowNodeInlay v-if="id === ID_End" class="node" :id :text v-bind="properties"></FlowNodeInlay>
+  <a-popover v-else :title="text" placement="right" trigger="hover" v-model:open="popover" :mouseEnterDelay="1">
+    <template #content>
+      <div class="popover-content-wrapper">
+        <a-space wrap>
+          <a-button type="primary" v-if="properties?.configurable" @click="configure">配置数据</a-button>
+          <a-button danger :disabled="id === ID_End || id === ID_Start" @click.prevent="remove">移除节点</a-button>
+        </a-space>
+        <div style="margin-top: 14px" v-if="appendNodes.length">
+          <div class="title">添加下流节点</div>
+          <a-space wrap>
+            <a-button v-for="node in appendNodes" :key="node.id" type="dashed" @click.prevent="link(node.id)">{{ node.text }}</a-button>
+          </a-space>
+        </div>
+        <div style="margin-top: 14px" v-if="linkNodes.length">
+          <div class="title">连接下流节点</div>
+          <a-space wrap>
+            <a-button v-for="node in linkNodes" :key="node.id" :disabled="node.disabled" type="dashed" @click.prevent="link(node.id)">{{ node.text }}</a-button>
+          </a-space>
+        </div>
+      </div>
+    </template>
+    <FlowNodeInlay class="node" :id :text v-bind="properties"></FlowNodeInlay>
+  </a-popover>
 </template>
 
 <style scoped lang="scss">
@@ -62,4 +156,13 @@ function updateProperties(props: FlowNodeProperties) {
   --icon-background: v-bind(properties.iconBackground);
   --icon-color: v-bind(properties.iconColor);
 }
+
+.popover-content-wrapper {
+  min-width: 300px;
+  .title {
+    margin-bottom: 8px;
+    color: rgba(0, 0, 0, 0.88);
+    font-weight: 600;
+  }
+}
 </style>

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

@@ -3,6 +3,7 @@ import { h } from 'vue';
 import type { FlowNodeProperties } from './index';
 
 import IconStart from '@/pages/aio/flow-config/assets/start.svg';
+import IconFinish from '@/pages/aio/flow-config/assets/finish.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';
@@ -20,6 +21,7 @@ defineOptions({
 
 const Icon = {
   start: IconStart,
+  finish: IconFinish,
   config: IconConfig,
   report: IconReport,
   pulse: IconPulse,

+ 26 - 9
src/pages/aio/flow-config/nodes/config.ts

@@ -1,4 +1,4 @@
-import { END_ID, START_ID } from '@/libs/logic-flow/constant';
+import { END_ID, END_NODE, START_ID } from '@/libs/logic-flow/constant';
 import type { FlowNodeProperties } from './index';
 
 interface CreateNodeOptions extends FlowNodeProperties {
@@ -18,6 +18,7 @@ export const DEFAULT_NODE_HEIGHT = 40;
 
 export const ID_Start = START_ID;
 export const ID_End = END_ID;
+export const ID_Back = 'back';
 export const ID_Register = 'register';
 export const ID_Analysis_Pulse = 'pulseAnalysis';
 export const ID_Analysis_TongueAndFace = 'tongueAndFaceAnalysis';
@@ -48,14 +49,15 @@ const factory = (type: 'StartNode' | 'EndNode' | 'FlowNode', id: string, text: s
 
 const textRef = {
   [ID_Start]: '开始检测',
-  [ID_End]: '返回首页',
+  [ID_End]: '完成检测',
+  [ID_Back]: '返回首页',
   [ID_Register]: '建档',
   [ID_Analysis_Pulse]: '脉象分析',
   [ID_Analysis_TongueAndFace]: '舌面象分析',
   [ID_Analysis_Health]: '问诊',
   [ID_Report_Pulse]: '脉象分析报告',
   [ID_Report_TongueAndFace]: '舌面象分析报告',
-  [ID_Report_Health]: '健康分析报告',
+  [ID_Report_Health]: '报告+方案',
   [ID_Scheme_Health]: '调理方案',
   [ID_Report_Alcohol]: '黄酒建议',
 };
@@ -73,14 +75,24 @@ export function start(options?: CreateNodeOptions) {
     icon: 'start',
     iconBackground: '#cf1322',
     iconColor: '#fff',
-    start: true,
     configurable: true,
+    quickNodes: [ID_Register, ID_Analysis_Pulse, ID_Analysis_TongueAndFace].map((id) => ({ id, text: formatText(id) })),
     ...options,
   });
 }
 
 export function end(options?: CreateNodeOptions) {
-  return factory('EndNode', ID_End, textRef[ID_End], {
+  return factory('FlowNode', ID_End, textRef[ID_End], {
+    icon: 'finish',
+    iconBackground: '#0958d9',
+    iconColor: '#fff',
+    forbidDirectSource: [ID_Start],
+    ...options,
+  });
+}
+
+export function back(options?: CreateNodeOptions) {
+  return factory(END_NODE, ID_Back, textRef[ID_Back], {
     radius: 20,
     ...options,
   });
@@ -93,6 +105,7 @@ export function register(options?: CreateNodeOptions) {
     iconColor: '#fff',
     onlySource: [START_ID],
     configurable: true,
+    quickNodes: [ID_Analysis_Pulse, ID_Analysis_TongueAndFace].map((id) => ({ id, text: formatText(id) })),
     ...options,
   });
 }
@@ -103,6 +116,7 @@ export function pulseAnalysis(options?: CreateNodeOptions) {
     iconBackground: '#d46b08',
     iconColor: '#fff',
     forbidSource: [ID_Report_Pulse],
+    quickNodes: [ID_Analysis_TongueAndFace].map((id) => ({ id, text: formatText(id) })),
     ...options,
   });
 }
@@ -112,6 +126,7 @@ export function tongueAndFaceAnalysis(options?: CreateNodeOptions) {
     iconBackground: '#d46b08',
     iconColor: '#fff',
     forbidSource: [ID_Report_TongueAndFace, ID_Analysis_Health, ID_Report_Alcohol, ID_Report_Health, ID_Scheme_Health],
+    quickNodes: [ID_Analysis_Health, ID_Report_TongueAndFace].map((id) => ({ id, text: formatText(id) })),
     ...options,
   });
 }
@@ -122,6 +137,7 @@ export function healthAnalysis(options?: CreateNodeOptions) {
     iconBackground: '#d46b08',
     iconColor: '#fff',
     forbidSource: [ID_Report_Alcohol, ID_Report_Health, ID_Scheme_Health],
+    quickNodes: [ID_Report_Health, ID_Scheme_Health].map((id) => ({ id, text: formatText(id) })),
     ...options,
   });
 }
@@ -131,7 +147,7 @@ export function pulseAnalysisReport(options?: CreateNodeOptions) {
     icon: 'report',
     iconBackground: '#08979c',
     iconColor: '#fff',
-    forbidDirectTarget: [ID_End],
+    forbidDirectTarget: [ID_Back],
     ...options,
   });
 }
@@ -141,7 +157,8 @@ export function tongueAndFaceAnalysisReport(options?: CreateNodeOptions) {
     icon: 'report',
     iconBackground: '#08979c',
     iconColor: '#fff',
-    forbidDirectTarget: [ID_End],
+    configurable: true,
+    forbidDirectTarget: [ID_Back],
     ...options,
   });
 }
@@ -151,8 +168,8 @@ export function healthAnalysisReport(options?: CreateNodeOptions) {
     icon: 'report',
     iconBackground: '#08979c',
     iconColor: '#fff',
-    finish: true,
     configurable: true,
+    onlyTarget: [ID_End],
     ...options,
   });
 }
@@ -162,8 +179,8 @@ export function healthAnalysisScheme(options?: CreateNodeOptions) {
     icon: 'report',
     iconBackground: '#08979c',
     iconColor: '#fff',
-    finish: true,
     configurable: true,
+    onlyTarget: [ID_End],
     ...options,
   });
 }

+ 4 - 3
src/pages/aio/flow-config/nodes/index.ts

@@ -14,16 +14,17 @@ export {
 export * as Node from './config';
 
 export interface FlowNodeProperties extends IRectNodeProperties {
-  icon?: 'start' | 'config' | 'report' | 'pulse' | 'tongue' | 'questionnaire';
+  icon?: 'start' | 'finish' | 'config' | 'report' | 'pulse' | 'tongue' | 'questionnaire';
   iconBackground?: string;
   iconColor?: string;
 
+  forbidDirectSource?: string[];
   forbidDirectTarget?: string[];
   forbidSource?: string[];
   onlySource?: string[];
+  onlyTarget?: string[];
 
-  start?: boolean;
-  finish?: boolean;
+  quickNodes?: {id: string; text: string}[];
 
   configurable?: boolean;
   requestData?: Record<string, any>;

+ 10 - 5
src/pages/aio/flow-config/panel/StartPanel.vue

@@ -1,4 +1,6 @@
 <script setup lang="ts">
+import { Preset_Image_1, Preset_Image_2 } from '../tool';
+
 interface Model {
   copyright?: string;
 
@@ -8,8 +10,10 @@ interface Model {
   homeValue?: string;
 }
 
-const Preset_Image_1 = `preset:1;el:scan|btn;`;
-const Preset_Image_2 = `preset:2;el:scan;`;
+const copyrightPlaceholder = `\
+某某机构
+杭州六智科技提供技术支持\
+`
 
 const props = defineProps<{ id: string }>();
 const requestData = defineModel<{ partner?: string; technicalSupporter?: string; tabletDrainageImage?: string }>('requestData', { default: {} });
@@ -93,8 +97,8 @@ const updateHomeType = () => {
   <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="1">默认</a-radio>
+        <!--<a-radio :value="2">萧山</a-radio>-->
         <a-radio :value="99">
           <template v-if="model.homeType === 99">
             <div class="flex-none">自定义:</div>
@@ -114,7 +118,8 @@ const updateHomeType = () => {
       </div>
     </a-form-item>
     <a-form-item label="版权信息">
-      <a-textarea v-model:value="model.copyright" placeholder="最多显示两行文本" :auto-size="{ minRows: 2, maxRows: 2 }" />
+      <template #help>最多显示两行文本</template>
+      <a-textarea v-model:value="model.copyright" :placeholder="copyrightPlaceholder" :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>

+ 15 - 7
src/pages/aio/flow-config/tool.ts

@@ -1,5 +1,6 @@
 import {
   alcoholAnalysisReport,
+  back,
   edge,
   end,
   healthAnalysis,
@@ -8,6 +9,7 @@ import {
   ID_Analysis_Health,
   ID_Analysis_Pulse,
   ID_Analysis_TongueAndFace,
+  ID_Back,
   ID_End,
   ID_Register,
   ID_Report_Alcohol,
@@ -33,24 +35,24 @@ const nodeRef = {
   [ID_Analysis_TongueAndFace]: /*拍照页*/ 'tongueface_upload',
   [ID_Analysis_Health]: /* 问诊页 */ 'tongueface_analysis',
   [ID_Report_Pulse]: /* 脉诊结果页 */ 'pulse_upload_result',
-  [ID_Report_TongueAndFace]:  /* 舌面分析报告页 */'tongueface_analysis_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 }[];
+export type Gather = { level: number; sourceNodeId: NodeId; targetNodeId: NodeId | typeof ID_Back; 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) {
+  for (let i = 0; i < gather.length; i++) {
+    const { targetNodeId } = gather[i];
+    if (targetNodeId === ID_Back && i !== gather.length - 1) {
       value.push(value.pop()!.replace(/\?*$/, '?'));
-    } else value.push(nodeRef[targetNodeId]);
+    } else if (targetNodeId !== ID_Back && targetNodeId !== ID_End) value.push(nodeRef[targetNodeId]);
   }
   const getReportRequestData = (key: string) => data[key]?.elements ?? [];
   return {
@@ -66,6 +68,7 @@ export function toFlowRequestData(gather: Gather, data: Record<string, any>) {
       technicalSupporter: string;
       tabletDrainageImage: string;
     }),
+    timestamp: Date.now(),
   } as const;
 }
 
@@ -73,6 +76,9 @@ export type FlowRequestData = Partial<ReturnType<typeof toFlowRequestData>>;
 
 type ReportPrefixKey = 'tongueface_upload_report_page' | 'health_analysis_report_page' | 'health_analysis_scheme_page';
 
+export const Preset_Image_1 = `preset:1;el:btn;`;
+export const Preset_Image_2 = `preset:2;el:scan;`;
+
 export function fromFlowRequestData(data?: FlowRequestData) {
   const getReportRequestData = (key: ReportPrefixKey) => ({
     key,
@@ -89,6 +95,7 @@ export function fromFlowRequestData(data?: FlowRequestData) {
       healthAnalysisReport({ requestData: getReportRequestData('health_analysis_report_page') }),
       healthAnalysisScheme({ requestData: getReportRequestData('health_analysis_scheme_page') }),
     ],
+    [back()],
   ];
 
   const nodes = new Set<string>([ID_Start, ID_End]);
@@ -97,6 +104,7 @@ export function fromFlowRequestData(data?: FlowRequestData) {
   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);
+  flow.push(ID_End);
   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);
@@ -119,7 +127,7 @@ export function fromFlowRequestData(data?: FlowRequestData) {
             requestData: {
               partner: data?.partner,
               technicalSupporter: data?.technicalSupporter,
-              tabletDrainageImage: data?.tabletDrainageImage,
+              tabletDrainageImage: data?.tabletDrainageImage || Preset_Image_1,
             },
           });
         if (id === ID_End) return end();

+ 21 - 0
src/polyfill.ts

@@ -0,0 +1,21 @@
+if (typeof Promise.withResolvers !== 'function') {
+  Promise.withResolvers = function <T>() {
+    let resolve!: (value: T | PromiseLike<T>) => void;
+    let reject!: (reason?: any) => void;
+
+    const promise = new Promise<T>((res, rej) => {
+      resolve = res;
+      reject = rej;
+    });
+
+    return { promise, resolve, reject };
+  };
+}
+
+if (typeof Array.prototype.at !== 'function') {
+  Array.prototype.at = function (index: number) {
+    return index < 0 ? this[this.length + index] : this[index];
+  };
+}
+
+export {};

+ 2 - 1
src/tools/promise.ts

@@ -1,3 +1,4 @@
+// @ts-nocheck
 export function withResolvers<T, R = any>(): {
   promise: Promise<T>;
   resolve: (value: T | PromiseLike<T>) => void;
@@ -16,6 +17,6 @@ export function withResolvers<T, R = any>(): {
       return { promise, resolve, reject };
     };
     withResolvers = fn;
+    return fn();
   }
-  return fn();
 }

Some files were not shown because too many files changed in this diff