cc12458 6 месяцев назад
Родитель
Сommit
86850af8f2
61 измененных файлов с 4145 добавлено и 425 удалено
  1. 1 0
      .gitignore
  2. 18 0
      @types/global.d.ts
  3. 8 0
      @types/typed-router.d.ts
  4. 6 0
      package.json
  5. 292 0
      pnpm-lock.yaml
  6. 151 0
      src/components/EditConfigured.vue
  7. 4 262
      src/components/EditEquirement.vue
  8. 179 0
      src/components/EditMoreConfigured.vue
  9. 9 5
      src/components/Swiper.vue
  10. 178 0
      src/components/TongueAnalysisReport.vue
  11. 78 0
      src/libs/logic-flow/VLogicFlow.vue
  12. 5 0
      src/libs/logic-flow/constant.ts
  13. 5 0
      src/libs/logic-flow/edges/index.ts
  14. 22 0
      src/libs/logic-flow/edges/polyline.ts
  15. 98 0
      src/libs/logic-flow/index.ts
  16. 48 0
      src/libs/logic-flow/nodes/end.ts
  17. 8 0
      src/libs/logic-flow/nodes/index.ts
  18. 34 0
      src/libs/logic-flow/nodes/start.ts
  19. 9 0
      src/libs/logic-flow/types.ts
  20. 45 0
      src/libs/logic-flow/use.ts
  21. 1 1
      src/main.ts
  22. 44 0
      src/model/device.model.ts
  23. 0 0
      src/pages/aio/flow-config/assets/config.svg
  24. 0 0
      src/pages/aio/flow-config/assets/finish.svg
  25. 0 0
      src/pages/aio/flow-config/assets/pulse.svg
  26. 1 0
      src/pages/aio/flow-config/assets/questionnaire.svg
  27. 1 0
      src/pages/aio/flow-config/assets/report.svg
  28. 0 0
      src/pages/aio/flow-config/assets/start.svg
  29. 0 0
      src/pages/aio/flow-config/assets/tongue.svg
  30. 5 0
      src/pages/aio/flow-config/index.ts
  31. 478 0
      src/pages/aio/flow-config/index.vue
  32. 131 0
      src/pages/aio/flow-config/nodes/FlowNode.model.ts
  33. 169 0
      src/pages/aio/flow-config/nodes/FlowNode.vue
  34. 115 0
      src/pages/aio/flow-config/nodes/FlowNodeInlay.vue
  35. 204 0
      src/pages/aio/flow-config/nodes/config.ts
  36. 35 0
      src/pages/aio/flow-config/nodes/index.ts
  37. 179 0
      src/pages/aio/flow-config/panel/RegisterPanel.vue
  38. 101 0
      src/pages/aio/flow-config/panel/ReportPanel.vue
  39. 159 0
      src/pages/aio/flow-config/panel/StartPanel.vue
  40. 234 0
      src/pages/aio/flow-config/tool.ts
  41. 27 18
      src/pages/index/care/issueService.vue
  42. 37 29
      src/pages/index/care/serviceItems.vue
  43. 383 0
      src/pages/index/equipment/configured.vue
  44. 411 0
      src/pages/index/equipment/reportManagement.vue
  45. 1 0
      src/pages/index/patient/history.vue
  46. 21 0
      src/polyfill.ts
  47. 11 16
      src/request/api/account.api.ts
  48. 2 3
      src/request/api/care.api.ts
  49. 36 1
      src/request/api/device.api.ts
  50. 9 0
      src/request/api/report.api.ts
  51. 4 0
      src/router/index.ts
  52. 129 74
      src/service/AddItems.vue
  53. 1 1
      src/service/CareProgress.vue
  54. 2 0
      src/service/Derivation.vue
  55. 3 3
      src/service/EditSystemService.vue
  56. 1 3
      src/service/ServiceItemsSystem.vue
  57. 2 1
      src/tools/promise.ts
  58. 3 3
      src/widgets/AnalysisReportWidget.vue
  59. 2 2
      src/widgets/ReportAnalysisWidgetTongue.vue
  60. 3 3
      src/widgets/ReportCardWidget.vue
  61. 2 0
      vite.config.ts

+ 1 - 0
.gitignore

@@ -30,3 +30,4 @@ coverage
 *.tsbuildinfo
 @types/auto-imports.d.ts
 @types/components.d.ts
+@types/typed-router.d.ts

+ 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;
+  }
+}

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

@@ -27,7 +27,9 @@ declare module 'vue-router/auto-routes' {
     '//care/supplier': RouteRecordInfo<'//care/supplier', '/care/supplier', Record<never, never>, Record<never, never>>,
     '//care/systemService': RouteRecordInfo<'//care/systemService', '/care/systemService', Record<never, never>, Record<never, never>>,
     '//care/text': RouteRecordInfo<'//care/text', '/care/text', Record<never, never>, Record<never, never>>,
+    '//equipment/configured': RouteRecordInfo<'//equipment/configured', '/equipment/configured', Record<never, never>, Record<never, never>>,
     '//equipment/registe': RouteRecordInfo<'//equipment/registe', '/equipment/registe', Record<never, never>, Record<never, never>>,
+    '//equipment/reportManagement': RouteRecordInfo<'//equipment/reportManagement', '/equipment/reportManagement', Record<never, never>, Record<never, never>>,
     '//follow/assessment': RouteRecordInfo<'//follow/assessment', '/follow/assessment', Record<never, never>, Record<never, never>>,
     '//follow/plan': RouteRecordInfo<'//follow/plan', '/follow/plan', Record<never, never>, Record<never, never>>,
     '//follow/task': RouteRecordInfo<'//follow/task', '/follow/task', Record<never, never>, Record<never, never>>,
@@ -39,6 +41,12 @@ 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>>,
     '/login': RouteRecordInfo<'/login', '/login', Record<never, never>, Record<never, never>>,
   }
 }

+ 6 - 0
package.json

@@ -14,6 +14,10 @@
   },
   "dependencies": {
     "@ant-design/icons-vue": "^7.0.1",
+    "@logicflow/core": "^2.1.2",
+    "@logicflow/extension": "^2.1.4",
+    "@logicflow/layout": "^2.0.2",
+    "@logicflow/vue-node-registry": "^1.1.3",
     "@unocss/reset": "^0.61.0",
     "@vueuse/components": "^10.11.0",
     "@vueuse/core": "^10.11.0",
@@ -28,12 +32,14 @@
     "pinia-plugin-persistedstate": "^3.2.1",
     "swiper": "^8.4.7",
     "v-selectpage": "^3.0.1",
+    "vite-svg-loader": "^5.1.0",
     "vue": "^3.4.29",
     "vue-echarts": "^7.0.2",
     "vue-range-component": "^1.0.3",
     "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"
   },

+ 292 - 0
pnpm-lock.yaml

@@ -11,6 +11,18 @@ importers:
       '@ant-design/icons-vue':
         specifier: ^7.0.1
         version: 7.0.1(vue@3.5.13(typescript@5.4.5))
+      '@logicflow/core':
+        specifier: ^2.1.2
+        version: 2.1.2
+      '@logicflow/extension':
+        specifier: ^2.1.4
+        version: 2.1.4(@logicflow/core@2.1.2)(@logicflow/vue-node-registry@1.1.3(@logicflow/core@2.1.2)(vue@3.5.13(typescript@5.4.5)))
+      '@logicflow/layout':
+        specifier: ^2.0.2
+        version: 2.0.2
+      '@logicflow/vue-node-registry':
+        specifier: ^1.1.3
+        version: 1.1.3(@logicflow/core@2.1.2)(vue@3.5.13(typescript@5.4.5))
       '@unocss/reset':
         specifier: ^0.61.0
         version: 0.61.9
@@ -53,6 +65,9 @@ importers:
       v-selectpage:
         specifier: ^3.0.1
         version: 3.0.1(vue@3.5.13(typescript@5.4.5))
+      vite-svg-loader:
+        specifier: ^5.1.0
+        version: 5.1.0(vue@3.5.13(typescript@5.4.5))
       vue:
         specifier: ^3.4.29
         version: 3.5.13(typescript@5.4.5)
@@ -71,6 +86,9 @@ importers:
       vue3-slider:
         specifier: ^1.10.1
         version: 1.10.1(vue@3.5.13(typescript@5.4.5))
+      vuedraggable:
+        specifier: ^4.1.0
+        version: 4.1.0(vue@3.5.13(typescript@5.4.5))
       vxe-pc-ui:
         specifier: ^4.6.42
         version: 4.6.42(vue@3.5.13(typescript@5.4.5))
@@ -197,6 +215,9 @@ packages:
   '@antfu/utils@8.1.1':
     resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==}
 
+  '@antv/hierarchy@0.6.14':
+    resolution: {integrity: sha512-V3uknf7bhynOqQDw2sg+9r9DwZ9pc6k/EcqyTFdfXB1+ydr7urisP0MipIuimucvQKN+Qkd+d6w601r1UIroqQ==}
+
   '@babel/code-frame@7.26.2':
     resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==}
     engines: {node: '>=6.9.0'}
@@ -863,6 +884,28 @@ packages:
   '@jridgewell/trace-mapping@0.3.25':
     resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
 
+  '@logicflow/core@2.1.2':
+    resolution: {integrity: sha512-2LW4ZMt0krcEgybkbnn1L0SzSXv02eEMAbTMKwQzBd4Bi2Si56j/OxMMLmx+dhfLNzw2g8JW/98Aj0tP8gDxcw==}
+
+  '@logicflow/extension@2.1.4':
+    resolution: {integrity: sha512-vMYwWYaNQNYruqdKnm7vcP4PiP+sIadulZfycmC+Acdn/3JYCEtAZzJOGt5gorG8F1H5vKXIXOFzwVLuuetoLQ==}
+    peerDependencies:
+      '@logicflow/core': 2.1.2
+      '@logicflow/vue-node-registry': 1.1.3
+
+  '@logicflow/layout@2.0.2':
+    resolution: {integrity: sha512-Ux47QLaxQ0nT91oA+XZ7mDZs8tbCbJuFihoT/zQQv6XsqSy6x9cPP+OxwswxqQb5HGrOIGXLM0MrEEPKQxf2VQ==}
+
+  '@logicflow/vue-node-registry@1.1.3':
+    resolution: {integrity: sha512-8xgAd8MQ1PD2ZjM8TCzHtE+nS2uRm+nkRV74wxKPk7ZL3TRj3dJFH0VdLctMxAN+ouq1QPXMsjhWBmHWYSKZKQ==}
+    peerDependencies:
+      '@logicflow/core': 2.1.2
+      '@vue/composition-api': ^1.0.0-rc.10
+      vue: ^2.0.0 || >=3.0.0
+    peerDependenciesMeta:
+      '@vue/composition-api':
+        optional: true
+
   '@nodelib/fs.scandir@2.1.5':
     resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
     engines: {node: '>= 8'}
@@ -1103,9 +1146,16 @@ packages:
     resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
     engines: {node: '>=18'}
 
+  '@sphinxxxx/color-conversion@2.2.2':
+    resolution: {integrity: sha512-XExJS3cLqgrmNBIP3bBw6+1oQ1ksGjFh0+oClDKFYpCCqx/hlqwWO5KO/S63fzUo67SxI9dMrF0y5T/Ey7h8Zw==}
+
   '@sxzz/popperjs-es@2.11.7':
     resolution: {integrity: sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==, tarball: https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz}
 
+  '@trysound/sax@0.2.0':
+    resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
+    engines: {node: '>=10.13.0'}
+
   '@tsconfig/node20@20.1.5':
     resolution: {integrity: sha512-Vm8e3WxDTqMGPU4GATF9keQAIy1Drd7bPwlgzKJnZtoOsTm1tduUTbDjg0W5qERvGuxPI2h9RbMufH0YdfBylA==}
 
@@ -1557,6 +1607,9 @@ packages:
     resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
     engines: {node: '>= 14.16.0'}
 
+  classnames@2.5.1:
+    resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
+
   color-convert@2.0.1:
     resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
     engines: {node: '>=7.0.0'}
@@ -1571,6 +1624,10 @@ packages:
     resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
     engines: {node: '>=18'}
 
+  commander@7.2.0:
+    resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
+    engines: {node: '>= 10'}
+
   compute-scroll-into-view@1.0.20:
     resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==}
 
@@ -1601,18 +1658,36 @@ packages:
     resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
     engines: {node: '>= 8'}
 
+  css-select@5.2.2:
+    resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
+
+  css-tree@2.2.1:
+    resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==}
+    engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
+
   css-tree@2.3.1:
     resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==}
     engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
 
+  css-what@6.2.2:
+    resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
+    engines: {node: '>= 6'}
+
   cssesc@3.0.0:
     resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
     engines: {node: '>=4'}
     hasBin: true
 
+  csso@5.0.5:
+    resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==}
+    engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
+
   csstype@3.1.3:
     resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
 
+  dagre@0.8.5:
+    resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==}
+
   dayjs@1.11.13:
     resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
 
@@ -1668,12 +1743,25 @@ packages:
   dom-scroll-into-view@2.0.1:
     resolution: {integrity: sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==}
 
+  dom-serializer@2.0.0:
+    resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
+
   dom-zindex@1.0.6:
     resolution: {integrity: sha512-FKWIhiU96bi3xpP9ewRMgANsoVmMUBnMnmpCT6dPMZOunVYJQmJhSRruoI0XSPoHeIif3kyEuiHbFrOJwEJaEA==}
 
   dom7@4.0.6:
     resolution: {integrity: sha512-emjdpPLhpNubapLFdjNL9tP06Sr+GZkrIHEXLWvOGsytACUrkbeIdjO5g77m00BrHTznnlcNqgmn7pCN192TBA==}
 
+  domelementtype@2.3.0:
+    resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
+
+  domhandler@5.0.3:
+    resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
+    engines: {node: '>= 4'}
+
+  domutils@3.2.2:
+    resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
+
   duplexer@0.1.2:
     resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
 
@@ -1900,6 +1988,9 @@ packages:
   graphemer@1.4.0:
     resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
 
+  graphlib@2.1.8:
+    resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==}
+
   gzip-size@6.0.0:
     resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==}
     engines: {node: '>=10'}
@@ -1912,6 +2003,9 @@ packages:
     resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
     hasBin: true
 
+  hoist-non-react-statics@2.5.5:
+    resolution: {integrity: sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==}
+
   hookable@5.5.3:
     resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
 
@@ -2101,9 +2195,15 @@ packages:
   magic-string@0.30.17:
     resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
 
+  mdn-data@2.0.28:
+    resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
+
   mdn-data@2.0.30:
     resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
 
+  medium-editor@5.23.3:
+    resolution: {integrity: sha512-he9/TdjX8f8MGdXGfCs8AllrYnqXJJvjNkDKmPg3aPW/uoIrlRqtkFthrwvmd+u4QyzEiadhCCM0EwTiRdUCJw==}
+
   memoize-one@6.0.0:
     resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
 
@@ -2135,10 +2235,27 @@ packages:
   mlly@1.7.4:
     resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==}
 
+  mobx-preact@3.0.0:
+    resolution: {integrity: sha512-ijan/cBs3WmRye87E5+3JmoFBB00KDAwNA3pm7bMwYLPHBAXlN86aC3gdrXw8aKzM5RI8V3a993PphzPv6P4FA==}
+    peerDependencies:
+      mobx: 5.x
+      preact: '>=8'
+
+  mobx-utils@5.6.2:
+    resolution: {integrity: sha512-a/WlXyGkp6F12b01sTarENpxbmlRgPHFyR1Xv2bsSjQBm5dcOtd16ONb40/vOqck8L99NHpI+C9MXQ+SZ8f+yw==}
+    peerDependencies:
+      mobx: ^4.13.1 || ^5.13.1
+
+  mobx@5.15.7:
+    resolution: {integrity: sha512-wyM3FghTkhmC+hQjyPGGFdpehrcX1KOXsDuERhfK2YbJemkUhEB+6wzEN639T21onxlfYBmriA1PFnvxTUhcKw==}
+
   mockjs@1.1.0:
     resolution: {integrity: sha512-eQsKcWzIaZzEZ07NuEyO4Nw65g0hdWAyurVol1IPl1gahRwY+svqzfgfey8U8dahLwG44d6/RwEzuK52rSa/JQ==}
     hasBin: true
 
+  mousetrap@1.6.5:
+    resolution: {integrity: sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==}
+
   mrmime@2.0.1:
     resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
     engines: {node: '>=10'}
@@ -2306,6 +2423,9 @@ packages:
     resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
     engines: {node: ^10 || ^12 || >=14}
 
+  preact@10.27.2:
+    resolution: {integrity: sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==}
+
   prelude-ls@1.2.1:
     resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
     engines: {node: '>= 0.8.0'}
@@ -2333,6 +2453,9 @@ packages:
   queue-microtask@1.2.3:
     resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
 
+  rangy@1.3.2:
+    resolution: {integrity: sha512-fS1C4MOyk8T+ZJZdLcgrukPWxkyDXa+Hd2Kj+Zg4wIK71yrWgmjzHubzPMY1G+WD9EgGxMp3fIL0zQ1ickmSWA==}
+
   rate-limiter-flexible@5.0.5:
     resolution: {integrity: sha512-+/dSQfo+3FYwYygUs/V2BBdwGa9nFtakDwKt4l0bnvNB53TNT++QSFewwHX9qXrZJuMe9j+TUaU21lm5ARgqdQ==}
 
@@ -2436,6 +2559,9 @@ packages:
     resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
     engines: {node: '>=8'}
 
+  sortablejs@1.14.0:
+    resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==}
+
   source-map-js@1.2.1:
     resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
     engines: {node: '>=0.10.0'}
@@ -2473,6 +2599,11 @@ packages:
     resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
     engines: {node: '>=8'}
 
+  svgo@3.3.2:
+    resolution: {integrity: sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==}
+    engines: {node: '>=14.0.0'}
+    hasBin: true
+
   swiper@8.4.7:
     resolution: {integrity: sha512-VwO/KU3i9IV2Sf+W2NqyzwWob4yX9Qdedq6vBtS0rFqJ6Fa5iLUJwxQkuD4I38w0WDJwmFl8ojkdcRFPHWD+2g==}
     engines: {node: '>= 4.7.0'}
@@ -2614,6 +2745,10 @@ packages:
   util-deprecate@1.0.2:
     resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
 
+  uuid@9.0.1:
+    resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
+    hasBin: true
+
   v-dropdown@3.0.0:
     resolution: {integrity: sha512-sDBiHhTjm0/eLmC83vUbOIHXNTlQD30w+usrh779OG+aNc/5vqyFQbSBPGekvD7pQPbWSPc5esVT8jcqfLRnrg==}
     peerDependencies:
@@ -2624,6 +2759,9 @@ packages:
     peerDependencies:
       vue: ^3.2.0
 
+  vanilla-picker@2.12.3:
+    resolution: {integrity: sha512-qVkT1E7yMbUsB2mmJNFmaXMWE2hF8ffqzMMwe9zdAikd8u2VfnsVY2HQcOUi2F38bgbxzlJBEdS1UUhOXdF9GQ==}
+
   vite-hot-client@0.2.4:
     resolution: {integrity: sha512-a1nzURqO7DDmnXqabFOliz908FRmIppkBKsJthS8rbe8hBEXwEwe4C3Pp33Z1JoFCYfVL4kTOMLKk0ZZxREIeA==}
     peerDependencies:
@@ -2653,6 +2791,11 @@ packages:
     peerDependencies:
       vite: ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0
 
+  vite-svg-loader@5.1.0:
+    resolution: {integrity: sha512-M/wqwtOEjgb956/+m5ZrYT/Iq6Hax0OakWbokj8+9PXOnB7b/4AxESHieEtnNEy7ZpjsjYW1/5nK8fATQMmRxw==}
+    peerDependencies:
+      vue: '>=3.2.13'
+
   vite@5.4.16:
     resolution: {integrity: sha512-Y5gnfp4NemVfgOTDQAunSD4346fal44L9mszGGY/e+qxsRT5y1sMlS/8tiQ8AFAp+MFgYNSINdfEchJiPm41vQ==}
     engines: {node: ^18.0.0 || >=20.0.0}
@@ -2774,6 +2917,11 @@ packages:
       typescript:
         optional: true
 
+  vuedraggable@4.1.0:
+    resolution: {integrity: sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==}
+    peerDependencies:
+      vue: ^3.0.1
+
   vxe-pc-ui@4.6.42:
     resolution: {integrity: sha512-grBaVbagoc5rbTq2jj1P/cWYP+sBo+VSXFRpNGYOe9Ka4EG9JP+LIa7h0lKfojDE5fGlPUYTkkYNe0fsQVDQ0g==}
 
@@ -2867,6 +3015,8 @@ snapshots:
 
   '@antfu/utils@8.1.1': {}
 
+  '@antv/hierarchy@0.6.14': {}
+
   '@babel/code-frame@7.26.2':
     dependencies:
       '@babel/helper-validator-identifier': 7.25.9
@@ -3388,6 +3538,42 @@ snapshots:
       '@jridgewell/resolve-uri': 3.1.2
       '@jridgewell/sourcemap-codec': 1.5.0
 
+  '@logicflow/core@2.1.2':
+    dependencies:
+      classnames: 2.5.1
+      lodash-es: 4.17.21
+      mobx: 5.15.7
+      mobx-preact: 3.0.0(mobx@5.15.7)(preact@10.27.2)
+      mobx-utils: 5.6.2(mobx@5.15.7)
+      mousetrap: 1.6.5
+      preact: 10.27.2
+      uuid: 9.0.1
+
+  '@logicflow/extension@2.1.4(@logicflow/core@2.1.2)(@logicflow/vue-node-registry@1.1.3(@logicflow/core@2.1.2)(vue@3.5.13(typescript@5.4.5)))':
+    dependencies:
+      '@antv/hierarchy': 0.6.14
+      '@logicflow/core': 2.1.2
+      '@logicflow/vue-node-registry': 1.1.3(@logicflow/core@2.1.2)(vue@3.5.13(typescript@5.4.5))
+      classnames: 2.5.1
+      lodash-es: 4.17.21
+      medium-editor: 5.23.3
+      mobx: 5.15.7
+      preact: 10.27.2
+      rangy: 1.3.2
+      vanilla-picker: 2.12.3
+
+  '@logicflow/layout@2.0.2':
+    dependencies:
+      '@logicflow/core': 2.1.2
+      dagre: 0.8.5
+
+  '@logicflow/vue-node-registry@1.1.3(@logicflow/core@2.1.2)(vue@3.5.13(typescript@5.4.5))':
+    dependencies:
+      '@logicflow/core': 2.1.2
+      lodash-es: 4.17.21
+      vue: 3.5.13(typescript@5.4.5)
+      vue-demi: 0.14.10(vue@3.5.13(typescript@5.4.5))
+
   '@nodelib/fs.scandir@2.1.5':
     dependencies:
       '@nodelib/fs.stat': 2.0.5
@@ -3544,8 +3730,12 @@ snapshots:
 
   '@sindresorhus/merge-streams@4.0.0': {}
 
+  '@sphinxxxx/color-conversion@2.2.2': {}
+
   '@sxzz/popperjs-es@2.11.7': {}
 
+  '@trysound/sax@0.2.0': {}
+
   '@tsconfig/node20@20.1.5': {}
 
   '@types/estree@1.0.7': {}
@@ -4204,6 +4394,8 @@ snapshots:
     dependencies:
       readdirp: 4.1.2
 
+  classnames@2.5.1: {}
+
   color-convert@2.0.1:
     dependencies:
       color-name: 1.1.4
@@ -4214,6 +4406,8 @@ snapshots:
 
   commander@13.1.0: {}
 
+  commander@7.2.0: {}
+
   compute-scroll-into-view@1.0.20: {}
 
   concat-map@0.0.1: {}
@@ -4238,15 +4432,39 @@ snapshots:
       shebang-command: 2.0.0
       which: 2.0.2
 
+  css-select@5.2.2:
+    dependencies:
+      boolbase: 1.0.0
+      css-what: 6.2.2
+      domhandler: 5.0.3
+      domutils: 3.2.2
+      nth-check: 2.1.1
+
+  css-tree@2.2.1:
+    dependencies:
+      mdn-data: 2.0.28
+      source-map-js: 1.2.1
+
   css-tree@2.3.1:
     dependencies:
       mdn-data: 2.0.30
       source-map-js: 1.2.1
 
+  css-what@6.2.2: {}
+
   cssesc@3.0.0: {}
 
+  csso@5.0.5:
+    dependencies:
+      css-tree: 2.2.1
+
   csstype@3.1.3: {}
 
+  dagre@0.8.5:
+    dependencies:
+      graphlib: 2.1.8
+      lodash: 4.17.21
+
   dayjs@1.11.13: {}
 
   de-indent@1.0.2: {}
@@ -4285,12 +4503,30 @@ snapshots:
 
   dom-scroll-into-view@2.0.1: {}
 
+  dom-serializer@2.0.0:
+    dependencies:
+      domelementtype: 2.3.0
+      domhandler: 5.0.3
+      entities: 4.5.0
+
   dom-zindex@1.0.6: {}
 
   dom7@4.0.6:
     dependencies:
       ssr-window: 4.0.2
 
+  domelementtype@2.3.0: {}
+
+  domhandler@5.0.3:
+    dependencies:
+      domelementtype: 2.3.0
+
+  domutils@3.2.2:
+    dependencies:
+      dom-serializer: 2.0.0
+      domelementtype: 2.3.0
+      domhandler: 5.0.3
+
   duplexer@0.1.2: {}
 
   echarts@5.6.0:
@@ -4639,6 +4875,10 @@ snapshots:
 
   graphemer@1.4.0: {}
 
+  graphlib@2.1.8:
+    dependencies:
+      lodash: 4.17.21
+
   gzip-size@6.0.0:
     dependencies:
       duplexer: 0.1.2
@@ -4647,6 +4887,8 @@ snapshots:
 
   he@1.2.0: {}
 
+  hoist-non-react-statics@2.5.5: {}
+
   hookable@5.5.3: {}
 
   human-signals@8.0.1: {}
@@ -4803,8 +5045,12 @@ snapshots:
     dependencies:
       '@jridgewell/sourcemap-codec': 1.5.0
 
+  mdn-data@2.0.28: {}
+
   mdn-data@2.0.30: {}
 
+  medium-editor@5.23.3: {}
+
   memoize-one@6.0.0: {}
 
   memorystream@0.3.1: {}
@@ -4835,10 +5081,24 @@ snapshots:
       pkg-types: 1.3.1
       ufo: 1.5.4
 
+  mobx-preact@3.0.0(mobx@5.15.7)(preact@10.27.2):
+    dependencies:
+      hoist-non-react-statics: 2.5.5
+      mobx: 5.15.7
+      preact: 10.27.2
+
+  mobx-utils@5.6.2(mobx@5.15.7):
+    dependencies:
+      mobx: 5.15.7
+
+  mobx@5.15.7: {}
+
   mockjs@1.1.0:
     dependencies:
       commander: 13.1.0
 
+  mousetrap@1.6.5: {}
+
   mrmime@2.0.1: {}
 
   ms@2.1.3: {}
@@ -4993,6 +5253,8 @@ snapshots:
       picocolors: 1.1.1
       source-map-js: 1.2.1
 
+  preact@10.27.2: {}
+
   prelude-ls@1.2.1: {}
 
   prettier-linter-helpers@1.0.0:
@@ -5011,6 +5273,8 @@ snapshots:
 
   queue-microtask@1.2.3: {}
 
+  rangy@1.3.2: {}
+
   rate-limiter-flexible@5.0.5: {}
 
   read-package-json-fast@3.0.2:
@@ -5116,6 +5380,8 @@ snapshots:
 
   slash@3.0.0: {}
 
+  sortablejs@1.14.0: {}
+
   source-map-js@1.2.1: {}
 
   speakingurl@14.0.1: {}
@@ -5144,6 +5410,16 @@ snapshots:
     dependencies:
       has-flag: 4.0.0
 
+  svgo@3.3.2:
+    dependencies:
+      '@trysound/sax': 0.2.0
+      commander: 7.2.0
+      css-select: 5.2.2
+      css-tree: 2.3.1
+      css-what: 6.2.2
+      csso: 5.0.5
+      picocolors: 1.1.1
+
   swiper@8.4.7:
     dependencies:
       dom7: 4.0.6
@@ -5331,6 +5607,8 @@ snapshots:
 
   util-deprecate@1.0.2: {}
 
+  uuid@9.0.1: {}
+
   v-dropdown@3.0.0(vue@3.5.13(typescript@5.4.5)):
     dependencies:
       vue: 3.5.13(typescript@5.4.5)
@@ -5340,6 +5618,10 @@ snapshots:
       v-dropdown: 3.0.0(vue@3.5.13(typescript@5.4.5))
       vue: 3.5.13(typescript@5.4.5)
 
+  vanilla-picker@2.12.3:
+    dependencies:
+      '@sphinxxxx/color-conversion': 2.2.2
+
   vite-hot-client@0.2.4(vite@5.4.16(@types/node@20.17.29)(sass@1.86.1)):
     dependencies:
       vite: 5.4.16(@types/node@20.17.29)(sass@1.86.1)
@@ -5398,6 +5680,11 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  vite-svg-loader@5.1.0(vue@3.5.13(typescript@5.4.5)):
+    dependencies:
+      svgo: 3.3.2
+      vue: 3.5.13(typescript@5.4.5)
+
   vite@5.4.16(@types/node@20.17.29)(sass@1.86.1):
     dependencies:
       esbuild: 0.21.5
@@ -5488,6 +5775,11 @@ snapshots:
     optionalDependencies:
       typescript: 5.4.5
 
+  vuedraggable@4.1.0(vue@3.5.13(typescript@5.4.5)):
+    dependencies:
+      sortablejs: 1.14.0
+      vue: 3.5.13(typescript@5.4.5)
+
   vxe-pc-ui@4.6.42(vue@3.5.13(typescript@5.4.5)):
     dependencies:
       '@vxe-ui/core': 4.1.5(vue@3.5.13(typescript@5.4.5))

+ 151 - 0
src/components/EditConfigured.vue

@@ -0,0 +1,151 @@
+<script setup lang="ts">
+import { useRequest } from 'alova/client';
+import { getDeviceManageDetailMethod, updateDeviceManageMethod } from '@/request/api/device.api';
+import { branchMethod } from '@/request/api/system.api';
+import type { DeviceManageModel } from '@/model/device.model';
+import { AioFlowConfig, type FlowRequestData } from '@/pages/aio/flow-config/index';
+type FollowModel = Partial<DeviceManageModel>;
+const flowData = ref<FlowRequestData>();
+const loading = ref<boolean>(false);
+
+const props = defineProps<{ data: FollowModel }>();
+
+const emits = defineEmits<{
+  submit: [data?: DeviceManageModel];
+}>();
+
+const model = ref<DeviceManageModel>({});
+
+// 获取详情
+const getDetail = async () => {
+  try {
+    const res = await getDeviceManageDetailMethod(props.data);
+    if (res && JSON.stringify(res) !== '{}') {
+      flowData.value = { ...res } as FlowRequestData;
+      loading.value = false;
+      model.value = { ...res };
+    }
+  } catch (error: any) {
+    console.error(error, 'error');
+    loading.value = false;
+  }
+};
+
+onMounted(() => {
+  if (props.data && props.data.id) {
+    getDetail();
+  }
+});
+
+const branch = ref<any[]>([]);
+useRequest(branchMethod(0, 1, 1)).onSuccess(({ data }) => {
+  const to = (data?: any[]): any[] => {
+    return Array.isArray(data)
+      ? data.map((item) => {
+          return {
+            ...item,
+            value: item.id,
+            key: item.id.toString(),
+            children: to(item.children),
+          };
+        })
+      : [];
+  };
+  branch.value = to(data);
+});
+
+const { loading: submitting, send: submit } = useRequest(updateDeviceManageMethod, { immediate: false }).onSuccess(() => {
+  emits('submit');
+});
+
+const flowRef = useTemplateRef<InstanceType<typeof AioFlowConfig>>('flow');
+const save = async () => {
+  // 改为仅透传提交事件,由父级统一关闭弹窗,避免重复 close 导致内部状态异常
+  try {
+    await flowRef.value!.validate(/* 传入 false 不展示错误信息 */ true);
+    flowData.value!.id = model.value?.id as string;
+    submit(flowData.value as any);
+  } catch (error: any) {
+    console.error('保存错误', error.message);
+  }
+};
+const reset = () => {
+  flowRef.value?.update(flowData.value as FlowRequestData);
+};
+</script>
+
+<template>
+  <div class="form-container">
+    <div class="summary-container">
+      <div class="summary-item mb-3">
+        <span class="label" v-if="model?.warrant">设备ID:</span><span class="value">{{ model.warrant }}</span>
+      </div>
+      <div class="flex">
+        <div class="summary-item mr-6" v-if="model && model.orgName">
+          <span class="label">组织名称:</span><span class="value">{{ model.orgName }}</span>
+        </div>
+        <div class="summary-item" v-if="model && model.institutionName">
+          <span class="label">机构名称:</span><span class="value">{{ model.institutionName }}</span>
+        </div>
+      </div>
+    </div>
+    <!-- 流程配置 -->
+    <div class="content-container">
+      <div class="title">流程配置</div>
+      <span class="section-divider"></span>
+      <!-- validate 方法通过后会自动更新 -->
+      <AioFlowConfig ref="flow" :loading="loading" v-model:request-data="flowData"></AioFlowConfig>
+    </div>
+    <!-- 保存和重置 -->
+    <div class="button-container">
+      <vxe-button type="reset" content="重置" @click="reset()"></vxe-button>
+      <vxe-button type="submit" status="primary" content="保存" @click="save()" :loading="submitting"></vxe-button>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.form-container {
+  padding: 20px;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+.summary-container {
+  flex: none;
+  margin-bottom: 20px;
+}
+
+.content-container {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0; 
+  > div:not(.title) {
+    flex: auto;
+    overflow: auto; 
+  }
+  > .title {
+    flex: none;
+    padding: 12px 12px 12px 0;
+    font-size: 16px;
+    font-weight: 800;
+  }
+}
+
+.section-divider {
+  height: 1px !important;
+  background-color: #d9d9d9;
+  margin-bottom: 15px !important;
+}
+
+.button-container {
+  flex: none;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  gap: 16px;
+  padding-top: 20px;
+}
+</style>

+ 4 - 262
src/components/EditEquirement.vue

@@ -26,22 +26,6 @@ watchEffect(() => {
     model.value = { ...defaultModel, ...props.data };
   }
 });
-// 确保processConfig始终存在
-// watchEffect(() => {
-//   if (!model.value.processConfig) {
-
-//     model.value.processConfig = {
-//       archiving: false,
-//       tongueDiagnosis: false,
-//       tongueReport: 'none' as const,
-//       pulseDiagnosis: false,
-//       pulseReport: 'none' as const,
-//       inquiry: false,
-//       healthReport: 'none' as const,
-//       conditioningPlan: 'none' as const
-//     };
-//   }
-// });
 // 获取详情
 const getDetail = async () => {
   const res = await getDeviceRegisterDetailMethod(props.data);
@@ -85,13 +69,11 @@ async function getDeviceType() {
   deviceTypesLoading.value = true;
   const res = await getDictionaryMethod('fdhb_device_type');
   if (res && res.length > 0) {
-    console.log(res, 'res==>');
     deviceTypes.value = res.map((item: any) => ({
       id: item.value,
       name: item.label,
     }));
   }
-  console.log(deviceTypes.value, 'deviceTypes==>');
   // 设备类型加载后,若当前值是名称则映射成对应的id
   normalizeDeviceTypeToId();
   deviceTypesLoading.value = false;
@@ -182,16 +164,8 @@ const formItems = computed(() => {
         },
       },
     },
-    // {
-    //   field: 'processConfig',
-    //   title: '',
-    //   span: 24,
-    //   slots: {
-    //     default: 'processConfigSlot',
-    //   },
-    // },
     {
-      field: 'remarks',
+      field: 'remark',
       title: '备注',
       span: 24,
       slots: {
@@ -272,6 +246,7 @@ const formEmits: VxeFormListeners = {
     if (data.id) {
       data.deviceCode = data.deviceIds[0];
     }
+    data.remark ??= '';
     submit(data).then(() => {
       notification.success({
         message: '操作成功',
@@ -334,9 +309,7 @@ function normalizeDeviceTypeToId() {
       v-on="formEmits"
       :loading="submitting"
     >
-      <template #deviceIdTitleSlot>
-        <span style="color: #f56c6c;font-size: 20px;">*</span> 设备ID
-      </template>
+      <template #deviceIdTitleSlot> <span style="color: #f56c6c; font-size: 20px">*</span> 设备ID </template>
 
       <template #deviceIdSlot>
         <div class="device-ids-container">
@@ -352,237 +325,6 @@ function normalizeDeviceTypeToId() {
         </div>
       </template>
 
-      <!-- <template #processConfigSlot>
-        <div class="section-container">
-          <div class="section-title">流程配置</div>
-          <div class="section-divider"></div>
-          <div class="process-config">
-            <div class="config-item">
-              <span class="config-label">建档:</span>
-              <div class="radio-group">
-                <label class="radio-item">
-                  <input 
-                    type="radio" 
-                    v-model="model.processConfig.archiving" 
-                    :value="true"
-                    :checked="model.processConfig?.archiving"
-                  />
-                  <span>有</span>
-                </label>
-                <label class="radio-item">
-                  <input 
-                    type="radio" 
-                    v-model="model.processConfig.archiving" 
-                    :value="false"
-                    :checked="!model.processConfig?.archiving"
-                  />
-                  <span>无</span>
-                </label>
-              </div>
-            </div>
-
-            <div class="config-item">
-              <span class="config-label">舌面诊:</span>
-              <div class="radio-group">
-                <label class="radio-item">
-                  <input 
-                    type="radio" 
-                    v-model="model.processConfig.tongueDiagnosis" 
-                    :value="true"
-                    :checked="model.processConfig?.tongueDiagnosis"
-                    disabled
-                  />
-                  <span>有</span>
-                </label>
-                <label class="radio-item">
-                  <input 
-                    type="radio" 
-                    v-model="model.processConfig.tongueDiagnosis" 
-                    :value="false"
-                    :checked="!model.processConfig?.tongueDiagnosis"
-                  />
-                  <span>无</span>
-                </label>
-              </div>
-              <span class="report-label">舌面分析报告:</span>
-              <div class="radio-group">
-                <label class="radio-item">
-                  <input 
-                    type="radio" 
-                    v-model="model.processConfig.tongueReport" 
-                    value="full"
-                    :checked="model.processConfig?.tongueReport === 'full'"
-                  />
-                  <span>完整展示</span>
-                </label>
-                <label class="radio-item">
-                  <input 
-                    type="radio" 
-                    v-model="model.processConfig.tongueReport" 
-                    value="scan"
-                    :checked="model.processConfig?.tongueReport === 'scan'"
-                  />
-                  <span>扫码查看</span>
-                </label>
-                <label class="radio-item">
-                  <input 
-                    type="radio" 
-                    v-model="model.processConfig.tongueReport" 
-                    value="none"
-                    :checked="model.processConfig?.tongueReport === 'none'"
-                  />
-                  <span>无</span>
-                </label>
-              </div>
-            </div>
-
-            <div class="config-item">
-              <span class="config-label">脉诊:</span>
-              <div class="radio-group">
-                <label class="radio-item">
-                  <input 
-                    type="radio" 
-                    v-model="model.processConfig.pulseDiagnosis" 
-                    :value="true"
-                    :checked="model.processConfig?.pulseDiagnosis"
-                  />
-                  <span>有</span>
-                </label>
-                <label class="radio-item">
-                  <input 
-                    type="radio" 
-                    v-model="model.processConfig.pulseDiagnosis" 
-                    :value="false"
-                    :checked="!model.processConfig?.pulseDiagnosis"
-                  />
-                  <span>无</span>
-                </label>
-              </div>
-              <span class="report-label">脉象分析报告:</span>
-              <div class="radio-group">
-                <label class="radio-item">
-                  <input 
-                    type="radio" 
-                    v-model="model.processConfig.pulseReport" 
-                    value="full"
-                    :checked="model.processConfig?.pulseReport === 'full'"
-                  />
-                  <span>完整展示</span>
-                </label>
-                <label class="radio-item">
-                  <input 
-                    type="radio" 
-                    v-model="model.processConfig.pulseReport" 
-                    value="scan"
-                    :checked="model.processConfig?.pulseReport === 'scan'"
-                  />
-                  <span>扫码查看</span>
-                </label>
-                <label class="radio-item">
-                  <input 
-                    type="radio" 
-                    v-model="model.processConfig.pulseReport" 
-                    value="none"
-                    :checked="model.processConfig?.pulseReport === 'none'"
-                  />
-                  <span>无</span>
-                </label>
-              </div>
-            </div>
-
-            <div class="config-item">
-              <span class="config-label">问诊:</span>
-              <div class="radio-group">
-                <label class="radio-item">
-                  <input 
-                    type="radio" 
-                    v-model="model.processConfig.inquiry" 
-                    :value="true"
-                    :checked="model.processConfig?.inquiry"
-                  />
-                  <span>有</span>
-                </label>
-                <label class="radio-item">
-                  <input 
-                    type="radio" 
-                    v-model="model.processConfig.inquiry" 
-                    :value="false"
-                    :checked="!model.processConfig?.inquiry"
-                  />
-                  <span>无</span>
-                </label>
-              </div>
-            </div>
-
-            <div class="config-item">
-              <span class="config-label">健康分析报告:</span>
-              <div class="radio-group">
-                <label class="radio-item">
-                  <input 
-                    type="radio" 
-                    v-model="model.processConfig.healthReport" 
-                    value="full"
-                    :checked="model.processConfig?.healthReport === 'full'"
-                  />
-                  <span>完整展示</span>
-                </label>
-                <label class="radio-item">
-                  <input 
-                    type="radio" 
-                    v-model="model.processConfig.healthReport" 
-                    value="scan"
-                    :checked="model.processConfig?.healthReport === 'scan'"
-                  />
-                  <span>扫码查看</span>
-                </label>
-                <label class="radio-item">
-                  <input 
-                    type="radio" 
-                    v-model="model.processConfig.healthReport" 
-                    value="none"
-                    :checked="model.processConfig?.healthReport === 'none'"
-                  />
-                  <span>无</span>
-                </label>
-              </div>
-            </div>
-
-            <div class="config-item">
-              <span class="config-label">调理方案:</span>
-              <div class="radio-group">
-                <label class="radio-item">
-                  <input 
-                    type="radio" 
-                    v-model="model.processConfig.conditioningPlan" 
-                    value="full"
-                    :checked="model.processConfig?.conditioningPlan === 'full'"
-                  />
-                  <span>完整展示</span>
-                </label>
-                <label class="radio-item">
-                  <input 
-                    type="radio" 
-                    v-model="model.processConfig.conditioningPlan" 
-                    value="scan"
-                    :checked="model.processConfig?.conditioningPlan === 'scan'"
-                  />
-                  <span>扫码查看</span>
-                </label>
-                <label class="radio-item">
-                  <input 
-                    type="radio" 
-                    v-model="model.processConfig.conditioningPlan" 
-                    value="none"
-                    :checked="model.processConfig?.conditioningPlan === 'none'"
-                  />
-                  <span>无</span>
-                </label>
-              </div>
-            </div>
-          </div>
-        </div>
-      </template> -->
-
       <template #remarksSlot>
         <div class="section-container">
           <textarea
@@ -705,7 +447,7 @@ function normalizeDeviceTypeToId() {
 .required-field {
   :deep(.vxe-form--item-title) {
     position: relative;
-    
+
     &::before {
       content: '*';
       color: #ff4d4f;

+ 179 - 0
src/components/EditMoreConfigured.vue

@@ -0,0 +1,179 @@
+<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';
+import { AioFlowConfig, type FlowRequestData } from '@/pages/aio/flow-config/index';
+import type { DeviceManageModel } from '@/model/device.model';
+const flowData = ref<FlowRequestData>();
+const loading = ref(false);
+// const defaultModel: DeviceManageModel = {};
+
+const props = defineProps<{ data: DeviceManageModel[] }>();
+
+const emits = defineEmits<{
+  submit: [data?: DeviceManageModel];
+  submitSingle: [];
+}>();
+
+// 批量修改
+const { loading: submitting, send: submit } = useRequest(batchUpdateDeviceManageMethod, { immediate: false }).onSuccess(() => {
+  emits('submit');
+  notification.success({ message: '修改成功' });
+});
+// 单个修改
+const { loading: submittingSingle, send: submitSingle } = useRequest(updateDeviceManageMethod, { immediate: false }).onSuccess(() => {
+  emits('submitSingle');
+  notification.success({ message: '修改成功' });
+});
+// 右侧设备列表
+const tableData = computed<DeviceManageModel[]>(() => {
+  const src = props.data as any;
+  if (Array.isArray(src)) return src;
+  if (src && Array.isArray(src.data)) return src.data;
+  return src ? [src] : [];
+});
+
+// 设备ID增删在批量配置不需要
+let ids = ref<string[]>([]);
+tryOnMounted(async () => {
+  if (props.data && Array.isArray(props.data) && props.data.length > 1) {
+    props.data?.forEach((item: any) => {
+      ids.value.push(item.id);
+    });
+    flowData.value = mock();
+  } else if (props.data && props.data.length === 1) {
+    flowData.value = (await getDeviceManageDetailMethod({ id: props.data[0].id })) as FlowRequestData;
+  }
+});
+// 初始化数据
+const mock = () => {
+  return {
+    tabletProcessModules: ['patient_file', 'tongueface_upload', 'tongueface_analysis', 'health_analysis'],
+    tabletFileFields: ['phone:required', 'sex', 'age', 'isEasyAllergy'],
+    tabletRequiredPageOperationElements: [
+      'health_analysis_report_page_appletbutton'
+    ],
+    technicalSupporter: '',
+  };
+};
+const flowRef = useTemplateRef<InstanceType<typeof AioFlowConfig>>('flow');
+// 保存
+const save = async () => {
+  // 改为仅透传提交事件,由父级统一关闭弹窗,避免重复 close 导致内部状态异常
+  await flowRef.value!.validate(/* 传入 false 不展示错误信息 */ true);
+  if (Array.isArray(props.data) && props.data.length > 1) {
+    await submit({ ...flowData.value, ids: ids.value });
+  } else if (Array.isArray(props.data) && props.data.length === 1) {
+    await submitSingle({ ...flowData.value, id: props.data[0].id } as DeviceManageModel);
+  }
+};
+const reset = () => {
+  flowRef.value?.update();
+};
+</script>
+
+<template>
+  <div class="two-pane h-full">
+    <div class="left form-container flex flex-col">
+      <!-- 流程配置 -->
+      <div class="flex-auto content-container">
+        <div class="title">流程配置</div>
+        <!-- validate 方法通过后会自动更新 -->
+        <AioFlowConfig ref="flow" :loading="loading" v-model:request-data="flowData"></AioFlowConfig>
+      </div>
+
+      <!-- 保存和重置 -->
+      <div class="button-container">
+        <vxe-button type="reset" content="重置" @click="reset()"></vxe-button>
+        <vxe-button type="submit" status="primary" content="保存" @click="save()" :loading="submitting || submittingSingle"></vxe-button>
+      </div>
+    </div>
+    <!-- 右侧设备列表 -->
+    <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>
+              <th>设备ID</th>
+              <th>组织</th>
+              <th>机构名称</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="item in tableData" :key="item.id">
+              <td>{{ item.warrant }}</td>
+              <td>{{ item.orgName }}</td>
+              <td>{{ item.institutionName }}</td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.content-container {
+  width: 100%;
+  //height: 600px;
+  display: flex;
+  flex-direction: column;
+  > div:not(.title) {
+    flex: auto;
+  }
+  > .title {
+    flex: none;
+    padding: 12px;
+    font-size: 16px;
+    font-weight: 800;
+  }
+}
+.two-pane {
+  display: flex;
+  gap: 24px;
+}
+.left {
+  flex: 1;
+}
+.right {
+  width: 30%;
+  min-width: 420px;
+}
+.table-title {
+  font-weight: 600;
+  margin-bottom: 8px;
+}
+.table-wrapper {
+  border: 1px solid #eee;
+  //height: 100%;
+  overflow: auto;
+}
+.simple-table {
+  width: 100%;
+  border-collapse: collapse;
+}
+.simple-table th,
+.simple-table td {
+  border-bottom: 1px solid #f0f0f0;
+  padding: 8px 12px;
+  text-align: left;
+}
+.form-container {
+  //padding: 20px;
+}
+.section-divider {
+  height: 1px;
+  background-color: #d9d9d9;
+  margin-bottom: 15px;
+}
+.button-container {
+  flex: none;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  gap: 16px;
+  padding-top: 20px;
+}
+</style>

+ 9 - 5
src/components/Swiper.vue

@@ -9,9 +9,14 @@ import 'swiper/css/scrollbar'; // 轮播图的滚动条
 // 引入swiper核心和所需模块
 import { Autoplay, Pagination, Navigation, Scrollbar } from 'swiper';
 
+type ImageItem = { image: string; date?: string };
+
 const modules = [Autoplay, Pagination, Navigation, Scrollbar];
-const props = defineProps<{ images: string[] }>();
-const slidesPerView = computed(() => Math.min(props.images?.length, 3));
+const props = defineProps<{ images: ImageItem[] }>();
+const slidesPerView = computed(() => {
+  const count = Array.isArray(props.images) ? props.images.length : 0;
+  return Math.min(count || 1, 3);
+});
 
 </script>
 <template>
@@ -19,15 +24,14 @@ const slidesPerView = computed(() => Math.min(props.images?.length, 3));
     <swiper
       :modules="modules"
       :loop="true"
-      :slides-per-view="1"
+      :slides-per-view="slidesPerView"
       :autoplay="{ delay: 4000, disableOnInteraction: false }"
       navigation
       :pagination="{ clickable: true }"
       :scrollbar="{ draggable: true }"
-      :slidesPerView="slidesPerView"
     >
       <!-- loop可循环轮播,autoplay可自动播放 -->
-      <swiper-slide v-for="img in props.images" :key="img.date">
+      <swiper-slide v-for="img in props.images" :key="img.date || img.image">
         <div>
           <span>{{ img?.date }}</span>
           <div>

+ 178 - 0
src/components/TongueAnalysisReport.vue

@@ -0,0 +1,178 @@
+<script setup lang="ts">
+import { useWatcher } from 'alova/client';
+import { getTonguefaceAnalysisReportMethod } from '@/request/api/report.api';
+import type { DeviceReportModel } from '@/model/device.model';
+import HealthReportAnalysisWidget from '@/widgets/HealthReportAnalysisWidget.vue';
+
+const props = defineProps<{
+  report: Partial<DeviceReportModel>;
+}>();
+
+
+// 患者最后一次健康分析报告
+const { data: healthRecord } = useWatcher(() => getTonguefaceAnalysisReportMethod(props?.report?.tonguefaceAnalysisReportId?.toString()!), [() => props.report.tonguefaceAnalysisReportId], {
+  initialData: {},
+  immediate: true,
+  middleware: (_, next) => {
+    console.log(healthRecord, 'props?.report?.tonguefaceAnalysisReportId');
+    if (props?.report?.tonguefaceAnalysisReportId) next();
+  },
+});
+
+// 判断是否有数据
+const hasData = computed(() => {
+  return props?.report?.tonguefaceAnalysisReportId && 
+         healthRecord.value && 
+         Object.keys(healthRecord.value).length > 0 &&
+         healthRecord.value?.analysis;
+});
+</script>
+
+<template>
+  <div class="p-6">
+    <!-- 有数据时显示分析组件 -->
+    <template v-if="hasData">
+      <HealthReportAnalysisWidget class="row" category="tongue" :analysis="healthRecord?.analysis"></HealthReportAnalysisWidget>
+      <HealthReportAnalysisWidget class="row" category="face" :analysis="healthRecord?.analysis"></HealthReportAnalysisWidget>
+    </template>
+    
+    <!-- 无数据时显示暂无数据提示 -->
+    <template v-else>
+      <div class="no-data-container">
+        <div class="no-data-content">
+          <div class="no-data-icon">📊</div>
+          <div class="no-data-text">暂无数据</div>
+          <div class="no-data-desc">当前没有舌面分析报告数据</div>
+        </div>
+      </div>
+    </template>
+  </div>
+</template>
+
+<style scoped lang="scss">
+section {
+  color: rgba(0, 0, 0, 0.85);
+
+  > header {
+    font-size: 18px;
+    font-weight: 700;
+
+    :deep(.ant-btn-link) {
+      padding-block: 0;
+      font-size: 18px;
+      border: none;
+    }
+  }
+
+  > main {
+    margin-left: 18px * 4;
+    padding: 0 15px;
+    font-size: 16px;
+    color: rgba(0, 0, 0, 0.85);
+
+    > .row > .ant-space {
+      font-size: 16px;
+    }
+
+    .row {
+      padding: 12px 0;
+
+      span > label {
+        color: rgba(0, 0, 0, 0.45);
+      }
+
+      label::after {
+        margin-left: 2px;
+        margin-right: 8px;
+        content: ':';
+      }
+
+      > header::before {
+        $size: 10px;
+        content: '';
+        display: inline-block;
+        margin-right: 12px;
+        width: $size;
+        height: $size;
+        border: 2px solid #1d6ff6;
+        border-radius: 50%;
+      }
+
+      > main {
+        margin-left: 18px * 2;
+      }
+    }
+  }
+
+  .ant-tag {
+    margin-top: 6px;
+  }
+}
+
+.separate {
+  :deep(.ant-space-item) {
+    & + .ant-space-item::before {
+      content: ',';
+      margin-right: 2px;
+    }
+  }
+
+  span + span::before {
+    content: ',';
+    margin-right: 2px;
+  }
+}
+
+.panel-wrapper {
+  :deep(.ant-tabs-content-holder) {
+    padding-top: 12px;
+    height: calc(100vh - 60px - 24px - 32px);
+
+    .ant-tabs-content {
+      height: 100%;
+    }
+  }
+}
+
+.trend-up {
+  color: #ff4d4f;
+  border-color: #ff4d4f;
+}
+
+.trend-down {
+  color: #87d068;
+  border-color: #87d068;
+}
+
+// 暂无数据样式
+.no-data-container {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  min-height: 300px;
+  width: 100%;
+}
+
+.no-data-content {
+  text-align: center;
+  color: #999;
+  
+  .no-data-icon {
+    font-size: 48px;
+    margin-bottom: 16px;
+    opacity: 0.6;
+  }
+  
+  .no-data-text {
+    font-size: 18px;
+    font-weight: 500;
+    margin-bottom: 8px;
+    color: #666;
+  }
+  
+  .no-data-desc {
+    font-size: 14px;
+    color: #999;
+  }
+}
+</style>

+ 78 - 0
src/libs/logic-flow/VLogicFlow.vue

@@ -0,0 +1,78 @@
+<script setup lang="ts">
+import { tryOnMounted } from '@vueuse/core';
+import { LogicFlow } from '@logicflow/core';
+
+import { provideLogicFlow, tryLogicFlow } from './use';
+import { EndNode, StartNode } from './nodes';
+import { PolylineEdge } from './edges';
+
+import type { LogicFlowInstance, LogicFlowOptions } from './types';
+
+defineOptions({
+  name: 'VLogicFlow',
+});
+const props = defineProps<{ options?: LogicFlowOptions; loading?: boolean }>();
+const emits = defineEmits<{ loaded: [LogicFlowInstance] }>();
+const containerRef = useTemplateRef('container');
+tryOnMounted(() => {
+  const options = {
+    ...props.options,
+    parentTransform: tryLogicFlow()?.graphModel.transformModel,
+    container: containerRef.value!,
+  };
+  const lf = new LogicFlow(options);
+  lf.register(PolylineEdge);
+  lf.register(StartNode);
+  lf.register(EndNode);
+  lf.setDefaultEdgeType('custom-polyline-edge');
+  provideLogicFlow(lf);
+  emits('loaded', lf);
+});
+</script>
+
+<template>
+  <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%;
+  position: relative;
+  > main {
+    width: 100%;
+    height: 100%;
+  }
+}
+.panel-wrapper {
+  position: absolute;
+  top: 0;
+  left: 0;
+}
+</style>

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

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

+ 5 - 0
src/libs/logic-flow/edges/index.ts

@@ -0,0 +1,5 @@
+import __polyline from './polyline';
+
+export {
+  __polyline as PolylineEdge,
+}

+ 22 - 0
src/libs/logic-flow/edges/polyline.ts

@@ -0,0 +1,22 @@
+import { PolylineEdge as __PolylineEdge, PolylineEdgeModel as __PolylineEdgeModel } from '@logicflow/core';
+
+class PolylineEdgeModel extends __PolylineEdgeModel {
+  getEdgeAnimationStyle() {
+    const style = super.getEdgeAnimationStyle();
+    style.strokeDasharray = '5 5';
+    style.strokeDashoffset = '100%';
+    style.animationDuration = '10s';
+    return style;
+  }
+  setProperties(properties: Record<string, any>) {
+    super.setProperties(properties);
+    if ('id' in properties) this.id = properties.id;
+    if ('isAnimation' in properties) this.isAnimation = properties.isAnimation;
+  }
+}
+
+export default {
+  type: 'custom-polyline-edge',
+  model: PolylineEdgeModel,
+  view: __PolylineEdge,
+};

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

@@ -0,0 +1,98 @@
+import { type CallbackArgs, type EventArgs, type EventCallback, LogicFlow } from '@logicflow/core';
+import { register as __register } from '@logicflow/vue-node-registry';
+import type { Dagre } from '@logicflow/layout';
+
+import '@logicflow/core/lib/style/index.css';
+import '@logicflow/extension/es/index.css';
+import '@logicflow/extension/lib/style/index.css';
+
+import VLogicFlow from './VLogicFlow.vue';
+import { useEventListener } from './use';
+import type { LogicFlowInstance } from './types';
+
+export type * from './types';
+export { VLogicFlow };
+
+export type GraphData = LogicFlow.GraphConfigData;
+
+const Threshold = 100;
+
+export default function init(
+  lf: LogicFlowInstance,
+  config?: {
+    register: { category: 'node' | 'edge'; type: string; view: any; model: any }[];
+    graph?: GraphData;
+  }
+) {
+  const register = (type: string, component: Component, model?: any) => __register({ type, component, model }, lf);
+  const listener = <T extends keyof EventArgs>(event: T, callback: EventCallback<T>, predicate?: (event: CallbackArgs<T>) => boolean, once?: boolean) =>
+    useEventListener(lf.graphModel.eventCenter, event, callback, predicate, once);
+  const getDagre = () => lf.extension.dagre as Dagre;
+
+  for (const { category, type, view, model } of config?.register ?? []) {
+    if (category === 'node') register(type, view, model);
+  }
+  lf.renderRawData(config?.graph ?? {});
+
+  let delayClick: ReturnType<typeof setTimeout>;
+  listener('edge:dbclick', (event) => {
+    lf.deleteEdge(event.data.id);
+  });
+  listener('node:dbclick', (event) => {
+    clearTimeout(delayClick);
+    lf.deleteNode(event.data.id);
+  });
+  listener('node:click', (event) => {
+    delayClick = setTimeout(() => {
+      lf.graphModel.eventCenter.emit('node:click:300', event);
+    }, 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] 事件
+   */
+  let dropAddEdgeTime = 0;
+  listener('node:drop', () => {
+    if (Date.now() - dropAddEdgeTime < 100) lf.graphModel.eventCenter.emit('edge:proximity-connect', {});
+  });
+  listener('edge:add', () => {
+    dropAddEdgeTime = Date.now();
+  });
+
+  return { lf, register, getDagre, listener };
+}
+
+export type VLogicFlowInstance = ReturnType<typeof init>;

+ 48 - 0
src/libs/logic-flow/nodes/end.ts

@@ -0,0 +1,48 @@
+import { RectNode, RectNodeModel } from '@logicflow/core';
+import { START_ID } from '../constant';
+
+class EndNodeModel extends RectNodeModel {
+  getDefaultAnchor() {
+    return super.getDefaultAnchor().map((anchor) => Object.assign(anchor, { type: 'incoming' }));
+  }
+
+  getConnectedSourceRules() {
+    const rules = super.getConnectedSourceRules();
+    rules.push({
+      message: '结束节点不允许输出',
+      validate: () => false,
+    });
+    return rules;
+  }
+
+  getConnectedTargetRules() {
+    const rules = super.getConnectedTargetRules();
+    rules.push({
+      message: `当前节点不是输出锚点`,
+      validate: (source, target, sourceAnchor, targetAnchor) => {
+        return !sourceAnchor?.type || sourceAnchor?.type === 'outgoing';
+      }
+    })
+
+    rules.push({
+      message: '禁止直接连接开始节点',
+      validate: (source, target, sourceAnchor, targetAnchor) => {
+        return !source || source.id !== START_ID;
+      },
+    });
+
+    rules.push({
+      message: `当前节点和目标节点已存在连接`,
+      validate: (source, target) => {
+        return !target?.graphModel.getNodeIncomingNode(target?.id).find(node => node.id === source?.id)
+      }
+    })
+    return rules;
+  }
+}
+
+export default {
+  type: 'EndNode',
+  model: EndNodeModel,
+  view: RectNode,
+};

+ 8 - 0
src/libs/logic-flow/nodes/index.ts

@@ -0,0 +1,8 @@
+import __start from './start';
+import __end from './end';
+
+
+export {
+  __start as StartNode,
+  __end as EndNode,
+}

+ 34 - 0
src/libs/logic-flow/nodes/start.ts

@@ -0,0 +1,34 @@
+import { RectNode, RectNodeModel } from '@logicflow/core';
+
+class StartNodeModel extends RectNodeModel {
+  getDefaultAnchor() {
+    return super.getDefaultAnchor().map((anchor) => Object.assign(anchor, { type: 'outgoing' }));
+  }
+
+  getConnectedSourceRules() {
+    const rules = super.getConnectedSourceRules();
+    rules.push({
+      message: '开始节点仅允许1个输出',
+      validate: (source) => {
+        const edge = this.graphModel.getNodeOutgoingEdge(source!.id);
+        return !edge.length;
+      },
+    });
+    return rules;
+  }
+
+  getConnectedTargetRules() {
+    const rules = super.getConnectedTargetRules();
+    rules.push({
+      message: '开始节点不允许输入',
+      validate: () => false,
+    });
+    return rules;
+  }
+}
+
+export default {
+  type: 'StartNode',
+  model: StartNodeModel,
+  view: RectNode,
+};

+ 9 - 0
src/libs/logic-flow/types.ts

@@ -0,0 +1,9 @@
+import type { LogicFlow, Options } from '@logicflow/core';
+import { LogicFlow as LogicFlowClass } from '@logicflow/core';
+
+export type LogicFlowInstance = InstanceType<typeof LogicFlowClass>;
+export type LogicFlowOptions = Partial<Options.Common>;
+
+export type LogicFlowNodeProperties = LogicFlow.PropertiesType;
+export type LogicFlowNode<P extends LogicFlowNodeProperties = LogicFlowNodeProperties> = LogicFlow.NodeConfig<P>;
+export type LogicFlowGraphData = LogicFlow.GraphConfigData;

+ 45 - 0
src/libs/logic-flow/use.ts

@@ -0,0 +1,45 @@
+import type { LogicFlowInstance } from './types';
+import type { CallbackArgs, EventArgs, EventCallback, EventEmitter } from '@logicflow/core';
+import { tryOnScopeDispose } from '@vueuse/core';
+
+const symbol = Symbol('logicFlow') as InjectionKey<LogicFlowInstance>;
+
+export function provideLogicFlow(lf: LogicFlowInstance) {
+  provide(symbol, lf);
+}
+
+export function useLogicFlow(): LogicFlowInstance {
+  const lf = inject(symbol, null);
+  if (!lf) throw new Error('LogicFlow instance is not provided. Please ensure you have called provideLogicFlow before using useLogicFlow.');
+  return lf;
+}
+
+export function tryLogicFlow(): LogicFlowInstance | null {
+  try {
+    return useLogicFlow();
+  } catch (error) {
+    return null;
+  }
+}
+
+export function useEventListener<T extends keyof EventArgs>(
+  emitter: EventEmitter,
+  event: T,
+  callback: EventCallback<T>,
+  predicate?: (event: CallbackArgs<T>) => boolean,
+  once?: boolean
+): () => void {
+  const cleanups: Function[] = [];
+  const cleanup = () => {
+    cleanups.forEach((fn) => fn());
+    cleanups.length = 0;
+  };
+
+  const cb: EventCallback<T> = typeof predicate === 'function' ? (event) => (predicate(event) ?? true) && callback(event) : callback;
+
+  emitter.on(event, cb, once);
+  cleanups.push(() => emitter.off(event, cb));
+
+  tryOnScopeDispose(cleanup);
+  return () => cleanup();
+}

+ 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';

+ 44 - 0
src/model/device.model.ts

@@ -20,5 +20,49 @@ export interface EquirementModel {
 }
 export type EquirementQuery = Partial<EquirementModel>;
 
+export interface DeviceManageModel{
+  id?:string; //一体机id
+  warrant?:string; // 设备ID
+  partner?:string; // 合作伙伴
+  technicalSupporter?:string; // 技术支持
+  tabletProcessModules?:string[]; // 流程模块
+  tabletFileFields?:string[]; // 文件字段
+  tabletRequiredPageOperationElements?:string[]; // 必填页面操作元素
+  tabletSetsDetailResume?:{
+    isPatientFile?:boolean; // 是否建档
+    isTonguefaceUpload?:boolean; // 是否舌面诊
+    isTonguefaceUploadResult?:boolean; // 是否舌面分析报告
+    isPulseUpload?:boolean; // 是否脉诊
+    isTonguefaceAnalysis?:boolean; // 是否问诊
+  }
+  updateBy?:string;
+  orgId?:number;
+  orgName?:string;
+  institutionId?:number;
+  institutionName?:string;
+  updateTimeStart?:string;
+  updateTimeEnd?:string;
+  isHaveResume?:boolean;
+  remark?:string;
+}
+export type DeviceManageQuery = Partial<DeviceManageModel>;
 
+export interface DeviceReportModel{
+  orgName?:string;
+  orgId?:number;
+  deviceType?:string; 
+  deviceName?:string;
+  deviceCode?:string;
+  name?:string;
+  phone?:string;
+  cardno?:string;
+  createTime?:string;
+  pulseAnalysisReportId?:number;
+  tonguefaceAnalysisReportId?:number;
+  healthAnalysisReportId?:number;
+  pulseAnalysisReportUrl?:string;
+  createTimeStart?:string;
+  createTimeEnd?:string;
+}
 
+export type DeviceReportQuery = Partial<DeviceReportModel>;

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
src/pages/aio/flow-config/assets/config.svg


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
src/pages/aio/flow-config/assets/finish.svg


Разница между файлами не показана из-за своего большого размера
+ 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>

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
src/pages/aio/flow-config/assets/start.svg


Разница между файлами не показана из-за своего большого размера
+ 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';

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

@@ -0,0 +1,478 @@
+<script setup lang="ts">
+import { h } from 'vue';
+
+import { tryOnUnmounted, useParentElement } from '@vueuse/core';
+import { VxeUI } from 'vxe-pc-ui';
+
+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, InsertNodeInPolyline } 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, InsertNodeInPolyline],
+  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();
+// 新加的 start
+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 => {
+  setTimeout(() => {
+    maxPanelHeight.value = lf.container.getBoundingClientRect().height - 80;
+    openPanel.value = maxPanelHeight.value > 0;
+  }, 100);
+  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);
+      if (!oldValue || oldValue?.timestamp !== value?.timestamp) 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();
+  });*/
+  // @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();
+    }
+  });
+};
+
+const updateLayout = (dir?: 'LR' | 'TB' | 'center') => {
+  if (!hasInstance()) return;
+  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(false);
+const dragPanelNodeId = ref('');
+const togglePanel = (open?: boolean) => {
+  open ??= !openPanel.value;
+  if (open && !(maxPanelHeight.value > 0)) maxPanelHeight.value = instance.lf.container.getBoundingClientRect().height - 80;
+  openPanel.value = open;
+};
+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) {
+              if (!hasInstance()) return;
+              instance.lf.getNodeModelById(node.id!)?.setProperties({ requestData: value });
+              VxeUI.modal.close(`node:config`);
+            },
+          }),
+      },
+    });
+  }
+};
+
+const update = (data?: FlowRequestData) => {
+  if (!hasInstance()) return;
+  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) setTimeout(() => updateLayout('center'), 100);
+};
+const validate = (tips = true) => {
+  if (!hasInstance()) return Promise.reject(new Error('LogicFlow 已销毁'));
+  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: Gather;
+  const { promise, resolve, reject } = withResolvers<{ gather: Gather; data?: FlowRequestData; message?: string }>();
+  try {
+    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;
+    oldValue = toFlowRequestData(gather, data);
+    requestData.value = oldValue;
+    resolve({ gather, data: oldValue });
+    console.log('[AioFlowConfig] 更新 request-data 数据: ', oldValue);
+  } 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) {
+    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 });
+      if (notOpenEdgeId === edgeId) instance.lf.closeEdgeAnimation(notOpenEdgeId);
+      else instance.lf.openEdgeAnimation(edgeId);
+    }
+  } else {
+    for (const edge of instance.lf.getGraphRawData().edges) {
+      const edgeId = edge.id;
+      instance.lf.closeEdgeAnimation(edgeId);
+    }
+  }
+
+  return promise;
+};
+
+tryOnUnmounted(() => {
+  // 新加的
+  isUnmounted = true;
+  openPanel.value = false;
+  notification.destroy();
+  try { scope.stop(); } catch {}
+});
+
+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="togglePanel()">
+          <template #icon>
+            <CloseOutlined v-if="openPanel"></CloseOutlined>
+            <MenuOutlined v-else />
+          </template>
+        </a-button>
+        <template #overlay>
+          <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) }"
+                v-for="node in group"
+                :key="node.id"
+                :id="node.id"
+                :text="node.text"
+                v-bind="node.properties"
+                @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>
+    </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;
+}
+.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;
+}
+.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>

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

@@ -0,0 +1,131 @@
+import { RectNodeModel } from '@logicflow/core';
+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> {
+  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 = 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 = getProperties(target).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 = this.graphModel.getNodeOutgoingNode(targetId);
+      for (const directNode of directNodes) {
+        forbidSource = (getProperties(directNode).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 {
+    // 当前节点是否开始
+    if (this.id === START_ID)
+      return {
+        isAllPass: false,
+        msg: `当前 [${this.text.value}] 为开始节点不允许输入`,
+      };
+    // 获取目标节点连接到的所有起始节点
+    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 = (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(' | ')}] 节点作为输入`,
+      };
+
+    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.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)})`,
+      };
+
+    // 获取目标节点 onlyTarget
+    const onlyTarget = (getProperties(this).onlyTarget as string[]) ?? [];
+    if (onlyTarget.length && !onlyTarget.includes(args[0].id))
+      return {
+        isAllPass: false,
+        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 ?? {};
+  }
+}

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

@@ -0,0 +1,169 @@
+<script setup lang="ts">
+import { tryOnMounted, tryOnUnmounted } from '@vueuse/core';
+import { GraphModel } from '@logicflow/core';
+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';
+
+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 popover = ref(false);
+
+const properties = ref<FlowNodeProperties>({
+  width: 0,
+  height: 0,
+  radius: 0,
+  iconBackground: 'transparent',
+  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?.()!;
+
+  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
+  );
+  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
+  );*/
+});
+tryOnUnmounted(() => { popover.value = false; });
+
+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 v-if="id === ID_End" class="node" :id :text v-bind="properties"></FlowNodeInlay>
+  <a-popover v-else :title="text" placement="right" trigger="contextmenu" 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">
+.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);
+}
+
+.popover-content-wrapper {
+  max-width: 300px;
+  .title {
+    margin-bottom: 8px;
+    color: rgba(0, 0, 0, 0.88);
+    font-weight: 600;
+  }
+}
+</style>

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

@@ -0,0 +1,115 @@
+<script setup lang="ts">
+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';
+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,
+  finish: IconFinish,
+  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>

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

@@ -0,0 +1,204 @@
+import { END_ID, END_NODE, 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_Back = 'back';
+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_Back]: '返回首页',
+  [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',
+    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('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,
+  });
+}
+
+export function register(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Register, textRef[ID_Register], {
+    icon: 'config',
+    iconBackground: '#c41d7f',
+    iconColor: '#fff',
+    onlySource: [START_ID],
+    configurable: true,
+    quickNodes: [ID_Analysis_Pulse, ID_Analysis_TongueAndFace].map((id) => ({ id, text: formatText(id) })),
+    ...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],
+    quickNodes: [ID_Analysis_TongueAndFace].map((id) => ({ id, text: formatText(id) })),
+    ...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],
+    quickNodes: [ID_Analysis_Health, ID_Report_TongueAndFace].map((id) => ({ id, text: formatText(id) })),
+    ...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],
+    quickNodes: [ID_Report_Health, ID_Scheme_Health].map((id) => ({ id, text: formatText(id) })),
+    ...options,
+  });
+}
+
+export function pulseAnalysisReport(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Report_Pulse, textRef[ID_Report_Pulse], {
+    icon: 'report',
+    iconBackground: '#08979c',
+    iconColor: '#fff',
+    forbidDirectTarget: [ID_Back],
+    ...options,
+  });
+}
+
+export function tongueAndFaceAnalysisReport(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Report_TongueAndFace, textRef[ID_Report_TongueAndFace], {
+    icon: 'report',
+    iconBackground: '#08979c',
+    iconColor: '#fff',
+    configurable: true,
+    forbidDirectTarget: [ID_Back],
+    ...options,
+  });
+}
+
+export function healthAnalysisReport(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Report_Health, textRef[ID_Report_Health], {
+    icon: 'report',
+    iconBackground: '#08979c',
+    iconColor: '#fff',
+    configurable: true,
+    onlyTarget: [ID_End],
+    ...options,
+  });
+}
+
+export function healthAnalysisScheme(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Scheme_Health, textRef[ID_Scheme_Health], {
+    icon: 'report',
+    iconBackground: '#08979c',
+    iconColor: '#fff',
+    configurable: true,
+    onlyTarget: [ID_End],
+    ...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 ?? {},
+  };
+}

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

@@ -0,0 +1,35 @@
+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' | 'finish' | 'config' | 'report' | 'pulse' | 'tongue' | 'questionnaire';
+  iconBackground?: string;
+  iconColor?: string;
+
+  forbidDirectSource?: string[];
+  forbidDirectTarget?: string[];
+  forbidSource?: string[];
+  onlySource?: string[];
+  onlyTarget?: string[];
+
+  quickNodes?: {id: string; text: string}[];
+
+  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>

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

@@ -0,0 +1,159 @@
+<script setup lang="ts">
+import { Preset_Image_1, Preset_Image_2 } from '../tool';
+
+interface Model {
+  copyright?: string;
+
+  elements?: string[];
+
+  homeType?: number;
+  homeValue?: string;
+}
+
+const copyrightPlaceholder = `\
+某某机构
+杭州六智科技提供技术支持\
+`
+
+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="版权信息">
+      <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>
+      <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>

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

@@ -0,0 +1,234 @@
+import {
+  alcoholAnalysisReport,
+  back,
+  edge,
+  end,
+  healthAnalysis,
+  healthAnalysisReport,
+  healthAnalysisScheme,
+  ID_Analysis_Health,
+  ID_Analysis_Pulse,
+  ID_Analysis_TongueAndFace,
+  ID_Back,
+  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 | 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[] = [];
+  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 if (targetNodeId !== ID_Back && targetNodeId !== ID_End) 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;
+    }),
+    timestamp: Date.now(),
+  } as const;
+}
+
+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,
+    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') }),
+    ],
+    [back()],
+  ];
+
+  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);
+  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);
+    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_Back, { title, countDown }));
+  }
+  if (edges.some(edge => edge.targetNodeId === ID_Back)) nodes.add(ID_Back);
+  return {
+    graph: {
+      nodes: Array.from(nodes, (id) => {
+        if (id === ID_Start)
+          return start({
+            requestData: {
+              partner: data?.partner,
+              technicalSupporter: data?.technicalSupporter,
+              tabletDrainageImage: data?.tabletDrainageImage || Preset_Image_1,
+            },
+          });
+        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;
+}

+ 27 - 18
src/pages/index/care/issueService.vue

@@ -169,6 +169,7 @@ async function getCpRecordDetail(id: string) {
     selectedProvince.value = res?.provinceCode;
     selectedCity.value = res?.cityCode;
     selectedArea.value = res?.areaCode;
+    deliveryChecked.value = res?.isDelivery === 'Y' ? true : false;
   });
 }
 function getPatientList(id: string) {
@@ -348,11 +349,12 @@ watch(displayTableData, (newValue, oldValue) => {
     isShowDelivery.value = newValue.some((item) => {
       return item.conditioningProgramDetail?.isDelivery === 'Y';
     });
-    if (isShowDelivery.value) {
-      deliveryChecked.value = true;
-    } else {
-      deliveryChecked.value = false;
-    }
+    console.log(isShowDelivery.value, 'isShowDelivery.value');
+    // if (isShowDelivery.value) {
+    //   deliveryChecked.value = true;
+    // } else {
+    //   deliveryChecked.value = false;
+    // }
     newValue.forEach((row: any) => {
       row.frequencyTypeing = row.frequencyType ? [row.frequencyType] : [];
     });
@@ -568,14 +570,16 @@ function calculateCount(row: any) {
   } else if (pricingType === '1') {
     // 按穴位计价
     const frequencyType = Number(row.frequencyType) || 0;
-    row.totalMeasure = Math.ceil((period / frequencyType) * frequency);
+    row.totalMeasure = Math.ceil(period / frequencyType) * frequency;
+    // console.log(frequencyType, 'frequencyType', frequency, period, row.totalMeasure);
+    // console.log(acCount, maxCount, 'acCount, maxCount', row.conditioningProgramDetail.cpDynamicPricingRule?.[1]?.priceType);
     if (acCount > maxCount) {
       if (row.conditioningProgramDetail.cpDynamicPricingRule?.[1]?.priceType === 0) {
         // 单价
         if (acCount > 0) {
           let unitPrice: number = row.conditioningProgramDetail.cpDynamicPricingRule[1].price * acCount;
           row.unitPrice = unitPrice;
-          row.totalPrice = Math.ceil((period / frequencyType) * frequency * unitPrice);
+          row.totalPrice = row.totalMeasure * unitPrice;
         } else {
           row.unitPrice = '-';
           row.totalPrice = 0;
@@ -588,13 +592,16 @@ function calculateCount(row: any) {
         row.totalPrice = row.conditioningProgramDetail.cpDynamicPricingRule[1].price * row.totalMeasure;
       }
     } else {
+      // console.log(row.conditioningProgramDetail.cpDynamicPricingRule, 'row.conditioningProgramDetail.cpDynamicPricingRule?.length');
       if (row.conditioningProgramDetail.cpDynamicPricingRule?.length > 0) {
         if (row.conditioningProgramDetail.cpDynamicPricingRule?.[0]?.priceType === 0) {
           // 单价
           if (acCount > 0) {
             let unitPrice: number = row.conditioningProgramDetail.cpDynamicPricingRule[0].price * acCount;
             row.unitPrice = unitPrice;
-            row.totalPrice = Math.ceil((period / frequencyType) * frequency * unitPrice);
+
+            row.totalPrice = row.totalMeasure * unitPrice;
+            // console.log(unitPrice, 'unitPrice', row.totalPrice, 'row.totalPrice', period, frequencyType, frequency, acCount);
           } else {
             row.unitPrice = '-';
             row.totalPrice = 0;
@@ -605,6 +612,7 @@ function calculateCount(row: any) {
           let unitPrice: number = row.conditioningProgramDetail.cpDynamicPricingRule[0].price;
           row.unitPrice = unitPrice;
           row.totalPrice = row.conditioningProgramDetail.cpDynamicPricingRule[0].price * row.totalMeasure;
+          // console.log(row.totalPrice, 'row.totalPrice', row.conditioningProgramDetail.cpDynamicPricingRule[0].price, row.totalMeasure);
         }
       }
     }
@@ -660,6 +668,7 @@ async function handleSubmit() {
       delete item.id;
     });
   }
+  console.log(deliveryChecked.value, 'deliveryChecked.value');
   form.isDelivery = deliveryChecked.value ? 'Y' : 'N';
   form.id = Number(currentPatient.value.id);
   form.patientId = currentPatient.value.patientId;
@@ -674,7 +683,7 @@ async function handleSubmit() {
     notification.error({ message: '请输入有效的手机号码' });
     return;
   }
-  if(!form.estimatedStartDate) {
+  if (!form.estimatedStartDate) {
     notification.error({ message: '请选择调养日期' });
     return;
   }
@@ -1171,14 +1180,14 @@ function openPatientHealthRecord(row: { id: string }, showType: 'analysis' | 'sc
                   @click="currentPatient?.status === '0' ? null : editPart(row)"
                   :style="{ color: '#1890ff', cursor: 'pointer' }"
                 >
-                  {{ (() => {
-                    const acuPointNames = Array.isArray(row?.acuPointNames) ? row.acuPointNames : [];
-                    const acuMeridianNames = Array.isArray(row?.acuMeridianNames) ? row.acuMeridianNames : [];
-                    const combined = acuPointNames.concat(acuMeridianNames).join('、');
-                    return combined === '' ? 
-                      (currentPatient?.status === '0' ? '' : '编辑') : 
-                      combined;
-                  })() }}
+                  {{
+                    (() => {
+                      const acuPointNames = Array.isArray(row?.acuPointNames) ? row.acuPointNames : [];
+                      const acuMeridianNames = Array.isArray(row?.acuMeridianNames) ? row.acuMeridianNames : [];
+                      const combined = acuPointNames.concat(acuMeridianNames).join('、');
+                      return combined === '' ? (currentPatient?.status === '0' ? '' : '编辑') : combined;
+                    })()
+                  }}
                 </a>
               </template>
             </vxe-column>
@@ -1220,7 +1229,7 @@ function openPatientHealthRecord(row: { id: string }, showType: 'analysis' | 'sc
         <div class="delivery-row" v-if="isShowDelivery">
           <div v-if="btnType !== '转方案' && currentPatient?.status === '0'">
             <a-checkbox v-model:checked="deliveryChecked" disabled>配送</a-checkbox>
-            <span>地址:</span>
+            <span v-if="form.provinceName || form.cityName || form.areaName || form.detailAddress">地址:</span>
             <span>{{ form.provinceName }}{{ form.cityName }}{{ form.areaName }}{{ form.detailAddress }}</span>
             <span style="margin-left: 16px" v-if="form.phone">电话:</span>
             <span>{{ form.phone }}</span>

+ 37 - 29
src/pages/index/care/serviceItems.vue

@@ -22,31 +22,45 @@ const panels = shallowReactive([
 ].filter(item => !unref(item.hide)));
 
 const activePanel = ref(panels[0].id);
-const panelRef = ref<any[]>([]);
-
-function handleChange(activeKey: any) {
-  let newVal = activeKey.target.value;
-  const activeComponent = panels.findIndex(panel => {
-    return panel.id === newVal;
-  });
-  panelRef.value[activeComponent]?.send();
+const currentComponent = ref<any>(null);
+
+// 获取当前激活的组件
+const getCurrentComponent = () => {
+  return panels.find(panel => panel.id === activePanel.value);
+};
+
+// 切换面板
+function handleChange(panelId: string) {
+  activePanel.value = panelId;
+  // 延迟执行,确保新组件已经渲染完成
+  setTimeout(() => {
+    if (currentComponent.value && typeof currentComponent.value.send === 'function') {
+      currentComponent.value?.send();
+    } 
+  }, 100);
 };
 </script>
 
 <template>
   <div class="p-6">
-    <a-tabs class="panel-wrapper" v-model:activeKey="activePanel" >
-      <a-tab-pane v-for="panel in panels" :key="panel.id">
-        <component :is="panel.component" :title="panel.title" ref="panelRef"></component>
-      </a-tab-pane>
-      <template #renderTabBar>
-        <a-radio-group v-model:value="activePanel" @change="handleChange">
-          <a-radio-button v-for="panel in panels" :key="panel.id" :value="panel.id">
-            {{ panel.title }}
-          </a-radio-button>
-        </a-radio-group>
-      </template>
-    </a-tabs>
+    <!-- 标签栏 -->
+    <div class="mb-4">
+      <a-radio-group v-model:value="activePanel" @change="(e) => handleChange(e.target.value)">
+        <a-radio-button v-for="panel in panels" :key="panel.id" :value="panel.id">
+          {{ panel.title }}
+        </a-radio-button>
+      </a-radio-group>
+    </div>
+    
+    <!-- 内容区域 -->
+    <div class="content-wrapper">
+      <component 
+        :is="getCurrentComponent()?.component" 
+        :title="getCurrentComponent()?.title" 
+        ref="currentComponent"
+        :key="activePanel"
+      ></component>
+    </div>
   </div>
 </template>
 
@@ -124,15 +138,9 @@ section {
   }
 }
 
-.panel-wrapper {
-  :deep(.ant-tabs-content-holder) {
-    padding-top: 12px;
-    height: calc(100vh - 60px - 24px - 32px);
-
-    .ant-tabs-content {
-      height: 100%;
-    }
-  }
+.content-wrapper {
+  height: calc(100vh - 60px - 24px - 32px - 60px); // 减去标签栏高度
+  overflow: auto;
 }
 
 .trend-up {

+ 383 - 0
src/pages/index/equipment/configured.vue

@@ -0,0 +1,383 @@
+<script setup lang="ts">
+import { type VxeFormListeners, type VxeFormProps, VxeUI, type VxeGridProps } from 'vxe-pc-ui';
+import { usePagination, useRequest } from 'alova/client';
+import { notification } from 'ant-design-vue';
+import { getDictionaryMethod } from '@/request/api/dictionary.api';
+import dayjs from 'dayjs';
+import EditConfigured from '@/components/EditConfigured.vue';
+import EditMoreConfigured from '@/components/EditMoreConfigured.vue';
+
+defineOptions({ name: 'EquipmentConfiguredPage' });
+
+import type { DeviceManageModel, DeviceManageQuery } from '@/model/device.model';
+
+// 接口数据
+import { deviceManageMethod } from '@/request/api/device.api';
+import { branchMethod } from '@/request/api/system.api';
+// 获取组织树
+const { data: branch, loading: branchLoading } = useRequest(branchMethod(0, 1, 1));
+
+const model = shallowRef<DeviceManageQuery>();
+
+// 获取设备类型
+const deviceTypes = ref<{ id: string; name: string }[]>([]);
+const deviceTypesLoading = ref(false);
+
+// 日期验证
+const updateTimeStart = ref<string>('');
+const updateTimeEnd = ref<string>('');
+// 禁用结束时间的日期(早于开始时间的日期)
+function disabledEndDate(current: any) {
+  if (!updateTimeStart.value) return false;
+  return current && current < dayjs(updateTimeStart.value);
+}
+
+const searchFormProps = reactive<VxeFormProps<DeviceManageQuery>>({
+  titleWidth: 100,
+  titleAlign: 'right',
+  titleColon: true,
+  data: {},
+  items: [
+    {
+      field: 'deviceCode',
+      title: '设备ID',
+      span: 6,
+      itemRender: { name: 'VxeInput', props: { placeholder: '请输入' } },
+    },
+    {
+      field: 'orgId',
+      title: '组织名称',
+      span: 6,
+      itemRender: {
+        name: 'VxeTreeSelect',
+        props: {
+          loading: computed(() => branchLoading.value),
+          options: computed(() => branch.value),
+          optionProps: { value: 'id', label: 'label' },
+          clearable: true,
+        },
+        events: {
+          change(val: any) {
+            insArr.value = [];
+            if (val.data.orgId) {
+              // 清空表单中的机构名称字段
+              if (model.value) {
+                model.value.institutionId = '';
+              }
+              getInstitution(val.data.orgId);
+            }
+          },
+        },
+      },
+    },
+    {
+      field: 'institutionId',
+      title: '机构名称',
+      span: 6,
+      itemRender: {
+        name: 'VxeTreeSelect',
+        props: {
+          loading: computed(() => insLoading.value),
+          options: computed(() => insArr.value),
+          optionProps: { value: 'id', label: 'label' },
+          clearable: true,
+        },
+      },
+    },
+    {
+      field: 'isHaveResume',
+      title: '是否配置',
+      span: 6,
+      itemRender: {
+        name: 'VxeRadioGroup',
+        options: [
+          { label: '已配置', value: 'true' },
+          { label: '未配置', value: 'false' },
+        ],
+        props: {
+          strict: false,
+        },
+      },
+    },
+
+    {
+      field: 'updateBy',
+      title: '修改人',
+      span: 6,
+      itemRender: { name: 'VxeInput', props: { placeholder: '请输入' } },
+    },
+    {
+      field: 'updateTime',
+      title: '修改时间',
+      span: 10,
+      slots: {
+        default: 'createTimes',
+      },
+    },
+    {
+      field: 'action',
+      span: 6,
+      itemRender: {
+        name: 'VxeButtonGroup',
+        options: [
+          { name: 'submits', type: 'submit', content: '查询', status: 'primary' },
+          { name: 'reset', type: 'reset', content: '清空', status: 'warning' },
+          { name: 'config', content: '配置', status: 'primary' },
+        ],
+        events: {
+          click(slotParams, { name }) {
+            if (name === 'config') {
+              importOrganization();
+            }
+          },
+        },
+      },
+    },
+  ],
+});
+const insLoading = ref(false);
+const insArr = ref<any[]>([]);
+async function getInstitution(orgId: string | number) {
+  insLoading.value = true;
+  const res = await branchMethod(1, 0, Number(orgId));
+  if (res && res.length > 0) {
+    insArr.value = res;
+  }
+  insLoading.value = false;
+}
+const searchFormEmits: VxeFormListeners<DeviceManageQuery> = {
+  // 查询设备登记
+  submit({ data }) {
+    model.value = {
+      ...data,
+      updateTimeStart: updateTimeStart.value ? dayjs(updateTimeStart.value).format('YYYY-MM-DD HH:mm:ss') : '',
+      updateTimeEnd: updateTimeEnd.value ? dayjs(updateTimeEnd.value).format('YYYY-MM-DD HH:mm:ss') : '',
+    } as any;
+  },
+  // 重置
+  reset({ data }) {
+    model.value = { ...data } as any;
+    updateTimeStart.value = '';
+    updateTimeEnd.value = '';
+  },
+};
+// 设备列表
+const gridRef = ref<DeviceManageModel>();
+const gridOptions = reactive<VxeGridProps<DeviceManageModel>>({
+  id: 'tag-list',
+  border: true,
+  showOverflow: true,
+  height: 'auto',
+  autoResize: false,
+  syncResize: true,
+  scrollY: { enabled: true, gt: 0 },
+  toolbarConfig: {
+    custom: true,
+    zoom: true,
+    slots: {
+      tools: 'toolbar-extra',
+    },
+  },
+  columnConfig: {
+    resizable: true,
+  },
+  customConfig: {
+    storage: true,
+  },
+  columns: [
+    { type: 'checkbox', width: 60, fixed: 'left', title: '', align: 'center' },
+    { field: 'orgName', title: '组织名称' },
+    { field: 'warrant', title: '设备ID' },
+    {
+      title: '流程配置',
+      align: 'center',
+      children: [
+        { field: 'isPatientFile', title: '建档', formatter: (p: any) => yesNoFormatter(p.row.tabletSetsDetailResume.isPatientFile) },
+        { field: 'puisPulseUploadlse', title: '脉诊', formatter: (p: any) => yesNoFormatter(p.row.tabletSetsDetailResume.isPulseUpload) },
+        { field: 'pulseReportShowType', title: '脉象分析报告', formatter: (p: any) => showTypeFormatter(p.row.tabletSetsDetailResume.pulseReportShowType) },
+        { field: 'isTonguefaceUpload', title: '舌面诊', formatter: (p: any) => yesNoFormatter(p.row.tabletSetsDetailResume.isTonguefaceUpload) },
+        { field: 'tonguefaceUploadResultShowType', title: '舌面分析报告', formatter: (p: any) => showTypeFormatter(p.row.tabletSetsDetailResume.tonguefaceUploadResultShowType) },
+        { field: 'isTonguefaceAnalysis', title: '问诊', formatter: (p: any) => yesNoFormatter(p.row.tabletSetsDetailResume.isTonguefaceAnalysis) },
+        { field: 'healthAnalysisReportShowType', title: '报告+方案', formatter: (p: any) => showTypeFormatter(p.row.tabletSetsDetailResume.healthAnalysisReportShowType) },
+        { field: 'isHealthAnalysisScheme', title: '调理方案', formatter: (p: any) => yesNoFormatter(p.row.tabletSetsDetailResume.isHealthAnalysisScheme) },
+      ],
+    },
+    { field: 'updateBy', title: '修改人' },
+    { field: 'updateTime', title: '修改时间' },
+    { field: 'remark', title: '备注' },
+    {
+      field: 'action',
+      title: '操作',
+      align: 'center',
+      width: 100,
+      showOverflow: false,
+      cellRender: {
+        name: 'VxeButtonGroup',
+        props: { mode: 'text' },
+        options: [{ content: '编辑', status: 'primary', name: 'editConfigured' }],
+        events: {
+          click({ row }: any, { name }: any) {
+            if (name === 'editConfigured') {
+              editConfigured(row as any);
+            }
+          },
+        },
+      },
+    },
+  ],
+  data: [],
+});
+const gridEvents: any = {};
+
+// 分页
+const { loading, page, pageSize, total, onSuccess, refresh } = usePagination((page, size) => deviceManageMethod(page, size, model.value), {
+  initialData: { data: [], total: 0 },
+  initialPage: 1,
+  initialPageSize: 100,
+  watchingStates: [model],
+  immediate: false,
+});
+onSuccess(({ data: { data } }) => {
+  gridRef.value?.loadData(data);
+});
+
+// 获取设备类型
+async function getDeviceType() {
+  deviceTypesLoading.value = true;
+  const res = await getDictionaryMethod('fdhb_device_type');
+  if (res && res.length > 0) {
+    deviceTypes.value = res.map((item: any) => ({
+      id: item.value,
+      name: item.label,
+    }));
+  }
+  deviceTypesLoading.value = false;
+}
+
+onMounted(() => {
+  getDeviceType();
+  model.value = toRaw(searchFormProps.data);
+});
+
+// 表格显示格式化
+function yesNoFormatter(value: any) {
+  if (value === true) return '有';
+  if (value === false || value == null) return '无';
+  return String(value ?? '无');
+}
+function showTypeFormatter(value: any) {
+  // 与截图文案对应:"完整展示" / "无"
+  if (value === '1') return '完整展示';
+  if (value === '2') return '扫码查看';
+  if (!value || value === '0') return '无';
+  return String(value);
+}
+
+// 编辑辨识仪配置
+function editConfigured(model?: DeviceManageModel) {
+  VxeUI.modal.open({
+    title: `修改配置`,
+    fullscreen: true,
+    escClosable: true,
+    destroyOnClose: true,
+    id: `equirement-modal`,
+    remember: true,
+    storage: true,
+    slots: {
+      default() {
+        return h(EditConfigured, {
+          data: model,
+          onSubmit() {
+            refresh(page.value);
+            VxeUI.modal.close(`equirement-modal`);
+          },
+        } as any);
+      },
+    },
+  });
+}
+
+// 批量修改
+function importOrganization() {
+  const selectedRows = (gridRef.value?.getCheckboxRecords() as any[]) || [];
+  if (selectedRows.length === 0) {
+    notification.error({ message: '请先选择设备' });
+    return;
+  }
+  VxeUI.modal.open({
+    title: '批量修改',
+    fullscreen: true,
+    escClosable: true,
+    destroyOnClose: true,
+    id: 'import-more-configured',
+    remember: true,
+    storage: true,
+    slots: {
+      default() {
+        return h(EditMoreConfigured, {
+          data: selectedRows,
+          onSubmit() {
+            refresh(page.value);
+            VxeUI.modal.close('import-more-configured');
+          },
+        });
+      },
+    },
+  });
+}
+</script>
+<template>
+  <div class="page-container flex flex-col">
+    <header class="flex-none mt-4">
+      <vxe-form v-bind="searchFormProps" v-on="searchFormEmits">
+        <template #createTimes>
+          <div class="date-range-container">
+            <a-date-picker v-model:value="updateTimeStart" placeholder="请选择开始时间" style="flex: 1" :show-time="{ format: 'HH:mm' }" />
+            <span class="date-separator">至</span>
+            <a-date-picker v-model:value="updateTimeEnd" placeholder="请选择结束时间" style="flex: 1" :disabledDate="disabledEndDate" :show-time="{ format: 'HH:mm' }" />
+          </div>
+        </template>
+      </vxe-form>
+    </header>
+    <main class="flex-auto overflow-hidden">
+      <vxe-grid ref="gridRef" v-bind="gridOptions" v-on="gridEvents" :loading="loading">
+        <template #toolbar-extra>
+          <vxe-button style="margin-right: 12px" icon="vxe-icon-repeat" circle @click="refresh(page)"></vxe-button>
+        </template>
+      </vxe-grid>
+    </main>
+    <footer class="flex-none">
+      <vxe-pager
+        v-model:current-page="page"
+        v-model:page-size="pageSize"
+        :total="total"
+        :layouts="['Home', 'PrevJump', 'PrevPage', 'Number', 'NextPage', 'NextJump', 'End', 'Sizes', 'FullJump', 'Total']"
+      />
+    </footer>
+  </div>
+</template>
+<style scoped lang="scss">
+.page-container {
+  padding: 0 24px;
+  max-height: var(--page-main-container);
+}
+.date-range-container {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  width: 100%;
+
+  .vxe-input {
+    flex: 1;
+    min-width: 0;
+  }
+
+  .date-separator {
+    color: #666;
+    font-size: 14px;
+    font-weight: 500;
+    white-space: nowrap;
+    padding: 0 8px;
+  }
+}
+</style>

+ 411 - 0
src/pages/index/equipment/reportManagement.vue

@@ -0,0 +1,411 @@
+<script setup lang="ts">
+import { type VxeFormListeners, type VxeFormProps, VxeUI } from 'vxe-pc-ui';
+import { usePagination, useRequest } from 'alova/client';
+import { notification } from 'ant-design-vue';
+import { getDictionaryMethod } from '@/request/api/dictionary.api';
+import dayjs from 'dayjs';
+import TongueAnalysisReport from '@/components/TongueAnalysisReport.vue';
+import type { DeviceReportModel } from '@/model/device.model';
+defineOptions({ name: 'EquipmentConfiguredPage' });
+import { h } from 'vue';
+
+import type { EquirementQuery } from '@/model/device.model';
+// 接口数据
+import { deviceReportMethod } from '@/request/api/device.api';
+import { branchMethod } from '@/request/api/system.api';
+// 获取组织树
+const { data: branch, loading: branchLoading } = useRequest(branchMethod(0, 1, 1));
+
+const model = shallowRef<EquirementQuery>();
+// 获取设备类型
+const deviceTypes = ref<{ id: string; name: string }[]>([]);
+const deviceTypesLoading = ref(false);
+// 日期验证
+const createTimeStart = ref<string>('');
+const createTimeEnd = ref<string>('');
+// 禁用结束时间的日期(早于开始时间的日期)
+function disabledEndDate(current: any) {
+  if (!createTimeStart.value) return false;
+  return current && current < dayjs(createTimeStart.value);
+}
+
+const searchFormProps = reactive<VxeFormProps<EquirementQuery>>({
+  titleWidth: 100,
+  titleAlign: 'right',
+  titleColon: true,
+  data: {},
+  items: [
+    {
+      field: 'orgId',
+      title: '组织名称',
+      span: 6,
+      itemRender: {
+        name: 'VxeTreeSelect',
+        props: {
+          loading: computed(() => branchLoading.value),
+          options: computed(() => branch.value),
+          optionProps: { value: 'id', label: 'label' },
+          clearable: true,
+        },
+        events: {
+          change(val: any) {
+            insArr.value = [];
+            if (val.data.orgId) {
+              // 清空表单中的机构名称字段
+              if (model.value) {
+                model.value.institutionId = '';
+              }
+              getInstitution(val.data.orgId);
+            }
+          },
+        },
+      },
+    },
+    {
+      field: 'deviceType',
+      title: '设备名称',
+      span: 6,
+      itemRender: {
+        name: 'VxeSelect',
+        props: {
+          placeholder: '请选择',
+          loading: deviceTypesLoading.value,
+          options: computed(() => deviceTypes.value),
+          optionProps: { value: 'id', label: 'name' },
+          optionGroupProps: { options: 'groups' },
+          clearable: true,
+          filterable: true,
+        },
+      },
+    },
+    {
+      field: 'deviceCode',
+      title: '设备ID',
+      span: 6,
+      itemRender: { name: 'VxeInput', props: { placeholder: '请输入' } },
+    },
+
+    {
+      field: 'phone',
+      title: '手机号码',
+      span: 6,
+      itemRender: { name: 'VxeInput', props: { placeholder: '请输入' } },
+    },
+    {
+      field: 'cardno',
+      title: '身份证号码',
+      span: 6,
+      itemRender: { name: 'VxeInput', props: { placeholder: '请输入' } },
+    },
+    {
+      field: 'createTime',
+      title: '生成时间',
+      span: 10,
+      slots: {
+        default: 'createTimes',
+      },
+    },
+    {
+      span: 6,
+      itemRender: {
+        name: 'VxeButtonGroup',
+        options: [
+          { name: 'submits', type: 'submit', content: '查询', status: 'primary' },
+          { name: 'reset', type: 'reset', content: '清空', status: 'warning' },
+        ],
+        events: {
+          click(slotParams, { name }) {},
+        },
+      },
+    },
+  ],
+});
+const insLoading = ref(false);
+const insArr = ref<any[]>([]);
+async function getInstitution(orgId: string | number) {
+  insLoading.value = true;
+  const res = await branchMethod(1, 0, Number(orgId));
+  if (res && res.length > 0) {
+    insArr.value = res;
+  }
+  insLoading.value = false;
+}
+const searchFormEmits: VxeFormListeners<EquirementQuery> = {
+  // 查询设备登记
+  submit({ data }) {
+    model.value = {
+      ...data,
+      createTimeStart: createTimeStart.value ? dayjs(createTimeStart.value).format('YYYY-MM-DD HH:mm:ss') : '',
+      createTimeEnd: createTimeEnd.value ? dayjs(createTimeEnd.value).format('YYYY-MM-DD HH:mm:ss') : '',
+    } as any;
+  },
+  // 重置
+  reset({ data }) {
+    model.value = { ...data } as any;
+    createTimeStart.value = '';
+    createTimeEnd.value = '';
+  },
+};
+// 设备列表
+const gridRef = ref<any>();
+const gridOptions = reactive<any>({
+  id: 'tag-list',
+  border: true,
+  showOverflow: true,
+  height: 'auto',
+  autoResize: false,
+  syncResize: true,
+  scrollY: { enabled: true, gt: 0 },
+  toolbarConfig: {
+    custom: true,
+    zoom: true,
+    slots: {
+      tools: 'toolbar-extra',
+    },
+  },
+  columnConfig: {
+    resizable: true,
+  },
+  customConfig: {
+    storage: true,
+  },
+  columns: [
+    { field: 'orgName', title: '组织名称' },
+    { field: 'deviceType', title: '设备名称' },
+    { field: 'deviceCode', title: '设备ID' },
+    { field: 'name', title: '用户姓名' },
+    { field: 'phone', title: '手机号' },
+    { field: 'cardno', title: '身份证号' },
+    { field: 'createTime', title: '生成时间' },
+    {
+      field: 'action',
+      title: '操作',
+      align: 'center',
+      width: 280,
+      showOverflow: false,
+      slots: {
+        default: 'actionButtons',
+      },
+    },
+  ],
+  data: [],
+});
+const gridEvents: any = {};
+
+// 分页
+const { loading, page, pageSize, total, onSuccess, refresh } = usePagination((page, size) => deviceReportMethod(page, size, model.value), {
+  initialData: { data: [], total: 0 },
+  initialPage: 1,
+  initialPageSize: 100,
+  watchingStates: [model],
+  immediate: false,
+});
+onSuccess(({ data: { data } }) => {
+  gridRef.value?.loadData(data);
+});
+
+// 获取设备类型
+async function getDeviceType() {
+  deviceTypesLoading.value = true;
+  const res = await getDictionaryMethod('fdhb_device_type');
+  if (res && res.length > 0) {
+    deviceTypes.value = res.map((item: any) => ({
+      id: item.value,
+      name: item.label,
+    }));
+  }
+  deviceTypesLoading.value = false;
+}
+
+onMounted(() => {
+  getDeviceType();
+  model.value = toRaw(searchFormProps.data);
+});
+
+// 查看健康分析报告
+function openHistoryPreviewHandle(row: DeviceReportModel) {
+  console.log(row, '健康分析报告');
+  const component = defineAsyncComponent(() => import('@/components/ReportPreview.vue'));
+  const id = `drawer:report:preview`;
+  const onDestroy = () => {
+    VxeUI.drawer.close(id);
+  };
+  onDestroy();
+  VxeUI.drawer.open({
+    id,
+    mask: true,
+    lockView: false,
+    padding: false,
+    width: window.innerWidth - 256,
+    escClosable: true,
+    maskClosable: true,
+    title: `健康分析报告`,
+    slots: {
+      default() {
+        return h(component, {
+          reportId: row.healthAnalysisReportId?.toString(),
+          onDestroy,
+        });
+      },
+    },
+  });
+}
+// 脉象报告
+function pulseAnalysisReport(row: any) {
+  console.log(row, '脉象报告');
+  const id = `drawer:report-pulse:preview`;
+  const onDestroy = () => {
+    VxeUI.drawer.close(id);
+  };
+  onDestroy();
+
+  // 构建脉象报告URL,可以根据row数据动态生成
+  // const reportUrl = `https://hybrid.reborn-tech.com/report.html#/?mid=d78511637507423fb4323ab4cc38210e&access_session=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhcHBJZCI6ImhKbjVEM3JyIiwiYXBwU2VjcmV0IjoiMGYwZDQ1MDIyNmYzMTZkNjY4ZDZlYjhiY2ZiYjRhY2NhNGNjYzQ3ZiJ9.Gnk2gUdO5EsN_C8fvwj9QaWpiNGizzFAypERKPk_u7A&appId=hJn5D3rr`;
+  const reportUrl = row?.pulseAnalysisReportUrl;
+  VxeUI.drawer.open({
+    id,
+    title: `脉象报告`,
+    maskClosable: true,
+    escClosable: true,
+    padding: false,
+    width: window.innerWidth - 256,
+    slots: {
+      default() {
+        return h(
+          'div',
+          {
+            style: {
+              width: '100%',
+              height: '100%',
+              border: 'none',
+              overflow: 'hidden',
+              position: 'relative',
+            },
+          },
+          [
+            h('iframe', {
+              src: reportUrl,
+              style: {
+                width: '100%',
+                height: '100%',
+                border: 'none',
+                display: 'block',
+              },
+              allow: 'camera; microphone; geolocation; payment; usb; vr; accelerometer; gyroscope; magnetometer; ambient-light-sensor; cross-origin-isolated',
+              sandbox: 'allow-same-origin allow-scripts allow-forms allow-popups allow-presentation allow-downloads',
+              onLoad: () => {
+                console.log('脉象报告页面加载完成');
+              },
+              onError: (error: any) => {
+                console.error('脉象报告页面加载失败:', error);
+                // 可以在这里显示错误提示
+              },
+            }),
+          ]
+        );
+      },
+    },
+    onHide() {
+      VxeUI.modal.close();
+    },
+  });
+}
+// 舌面分析报告
+function tongueAndFaceAnalysisReport(row: any) {
+  console.log(row, '舌面分析报告');
+  const id = `drawer:report-tongue:preview`;
+  const onDestroy = () => {
+    VxeUI.drawer.close(id);
+  };
+  VxeUI.drawer.open({
+    id,
+    title: `舌面分析报告`,
+    maskClosable: true,
+    escClosable: true,
+    padding: false,
+    width: window.innerWidth - 256,
+    slots: {
+      default() {
+        return h(TongueAnalysisReport, {
+          report: row,
+          onDestroy,
+          onRefresh() {
+            refresh(page.value);
+          },
+        });
+      },
+    },
+    onHide() {
+      VxeUI.modal.close();
+    },
+  });
+}
+</script>
+<template>
+  <div class="page-container flex flex-col">
+    <header class="flex-none mt-4">
+      <vxe-form v-bind="searchFormProps" v-on="searchFormEmits">
+        <template #createTimes>
+          <div class="date-range-container">
+            <a-date-picker v-model:value="createTimeStart" placeholder="请选择开始时间" style="flex: 1" :show-time="{ format: 'HH:mm' }" />
+            <span class="date-separator">至</span>
+            <a-date-picker v-model:value="createTimeEnd" placeholder="请选择结束时间" style="flex: 1" :disabledDate="disabledEndDate" :show-time="{ format: 'HH:mm' }" />
+          </div>
+        </template>
+      </vxe-form>
+    </header>
+    <main class="flex-auto overflow-hidden">
+      <vxe-grid ref="gridRef" v-bind="gridOptions" v-on="gridEvents" :loading="loading">
+        <template #toolbar-extra>
+          <vxe-button style="margin-right: 12px" icon="vxe-icon-repeat" circle @click="refresh(page)"></vxe-button>
+        </template>
+
+        <!-- 动态操作按钮 -->
+        <template #actionButtons="{ row }">
+          <vxe-button-group mode="text">
+            <!-- 健康分析报告 - 根据 healthAnalysisReportId 判断 -->
+            <vxe-button v-if="row.healthAnalysisReportId" status="primary" @click="openHistoryPreviewHandle(row)"> 健康分析报告 </vxe-button>
+            <!-- 脉象报告 - 根据 pulseAnalysisReportId 判断 -->
+            <vxe-button v-if="row.pulseAnalysisReportId" status="primary" @click="pulseAnalysisReport(row)"> 脉象报告 </vxe-button>
+            <!-- 舌面分析报告 - 根据 tonguefaceAnalysisReportId 判断 -->
+            <vxe-button v-if="row.tonguefaceAnalysisReportId" status="primary" @click="tongueAndFaceAnalysisReport(row)"> 舌面分析报告 </vxe-button>
+          </vxe-button-group>
+        </template>
+      </vxe-grid>
+    </main>
+    <footer class="flex-none">
+      <vxe-pager
+        v-model:current-page="page"
+        v-model:page-size="pageSize"
+        :total="total"
+        :layouts="['Home', 'PrevJump', 'PrevPage', 'Number', 'NextPage', 'NextJump', 'End', 'Sizes', 'FullJump', 'Total']"
+      />
+    </footer>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.page-container {
+  padding: 0 24px;
+  max-height: var(--page-main-container);
+}
+.date-range-container {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  width: 100%;
+
+  .vxe-input {
+    flex: 1;
+    min-width: 0;
+  }
+
+  .date-separator {
+    color: #666;
+    font-size: 14px;
+    font-weight: 500;
+    white-space: nowrap;
+    padding: 0 8px;
+  }
+}
+</style>

+ 1 - 0
src/pages/index/patient/history.vue

@@ -115,6 +115,7 @@ const gridOptions = reactive<VxeGridProps<PatientReportModel>>({
   columns: [
     { type: 'seq', width: 70, fixed: 'left' },
     { field: 'name', title: '姓名', minWidth: 80 },
+    { field: 'phone', title: '手机号码', minWidth: 80 },
     { field: 'gender', title: '性别', minWidth: 40, formatter: 'gender' },
     { field: 'age', title: '年龄', minWidth: 40, formatter: ({ cellValue }) => (cellValue ? `${cellValue}岁` : '') },
     { field: 'diagnosis', title: '诊断', minWidth: 40 },

+ 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 {};

+ 11 - 16
src/request/api/account.api.ts

@@ -69,22 +69,17 @@ export function getMenusMethod(account: AccountModel) {
   return request.Get<AccountModel, any[]>(`/system/menu/getRouters`, {
     headers: { Authorization: account.token },
     transform(data) {
-      // data.push({
-      //   path: '/equipment',
-      //   meta: { title: '设备管理' },
-      //   children: [
-      //       {
-      //         path: 'registe',
-      //         meta: { title: '设备登记' },
-      //       },
-            // {
-            //   path: 'configured',
-            //   meta: { title: '调理方案配置' },
-            // },
-      //   ],
-      // });
-      // console.log(data, 'push之后的data', transformMenus(data));
+    // data[5]?.children?.push({
+    //     path: 'configured',
+    //     meta: { title: '辨识仪配置' }
+    //   },
+    //   {
+    //     path: 'reportManagement',
+    //     meta: { title: '报告管理' }
+    //   },
+    // );
+    //   console.log(data, 'push之后的data', transformMenus(data));
       return { ...account, menus: transformMenus(data) };
-    },
+    }
   });
 }

+ 2 - 3
src/request/api/care.api.ts

@@ -28,7 +28,7 @@ export function supplierDeleteMethod(data: Partial<SupplierModel>) {
 //系统项目列表
 export function pageSystemCpMethod(page: number, size: number, query?: SystemIteQuery) {
   return request.Post<List<SystemItemModel>>('/fdhb-pc/conditioningManage/program/pageSystemCp', query ?? {}, {
-    // hitSource: /plan$/, // 匹配失效源
+    hitSource: /system-cp$/, // 系统项目专用缓存标识
     params: { pageNum: page, pageSize: size },
   });
 }
@@ -45,7 +45,6 @@ export function getAllSystemCpMethod() {
 }
 // 新增和编辑系统项目和新增编辑项目列表。  项目列表就是机构项目
 export function systemCpEditMethod(data: Partial<SystemItemModel>) {
-  console.log(data, 'data==>', data.addType,data.id,data.isType);
   if(data.isType === 'itemsList'){
     delete data.id;
   }
@@ -79,7 +78,7 @@ export function selectSystemCpMethod(data: Array<string>) {
 // 机构待确认列表
 export function pageOrgConfirmMethod(page: number, size: number, query?: SystemIteQuery) {
   return request.Post<List<SystemItemModel>>('/fdhb-pc/conditioningManage/program/pageUnconfirmedCp', query ?? {}, {
-    hitSource: /plan$/, // 匹配失效源
+    hitSource: /org-confirm$/, // 待确认项目专用缓存标识
     params: { pageNum: page, pageSize: size },
   });
 }

+ 36 - 1
src/request/api/device.api.ts

@@ -1,5 +1,5 @@
 import type { List } from '@/model';
-import type { EquirementModel } from '@/model/device.model';
+import type { EquirementModel,DeviceManageModel,DeviceReportModel } from '@/model/device.model';
 import request from '@/request/alova';
 // 设备登记分页列表
 export function getDeviceRegisterMethod(page: number, size: number, query?: Record<string, any>) {
@@ -44,5 +44,40 @@ export function updateDeviceRegisterOrganizationMethod(data: any) {
   );
 }
 
+// 一体机配置分页列表
+export function deviceManageMethod(page: number, size: number, query?: Record<string, any>) {
+  return request.Post<List<DeviceManageModel>>('/fdhb-pc/deviceManage/tablet/sets/page', query ?? {}, {
+    hitSource: /plan$/, // 匹配失效源
+    params: { pageNum: page, pageSize: size },
+  });
+}
+// 根据一体机id获取一体机配置详情
+export function getDeviceManageDetailMethod(data: Partial<DeviceManageModel>) {
+  return request.Post(`/fdhb-pc/deviceManage/tablet/sets/detail/${data.id}`, {
+    name: 'get-device-manage-detail',
+    cacheFor: null,
+  });
+}
+// 修改一体机配置
+export function updateDeviceManageMethod(data: Partial<DeviceManageModel>) {
+  return request.Post(`/fdhb-pc/deviceManage/tablet/sets/update`, data, {
+    name: 'update-device-manage',
+    cacheFor: null,
+  });
+}
+// 批量修改一体机配置
+export function batchUpdateDeviceManageMethod(data: Partial<DeviceManageModel>) {
+  return request.Post(`/fdhb-pc/deviceManage/tablet/sets/batchUpdate`, data, {
+    name: 'batch-update-device-manage',
+    cacheFor: null,
+  });
+}
+// 一体机报告分页列表
+export function deviceReportMethod(page: number, size: number, query?: Record<string, any>) {
+  return request.Post<List<DeviceReportModel>>('/fdhb-pc/deviceManage/tablet/report/page', query ?? {}, {
+    hitSource: /plan$/, // 匹配失效源
+    params: { pageNum: page, pageSize: size },
+  });
+}
 
 

+ 9 - 0
src/request/api/report.api.ts

@@ -249,6 +249,15 @@ export function getPatientHealthRecordMethod(id: string) {
     },
   });
 }
+// 获取舌面分析报告 getTofRepDetailById
+export function getTonguefaceAnalysisReportMethod(id: string) {
+  return request.Get<HealthReportVO, HealthReportDTO>(`/fdhb-pc/analysisManage/getTofRepDetailById`, {
+    params: { tonguefaceAnalysisReportId: id },
+    transform(data) {
+      return fromHealthReport(data);
+    },
+  });
+}
 
 export function getPatientHealthIndicatorsMethod(patientId: string) {
   return request.Get<HealthIndicatorVO[], any[]>(`/fdhb-pc/patientQuota/getQuovalRecord`, {

+ 4 - 0
src/router/index.ts

@@ -42,11 +42,15 @@ const router = createRouter({
             { path: 'issueService', component: () => import(`@/pages/index/care/issueService.vue`), },
             { path: 'conditioningRecord', component: () => import(`@/pages/index/care/conditioningRecord.vue`), },
             { path: 'configured', component: () => import(`@/pages/index/care/configured.vue`), },
+            
           ],
         },
         {
           path: 'equipment', children: [
             { path: 'registe', component: () => import(`@/pages/index/equipment/registe.vue`) },
+            { path: 'configured', component: () => import(`@/pages/index/equipment/configured.vue`) },
+            { path: 'reportManagement', component: () => import(`@/pages/index/equipment/reportManagement.vue`) },
+            
           ],
         },   
         {

+ 129 - 74
src/service/AddItems.vue

@@ -1,8 +1,7 @@
 <script lang="ts" setup>
-import { ref, watch, reactive, onMounted, h, computed } from 'vue';
+import { ref, watch, reactive, onMounted, h, computed, nextTick } from 'vue';
 import { message } from 'ant-design-vue';
 import { PlusOutlined } from '@ant-design/icons-vue'; // 确保导入
-import { notification } from 'ant-design-vue';
 import VxeUI from 'vxe-table';
 import { useRequest } from 'alova/client';
 import { getDictionaryMethod, cpMedicinesMethod } from '@/request/api/dictionary.api';
@@ -21,10 +20,13 @@ const formRef = ref<FormInstance>();
 const typeOptionsLoading = ref<boolean>(false);
 const typeOptions = ref<{ label: string; value: string }[]>([]);
 
+// 统一提升消息提示层级,避免被弹窗遮挡
+message.config({ zIndex: 5000, top: 80, maxCount: 3, duration: 2 });
+
 const supplierOptions = ref<{ label: string; value: string }[]>([]);
 
 const isRequired = ref<boolean>(true);
-
+// 使用单位
 const unitOptions = [
   { label: '袋', value: '袋' },
   { label: '包', value: '包' },
@@ -100,17 +102,36 @@ const checkedList = ref<string[]>(['1']); // 默认选中第一个
 const onlineArr = ref<string[]>([]);
 const deliverArr = ref<string[]>([]);
 
+// 添加推导逻辑编辑状态
+const isDerivationModalOpen = ref(false);
+
 const rules = {
   name: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
   conditioningProgramType: [{ required: true, message: '请选择方案类型', trigger: 'change' }],
-  institutionId: [{ required: true, message: '请选择机构名称', trigger: 'change' }],
+  institutionId: [{ 
+    required: true, 
+    message: '请选择机构名称', 
+    trigger: 'blur',
+    validator: (rule: any, value: any, callback: any) => {
+      // 如果推导逻辑弹窗打开,跳过验证
+      if (isDerivationModalOpen.value) {
+        callback();
+        return;
+      }
+      if (!value) {
+        callback(new Error('请选择机构名称'));
+      } else {
+        callback();
+      }
+    }
+  }],
+  isOffline: [{ required: true, message: '请选择线下项目', trigger: 'change' }],
+  isDelivery: [{ required: true, message: '请选择是否配送', trigger: 'change' }],
 };
 const isShowOnline = ref<boolean>(false);
 const isShowDelivery = ref<boolean>(false);
 const supplierArr = ref<any[]>([]);
 
-// 弹层容器:避免模板中直接引用 document 导致类型检查报错
-const getBodyContainer = () => document.body as HTMLElement;
 
 // 获取所有的供应商
 async function getSupplier(params: any) {
@@ -223,23 +244,19 @@ function removeHerb(idx: number) {
   }
 }
 
-// 新增:处理自定义药材输入
-function handleCustomHerb(value: string, idx: number) {
-  if (form.cpMedicines && form.cpMedicines[idx]) {
-    form.cpMedicines[idx].name = value;
-    form.cpMedicines[idx].id = value; // 自定义选项使用输入值作为ID
-  }
-}
-
 function cancel() {
   VxeUI.modal.close(`add-items-modal`);
 }
 function doSubmit() {
+  // 手动验证方案类型
+  if (!form.conditioningProgramType) {
+    message.error('请选择方案类型');
+    return;
+  }
+  
   // 自定义验证:检查项目应用是否已选择
   if (!checkedList.value || checkedList.value.length === 0) {
-    notification.error({
-      message: '请选择项目应用',
-    });
+    message.error('请选择项目应用');
     return;
   }
   form.isForWrap = checkedList.value.includes('1') ? 'Y' : null;
@@ -248,9 +265,7 @@ function doSubmit() {
   if ((form.addType === 'itemsList' && checkedList.value.includes('1')) || form.addType === 'system') {
     // 计价规则
     if (!form.pricingType) {
-      notification.error({
-        message: '请选择计价规则',
-      });
+      message.error('请选择计价规则');
       return;
     }
     // 计价规则相关字段校验
@@ -258,9 +273,7 @@ function doSubmit() {
       const { unitPrice, pricingUnit, convertDose, convertUnit } = form.cpFixedPricingRule || {};
       const allFilled = (unitPrice !== '' && unitPrice !== null && unitPrice !== undefined) && pricingUnit && (convertDose !== '' && convertDose !== null && convertDose !== undefined) && convertUnit;
       if (!allFilled) {
-        notification.error({
-          message: '请将单价、计价单位、相当于、使用单位全部填写',
-        });
+        message.error('请将单价、计价单位、相当于、使用单位全部填写');
         return;
       }
       form.cpDynamicPricingRule = undefined;
@@ -272,21 +285,20 @@ function doSubmit() {
       rule1.max = 0;
       rule2.min = 0;
       if (!(allFilled1 && allFilled2)) {
-        notification.error({
-          message: '请将按穴位/经络/部位的所有计价字段全部填写',
-        });
+        message.error('请将按穴位/经络/部位的所有计价字段全部填写');
         return;
       }
       // 按穴位模式:清空一口价数据
       form.cpFixedPricingRule = undefined;
     }
     // 供应商
-    if (form.addType === 'itemsList' && checkedList.value.includes('1')) {
-      if (!form.conditioningProgramSupplierId) {
-        message.error('请选择供应商');
-        return;
-      }
-    } else if (form.addType === 'system') {
+    // if (form.addType === 'itemsList' && checkedList.value.includes('1')) {
+    //   // if (!form.conditioningProgramSupplierId) {
+    //   //   message.error('请选择供应商');
+    //   //   return;
+    //   // }
+    // } else
+     if (form.addType === 'system') {
       form.isOffline = 'N';
       form.isDelivery = 'N';
     }
@@ -360,11 +372,6 @@ function doSubmit() {
     }
     submit(form);
   });
-  // .catch((error: any) => {
-  //   notification.error({
-  //     message: error.message || '必填内容请填写完整',
-  //   });
-  // });
 }
 // 获取方案类型
 async function getConditioningProgramType() {
@@ -375,9 +382,7 @@ async function getConditioningProgramType() {
       typeOptions.value = res; // 直接使用返回的数据
     }
   } catch (error: any) {
-    notification.error({
-      message: error.message,
-    });
+    message.error(error.message);
   } finally {
     typeOptionsLoading.value = false;
   }
@@ -393,9 +398,7 @@ async function getJumpType() {
     jumpTypeOptions.value = res;
   }
 } catch (error: any) {
-  notification.error({
-    message: error.message,
-  });
+  message.error(error.message);
 } finally {
   jumpTypeOptionsLoading.value = false;
 }
@@ -409,12 +412,6 @@ function handleSelectClick() {
   }
 }
 
-// 搜索过滤函数
-function filterOption(input: string, option: any) {
-  const searchText = input.toLowerCase();
-  const optionText = option.label?.toLowerCase() || '';
-  return optionText.includes(searchText);
-}
 
 onMounted(async () => {
   const deptId = localStorage.getItem('deptId');
@@ -550,10 +547,26 @@ const uploading = ref(false);
 const progress = ref(0);
 const videoFileList = ref<UploadFile[]>([]);
 
-// 供应商校验规则:仅在“项目应用 勾选 服务包”且“新增项目(itemsList)”时必填
+// 供应商校验规则:仅在"项目应用 勾选 服务包"且"新增项目(itemsList)"时必填
 const supplierRules = computed(() => {
   const need = checkedList.value.includes('1') && form.addType === 'itemsList';
-  return need ? [{ required: true, message: '请选择供应商', trigger: ['change', 'blur'] }] : [];
+  return need ? [{ 
+    required: true, 
+    message: '请选择供应商', 
+    trigger: 'blur',
+    validator: (rule: any, value: any, callback: any) => {
+      // 如果推导逻辑弹窗打开,跳过验证
+      if (isDerivationModalOpen.value) {
+        callback();
+        return;
+      }
+      if (!value) {
+        callback(new Error('请选择供应商'));
+      } else {
+        callback();
+      }
+    }
+  }] : [];
 });
 
 // 上传前校验
@@ -714,10 +727,6 @@ watch(
     }
   }
 );
-function bindchange(e: any) {
-  form.conditioningProgramSupplierId = '';
-  form.isOffline = null;
-}
 function onlineChange(value: any) {
   form.isOffline = value[value.length - 1];
   deliverArr.value = [];
@@ -733,15 +742,6 @@ function getConditioningProgramSupplier(value: any) {
   form.isDelivery = null;
   isShowDelivery.value = false;
 }
-function handleSelect(value: any, node: any) {
-  form.institutionId = value;
-  if (node && node.label) {
-    form.institutionName = node.label;
-  } else if (node && node.title) {
-    form.institutionName = node.title;
-  }
-}
-
 // 项目应用数据
 const plainOptions = [
   { id: '1', name: '服务包项目' },
@@ -764,6 +764,9 @@ const isDerivationEmpty = computed(() => {
   });
 });
 function handleDerivation() {
+  // 设置推导逻辑弹窗打开状态
+  isDerivationModalOpen.value = true;
+  
   VxeUI.modal.open({
     title: `推导逻辑`,
     height: 750,
@@ -773,6 +776,13 @@ function handleDerivation() {
     id: `derivation-modal`,
     remember: true,
     storage: true,
+    onClose: () => {
+      // 弹窗关闭时清除验证状态并重置状态
+      isDerivationModalOpen.value = false;
+      nextTick(() => {
+        formRef.value?.clearValidate(['institutionId', 'conditioningProgramSupplierId']);
+      });
+    },
     slots: {
       default() {
         return h(Derivation, {
@@ -785,6 +795,19 @@ function handleDerivation() {
           onSubmit: (data: any) => {
             derivationData.value = data;
             hasDerivationLogic.value = true; // 设置推导逻辑已编辑
+            
+            // 重置状态并清除验证状态
+            isDerivationModalOpen.value = false;
+            nextTick(() => {
+              formRef.value?.clearValidate(['institutionId', 'conditioningProgramSupplierId']);
+            });
+          },
+          onCancel: () => {
+            // 取消时重置状态并清除验证状态
+            isDerivationModalOpen.value = false;
+            nextTick(() => {
+              formRef.value?.clearValidate(['institutionId', 'conditioningProgramSupplierId']);
+            });
           },
         });
       },
@@ -796,11 +819,21 @@ function handleDerivation() {
 <template>
   <div class="form-container">
     <a-form ref="formRef" :model="form" :rules="rules" layout="horizontal">
-      <a-form-item label="项目名称:" name="name" required>
+      <a-form-item label="项目名称:" name="name">
         <a-input v-model:value="form.name" placeholder="请输入" />
       </a-form-item>
-      <a-form-item label="方案类型:" name="conditioningProgramType" required style="width: 100%">
-        <vxe-select v-model="form.conditioningProgramType" :options="typeOptions" placeholder="请选择" clearable filterable transfer @change="bindchange" style="width: 100%" />
+      <a-form-item label="方案类型:" name="conditioningProgramType" style="width: 100%" required>
+        <vxe-select
+          v-model="form.conditioningProgramType"
+          :options="typeOptions"
+          placeholder="请选择方案类型"
+          clearable
+          filterable
+          transfer
+          empty-text="暂无数据"
+          @focus="handleSelectClick"
+          style="width: 100%"
+        />
       </a-form-item>
       <a-form-item label="项目应用:" required v-if="form.addType === 'itemsList'">
         <a-checkbox-group v-model:value="checkedList">
@@ -1080,15 +1113,18 @@ function handleDerivation() {
         ></a-tree-select>
       </a-form-item>
       <a-form-item label="供应商:" name="conditioningProgramSupplierId" :rules="supplierRules" style="width: 100%">
-        <vxe-select
-          v-model="form.conditioningProgramSupplierId"
+        <a-select
+          v-model:value="form.conditioningProgramSupplierId"
           :options="supplierOptions"
           placeholder="请选择"
-          clearable
-          filterable
-          transfer
-          @change="getConditioningProgramSupplier"
+          allow-clear
+          show-search
+          :getPopupContainer="getSafePopupContainer"
+          :dropdownMatchSelectWidth="false"
+          :dropdownStyle="{ zIndex: 5000 }"
           style="width: 100%"
+          @change="getConditioningProgramSupplier"
+          not-found-content="暂无数据"
         />
       </a-form-item>
       <a-form-item label="线下项目:" name="isOffline" v-if="isShowOnline" :required="checkedList.includes('1')">
@@ -1129,9 +1165,12 @@ function handleDerivation() {
 
 <style scoped>
 .form-container {
-  width: 760px;
+  width: 95%;
   margin: 0 auto;
   padding: 10px 0px 0 0;
+  display: flex;
+  flex-direction: column;
+  /* max-height: 80vh; */
 }
 .per-rule {
   margin-bottom: 16px;
@@ -1185,6 +1224,16 @@ function handleDerivation() {
   display: flex;
   justify-content: center;
   gap: 16px;
+  position: sticky;
+  bottom: 0;
+  background: #fff;
+  padding: 12px 0;
+}
+
+.form-container .ant-form {
+  display: flex;
+  flex-direction: column;
+  overflow: auto;
 }
 .slider-section {
   margin-bottom: 16px;
@@ -1482,3 +1531,9 @@ function handleDerivation() {
   font-size: 10px;
 }
 </style>
+<style>
+/* 提升 AntD Select 下拉层级,避免被弹窗/遮罩遮挡 */
+.ant-select-dropdown {
+  z-index: 5000 !important;
+}
+</style>

+ 1 - 1
src/service/CareProgress.vue

@@ -286,7 +286,7 @@ const progressTextMap: Record<string, string> = {
           <vxe-column type="seq" title="序号" width="80" />
           <vxe-column field="operateTime" title="操作时间" />
           <vxe-column field="operateBy" title="操作人" />
-          <vxe-column field="feedback" title="上次治疗反馈" />
+          <vxe-column field="feedback" title="治疗备注" />
           <vxe-column field="acuPointNames" title="穴位" />
         </vxe-table>
         <div class="mt-3">

+ 2 - 0
src/service/Derivation.vue

@@ -10,6 +10,7 @@ type FollowModel = Partial<SystemItemModel>;
 const props = defineProps<{ data: FollowModel }>();
 const emit = defineEmits<{
   (e: 'submit', data: FollowModel): void;
+  (e: 'cancel'): void;
 }>();
 const formData = reactive<FollowModel>({
   cpPatientMatchRule: {
@@ -25,6 +26,7 @@ const formData = reactive<FollowModel>({
 
 // 取消
 function cancel() {
+  emit('cancel'); // 触发取消事件
   VxeUI.modal.close(`derivation-modal`);
 }
 // 确定

+ 3 - 3
src/service/EditSystemService.vue

@@ -370,7 +370,7 @@ function calculateCount(row: any) {
     // 按穴位计价
     const frequencyType = Number(row.frequencyType) || 0;
 
-    row.totalMeasure = Math.ceil((period / frequencyType) * frequency);
+    row.totalMeasure = Math.ceil((period / frequencyType)) * frequency;
     // console.log(frequencyType, 'frequencyType', frequency, period, row.totalMeasure);
     // console.log(acCount, maxCount, 'acCount, maxCount', row.conditioningProgramDetail.cpDynamicPricingRule?.[1]?.priceType);
     if (acCount > maxCount) {
@@ -379,7 +379,7 @@ function calculateCount(row: any) {
         if (acCount > 0) {
           let unitPrice: number = row.conditioningProgramDetail.cpDynamicPricingRule[1].price * acCount;
           row.unitPrice = unitPrice;
-          row.totalPrice = Math.ceil((period / frequencyType) * frequency) * unitPrice;
+          row.totalPrice = row.totalMeasure * unitPrice;
         } else {
           row.unitPrice = '-';
           row.totalPrice = 0;
@@ -398,7 +398,7 @@ function calculateCount(row: any) {
           if (acCount > 0) {
             let unitPrice: number = row.conditioningProgramDetail.cpDynamicPricingRule[0].price * acCount;
             row.unitPrice = unitPrice;
-            row.totalPrice = Math.ceil((period / frequencyType) * frequency) * unitPrice;
+            row.totalPrice = row.totalMeasure * unitPrice;
           } else {
             row.unitPrice = '-';
             row.totalPrice = 0;

+ 1 - 3
src/service/ServiceItemsSystem.vue

@@ -152,9 +152,7 @@ onSuccess(({ data: { data } }) => {
   gridRef.value?.loadData(data);
 });
 
-// const send = () => {
-//   sendRefresh();
-// }
+
 
 onMounted(() => {
   model.value = toRaw(searchFormProps.data);

+ 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();
 }

+ 3 - 3
src/widgets/AnalysisReportWidget.vue

@@ -64,10 +64,10 @@ const { data: indicator, loading: indicatorLoading } = useWatcher(
     <a-card class="card" size="small" title="舌象分析">
       <a-descriptions :column="3">
         <a-descriptions-item>
-          <a-image :width="200" :src="report.upImg" :preview="true" />
+          <a-image :width="200" :src="report.upImg" :preview="true" v-if="report.upImg" />
         </a-descriptions-item>
         <a-descriptions-item>
-          <a-image :width="200" :src="report.downImg" :preview="true" />
+          <a-image :width="200" :src="report.downImg" :preview="true" v-if="report.downImg" />
         </a-descriptions-item>
         <a-descriptions-item></a-descriptions-item>
       </a-descriptions>
@@ -140,7 +140,7 @@ const { data: indicator, loading: indicatorLoading } = useWatcher(
     <a-card class="card no-bordered" size="small" title="面象分析" v-if="report.faceAnalysisResult">
       <a-descriptions :column="3">
         <a-descriptions-item>
-          <a-image :width="200" :src="report.faceImg" :preview="true" />
+          <a-image :width="200" :src="report.faceImg" :preview="true" v-if="report.faceImg" />
         </a-descriptions-item>
         <a-descriptions-item :span="2">{{ report.faceAnalysisResult }}</a-descriptions-item>
       </a-descriptions>

+ 2 - 2
src/widgets/ReportAnalysisWidgetTongue.vue

@@ -27,10 +27,10 @@ const exceptionData = computed(() => {
   <a-card class="card" size="small" :title="props.title" :loading="props.loading">
     <a-descriptions :column="3" size="small">
       <a-descriptions-item>
-        <a-image :width="200" :src="props.dataset.upImg" :preview="true" />
+        <a-image :width="200" :src="props.dataset.upImg" :preview="true" v-if="props.dataset.upImg" />
       </a-descriptions-item>
       <a-descriptions-item>
-        <a-image :width="200" :src="props.dataset.downImg" :preview="true" />
+        <a-image :width="200" :src="props.dataset.downImg" :preview="true" v-if="props.dataset.downImg" />
       </a-descriptions-item>
       <a-descriptions-item v-if="props.dataset.tongueAnalysisResult">
         <div class="flex flex-col">

+ 3 - 3
src/widgets/ReportCardWidget.vue

@@ -210,15 +210,15 @@ defineExpose({
                 <a-descriptions-item>{{ props.dataset.sublingualVein.standardValue }}</a-descriptions-item>
               </template>
             </a-descriptions>
-            <a-image :width="200" :height="200" :src="props.dataset.upImg" :preview="true" />
-            <a-image :width="200" :height="200" :src="props.dataset.downImg" :preview="true" />
+            <a-image :width="200" :height="200" :src="props.dataset.upImg" :preview="true" v-if="props.dataset.upImg" />
+            <a-image :width="200" :height="200" :src="props.dataset.downImg" :preview="true" v-if="props.dataset.downImg" />
           </a-space>
         </a-card>
         <template v-if="props.dataset?.faceAnalysisResult">
           <a-card class="card background" size="small" title="面象分析" :loading="props.loading">
             <a-space align="start" class="w-full analysis-wrapper">
               <div>{{ props.dataset.faceAnalysisResult }}</div>
-              <a-image :width="200" :height="200" :src="props.dataset.faceImg" :preview="true" />
+              <a-image :width="200" :height="200" :src="props.dataset.faceImg" :preview="true" v-if="props.dataset.faceImg" />
             </a-space>
           </a-card>
         </template>

+ 2 - 0
vite.config.ts

@@ -4,6 +4,7 @@ import vueJsx from '@vitejs/plugin-vue-jsx';
 import { fileURLToPath, URL } from 'node:url';
 
 import UnoCSS from 'unocss/vite';
+import SVGLoader from 'vite-svg-loader';
 
 import AutoImport               from 'unplugin-auto-import/vite';
 import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers';
@@ -41,6 +42,7 @@ export default defineConfig((configEnv) => {
       vueJsx(),
       vueDevTools(),
       UnoCSS(),
+      SVGLoader(),
       AutoImport({
         imports: [
           'vue',

Некоторые файлы не были показаны из-за большого количества измененных файлов