张田田 5 ヶ月 前
コミット
72f8ab9236

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

@@ -33,8 +33,13 @@ declare module 'vue-router/auto-routes' {
     '//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>>,
+    '//healthy/education': RouteRecordInfo<'//healthy/education', '/healthy/education', Record<never, never>, Record<never, never>>,
+    '//notify/manage': RouteRecordInfo<'//notify/manage', '/notify/manage', Record<never, never>, Record<never, never>>,
     '//patient/history': RouteRecordInfo<'//patient/history', '/patient/history', Record<never, never>, Record<never, never>>,
     '//patient/room': RouteRecordInfo<'//patient/room', '/patient/room', Record<never, never>, Record<never, never>>,
+    '//satisfaction/survey': RouteRecordInfo<'//satisfaction/survey', '/satisfaction/survey', Record<never, never>, Record<never, never>>,
+    '//system/institution': RouteRecordInfo<'//system/institution', '/system/institution', Record<never, never>, Record<never, never>>,
+    '//system/organization': RouteRecordInfo<'//system/organization', '/system/organization', Record<never, never>, Record<never, never>>,
     '//system/role': RouteRecordInfo<'//system/role', '/system/role', Record<never, never>, Record<never, never>>,
     '//system/tag': RouteRecordInfo<'//system/tag', '/system/tag', Record<never, never>, Record<never, never>>,
     '//system/user': RouteRecordInfo<'//system/user', '/system/user', Record<never, never>, Record<never, never>>,

ファイルの差分が大きいため隠しています
+ 687 - 9
package-lock.json


+ 2 - 0
package.json

@@ -22,6 +22,8 @@
     "@vueuse/components": "^10.11.0",
     "@vueuse/core": "^10.11.0",
     "@vueuse/router": "^10.11.1",
+    "@wangeditor/editor": "^5.1.23",
+    "@wangeditor/editor-for-vue": "^5.1.12",
     "alova": "^3.2.10",
     "ant-design-vue": "^4.2.3",
     "china-division": "^2.7.0",

+ 467 - 2
pnpm-lock.yaml

@@ -35,6 +35,12 @@ importers:
       '@vueuse/router':
         specifier: ^10.11.1
         version: 10.11.1(vue-router@4.5.0(vue@3.5.13(typescript@5.4.5)))(vue@3.5.13(typescript@5.4.5))
+      '@wangeditor/editor':
+        specifier: ^5.1.23
+        version: 5.1.23
+      '@wangeditor/editor-for-vue':
+        specifier: ^5.1.12
+        version: 5.1.12(@wangeditor/editor@5.1.23)(vue@3.5.13(typescript@5.4.5))
       alova:
         specifier: ^3.2.10
         version: 3.2.10
@@ -1152,6 +1158,9 @@ packages:
   '@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}
 
+  '@transloadit/prettier-bytes@0.0.7':
+    resolution: {integrity: sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==, tarball: https://registry.npmmirror.com/@transloadit/prettier-bytes/-/prettier-bytes-0.0.7.tgz}
+
   '@trysound/sax@0.2.0':
     resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
     engines: {node: '>=10.13.0'}
@@ -1162,6 +1171,9 @@ packages:
   '@types/estree@1.0.7':
     resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
 
+  '@types/event-emitter@0.3.5':
+    resolution: {integrity: sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ==, tarball: https://registry.npmmirror.com/@types/event-emitter/-/event-emitter-0.3.5.tgz}
+
   '@types/lodash-es@4.17.12':
     resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
 
@@ -1327,6 +1339,23 @@ packages:
     peerDependencies:
       vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0
 
+  '@uppy/companion-client@2.2.2':
+    resolution: {integrity: sha512-5mTp2iq97/mYSisMaBtFRry6PTgZA6SIL7LePteOV5x0/DxKfrZW3DEiQERJmYpHzy7k8johpm2gHnEKto56Og==, tarball: https://registry.npmmirror.com/@uppy/companion-client/-/companion-client-2.2.2.tgz}
+
+  '@uppy/core@2.3.4':
+    resolution: {integrity: sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==, tarball: https://registry.npmmirror.com/@uppy/core/-/core-2.3.4.tgz}
+
+  '@uppy/store-default@2.1.1':
+    resolution: {integrity: sha512-xnpTxvot2SeAwGwbvmJ899ASk5tYXhmZzD/aCFsXePh/v8rNvR2pKlcQUH7cF/y4baUGq3FHO/daKCok/mpKqQ==, tarball: https://registry.npmmirror.com/@uppy/store-default/-/store-default-2.1.1.tgz}
+
+  '@uppy/utils@4.1.3':
+    resolution: {integrity: sha512-nTuMvwWYobnJcytDO3t+D6IkVq/Qs4Xv3vyoEZ+Iaf8gegZP+rEyoaFT2CK5XLRMienPyqRqNbIfRuFaOWSIFw==, tarball: https://registry.npmmirror.com/@uppy/utils/-/utils-4.1.3.tgz}
+
+  '@uppy/xhr-upload@2.1.3':
+    resolution: {integrity: sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==, tarball: https://registry.npmmirror.com/@uppy/xhr-upload/-/xhr-upload-2.1.3.tgz}
+    peerDependencies:
+      '@uppy/core': ^2.3.3
+
   '@vitejs/plugin-vue-jsx@4.1.2':
     resolution: {integrity: sha512-4Rk0GdE0QCdsIkuMmWeg11gmM4x8UmTnZR/LWPm7QJ7+BsK4tq08udrN0isrrWqz5heFy9HLV/7bOLgFS8hUjA==}
     engines: {node: ^18.0.0 || >=20.0.0}
@@ -1480,6 +1509,93 @@ packages:
     peerDependencies:
       vue: ^3.2.0
 
+  '@wangeditor/basic-modules@1.1.7':
+    resolution: {integrity: sha512-cY9CPkLJaqF05STqfpZKWG4LpxTMeGSIIF1fHvfm/mz+JXatCagjdkbxdikOuKYlxDdeqvOeBmsUBItufDLXZg==, tarball: https://registry.npmmirror.com/@wangeditor/basic-modules/-/basic-modules-1.1.7.tgz}
+    peerDependencies:
+      '@wangeditor/core': 1.x
+      dom7: ^3.0.0
+      lodash.throttle: ^4.1.1
+      nanoid: ^3.2.0
+      slate: ^0.72.0
+      snabbdom: ^3.1.0
+
+  '@wangeditor/code-highlight@1.0.3':
+    resolution: {integrity: sha512-iazHwO14XpCuIWJNTQTikqUhGKyqj+dUNWJ9288Oym9M2xMVHvnsOmDU2sgUDWVy+pOLojReMPgXCsvvNlOOhw==, tarball: https://registry.npmmirror.com/@wangeditor/code-highlight/-/code-highlight-1.0.3.tgz}
+    peerDependencies:
+      '@wangeditor/core': 1.x
+      dom7: ^3.0.0
+      slate: ^0.72.0
+      snabbdom: ^3.1.0
+
+  '@wangeditor/core@1.1.19':
+    resolution: {integrity: sha512-KevkB47+7GhVszyYF2pKGKtCSj/YzmClsD03C3zTt+9SR2XWT5T0e3yQqg8baZpcMvkjs1D8Dv4fk8ok/UaS2Q==, tarball: https://registry.npmmirror.com/@wangeditor/core/-/core-1.1.19.tgz}
+    peerDependencies:
+      '@uppy/core': ^2.1.1
+      '@uppy/xhr-upload': ^2.0.3
+      dom7: ^3.0.0
+      is-hotkey: ^0.2.0
+      lodash.camelcase: ^4.3.0
+      lodash.clonedeep: ^4.5.0
+      lodash.debounce: ^4.0.8
+      lodash.foreach: ^4.5.0
+      lodash.isequal: ^4.5.0
+      lodash.throttle: ^4.1.1
+      lodash.toarray: ^4.4.0
+      nanoid: ^3.2.0
+      slate: ^0.72.0
+      snabbdom: ^3.1.0
+
+  '@wangeditor/editor-for-vue@5.1.12':
+    resolution: {integrity: sha512-0Ds3D8I+xnpNWezAeO7HmPRgTfUxHLMd9JKcIw+QzvSmhC5xUHbpCcLU+KLmeBKTR/zffnS5GQo6qi3GhTMJWQ==, tarball: https://registry.npmmirror.com/@wangeditor/editor-for-vue/-/editor-for-vue-5.1.12.tgz}
+    peerDependencies:
+      '@wangeditor/editor': '>=5.1.0'
+      vue: ^3.0.5
+
+  '@wangeditor/editor@5.1.23':
+    resolution: {integrity: sha512-0RxfeVTuK1tktUaPROnCoFfaHVJpRAIE2zdS0mpP+vq1axVQpLjM8+fCvKzqYIkH0Pg+C+44hJpe3VVroSkEuQ==, tarball: https://registry.npmmirror.com/@wangeditor/editor/-/editor-5.1.23.tgz}
+
+  '@wangeditor/list-module@1.0.5':
+    resolution: {integrity: sha512-uDuYTP6DVhcYf7mF1pTlmNn5jOb4QtcVhYwSSAkyg09zqxI1qBqsfUnveeDeDqIuptSJhkh81cyxi+MF8sEPOQ==, tarball: https://registry.npmmirror.com/@wangeditor/list-module/-/list-module-1.0.5.tgz}
+    peerDependencies:
+      '@wangeditor/core': 1.x
+      dom7: ^3.0.0
+      slate: ^0.72.0
+      snabbdom: ^3.1.0
+
+  '@wangeditor/table-module@1.1.4':
+    resolution: {integrity: sha512-5saanU9xuEocxaemGdNi9t8MCDSucnykEC6jtuiT72kt+/Hhh4nERYx1J20OPsTCCdVr7hIyQenFD1iSRkIQ6w==, tarball: https://registry.npmmirror.com/@wangeditor/table-module/-/table-module-1.1.4.tgz}
+    peerDependencies:
+      '@wangeditor/core': 1.x
+      dom7: ^3.0.0
+      lodash.isequal: ^4.5.0
+      lodash.throttle: ^4.1.1
+      nanoid: ^3.2.0
+      slate: ^0.72.0
+      snabbdom: ^3.1.0
+
+  '@wangeditor/upload-image-module@1.0.2':
+    resolution: {integrity: sha512-z81lk/v71OwPDYeQDxj6cVr81aDP90aFuywb8nPD6eQeECtOymrqRODjpO6VGvCVxVck8nUxBHtbxKtjgcwyiA==, tarball: https://registry.npmmirror.com/@wangeditor/upload-image-module/-/upload-image-module-1.0.2.tgz}
+    peerDependencies:
+      '@uppy/core': ^2.0.3
+      '@uppy/xhr-upload': ^2.0.3
+      '@wangeditor/basic-modules': 1.x
+      '@wangeditor/core': 1.x
+      dom7: ^3.0.0
+      lodash.foreach: ^4.5.0
+      slate: ^0.72.0
+      snabbdom: ^3.1.0
+
+  '@wangeditor/video-module@1.1.4':
+    resolution: {integrity: sha512-ZdodDPqKQrgx3IwWu4ZiQmXI8EXZ3hm2/fM6E3t5dB8tCaIGWQZhmqd6P5knfkRAd3z2+YRSRbxOGfoRSp/rLg==, tarball: https://registry.npmmirror.com/@wangeditor/video-module/-/video-module-1.1.4.tgz}
+    peerDependencies:
+      '@uppy/core': ^2.1.4
+      '@uppy/xhr-upload': ^2.0.7
+      '@wangeditor/core': 1.x
+      dom7: ^3.0.0
+      nanoid: ^3.2.0
+      slate: ^0.72.0
+      snabbdom: ^3.1.0
+
   acorn-jsx@5.3.2:
     resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
     peerDependencies:
@@ -1685,6 +1801,10 @@ packages:
   csstype@3.1.3:
     resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
 
+  d@1.0.2:
+    resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==, tarball: https://registry.npmmirror.com/d/-/d-1.0.2.tgz}
+    engines: {node: '>=0.12'}
+
   dagre@0.8.5:
     resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==}
 
@@ -1749,6 +1869,9 @@ packages:
   dom-zindex@1.0.6:
     resolution: {integrity: sha512-FKWIhiU96bi3xpP9ewRMgANsoVmMUBnMnmpCT6dPMZOunVYJQmJhSRruoI0XSPoHeIif3kyEuiHbFrOJwEJaEA==}
 
+  dom7@3.0.0:
+    resolution: {integrity: sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==, tarball: https://registry.npmmirror.com/dom7/-/dom7-3.0.0.tgz}
+
   dom7@4.0.6:
     resolution: {integrity: sha512-emjdpPLhpNubapLFdjNL9tP06Sr+GZkrIHEXLWvOGsytACUrkbeIdjO5g77m00BrHTznnlcNqgmn7pCN192TBA==}
 
@@ -1786,6 +1909,17 @@ packages:
   es-module-lexer@1.6.0:
     resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==}
 
+  es5-ext@0.10.64:
+    resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==, tarball: https://registry.npmmirror.com/es5-ext/-/es5-ext-0.10.64.tgz}
+    engines: {node: '>=0.10'}
+
+  es6-iterator@2.0.3:
+    resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==, tarball: https://registry.npmmirror.com/es6-iterator/-/es6-iterator-2.0.3.tgz}
+
+  es6-symbol@3.1.4:
+    resolution: {integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==, tarball: https://registry.npmmirror.com/es6-symbol/-/es6-symbol-3.1.4.tgz}
+    engines: {node: '>=0.12'}
+
   esbuild@0.21.5:
     resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
     engines: {node: '>=12'}
@@ -1856,6 +1990,10 @@ packages:
     deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
     hasBin: true
 
+  esniff@2.0.1:
+    resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==, tarball: https://registry.npmmirror.com/esniff/-/esniff-2.0.1.tgz}
+    engines: {node: '>=0.10'}
+
   espree@9.6.1:
     resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==}
     engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -1882,6 +2020,9 @@ packages:
     resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
     engines: {node: '>=0.10.0'}
 
+  event-emitter@0.3.5:
+    resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==, tarball: https://registry.npmmirror.com/event-emitter/-/event-emitter-0.3.5.tgz}
+
   execa@9.5.2:
     resolution: {integrity: sha512-EHlpxMCpHWSAh1dgS6bVeoLAXGnJNdR93aabr4QCGbzOM73o5XmRfM/e5FUqsw3aagP8S8XEWUWFAxnRBnAF0Q==}
     engines: {node: ^18.19.0 || >=20.5.0}
@@ -1889,6 +2030,9 @@ packages:
   exsolve@1.0.4:
     resolution: {integrity: sha512-xsZH6PXaER4XoV+NiT7JHp1bJodJVT+cxeSH1G0f0tlT0lJqYuHUP3bUx2HtfTDvOagMINYp8rsqusxud3RXhw==}
 
+  ext@1.7.0:
+    resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==, tarball: https://registry.npmmirror.com/ext/-/ext-1.7.0.tgz}
+
   fast-deep-equal@3.1.3:
     resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
 
@@ -2009,14 +2153,23 @@ packages:
   hookable@5.5.3:
     resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
 
+  html-void-elements@2.0.1:
+    resolution: {integrity: sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==, tarball: https://registry.npmmirror.com/html-void-elements/-/html-void-elements-2.0.1.tgz}
+
   human-signals@8.0.1:
     resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
     engines: {node: '>=18.18.0'}
 
+  i18next@20.6.1:
+    resolution: {integrity: sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==, tarball: https://registry.npmmirror.com/i18next/-/i18next-20.6.1.tgz}
+
   ignore@5.3.2:
     resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
     engines: {node: '>= 4'}
 
+  immer@9.0.21:
+    resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==, tarball: https://registry.npmmirror.com/immer/-/immer-9.0.21.tgz}
+
   immutable@5.1.1:
     resolution: {integrity: sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==}
 
@@ -2055,6 +2208,9 @@ packages:
     resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
     engines: {node: '>=0.10.0'}
 
+  is-hotkey@0.2.0:
+    resolution: {integrity: sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==, tarball: https://registry.npmmirror.com/is-hotkey/-/is-hotkey-0.2.0.tgz}
+
   is-inside-container@1.0.0:
     resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
     engines: {node: '>=14.16'}
@@ -2076,6 +2232,10 @@ packages:
     resolution: {integrity: sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==}
     engines: {node: '>=0.10.0'}
 
+  is-plain-object@5.0.0:
+    resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==, tarball: https://registry.npmmirror.com/is-plain-object/-/is-plain-object-5.0.0.tgz}
+    engines: {node: '>=0.10.0'}
+
   is-stream@4.0.1:
     resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
     engines: {node: '>=18'}
@@ -2084,6 +2244,9 @@ packages:
     resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
     engines: {node: '>=18'}
 
+  is-url@1.2.4:
+    resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==, tarball: https://registry.npmmirror.com/is-url/-/is-url-1.2.4.tgz}
+
   is-what@4.1.16:
     resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
     engines: {node: '>=12.13'}
@@ -2175,9 +2338,31 @@ packages:
       lodash: '*'
       lodash-es: '*'
 
+  lodash.camelcase@4.3.0:
+    resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==, tarball: https://registry.npmmirror.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz}
+
+  lodash.clonedeep@4.5.0:
+    resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==, tarball: https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz}
+
+  lodash.debounce@4.0.8:
+    resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==, tarball: https://registry.npmmirror.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz}
+
+  lodash.foreach@4.5.0:
+    resolution: {integrity: sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==, tarball: https://registry.npmmirror.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz}
+
+  lodash.isequal@4.5.0:
+    resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==, tarball: https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz}
+    deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
+
   lodash.merge@4.6.2:
     resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
 
+  lodash.throttle@4.1.1:
+    resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==, tarball: https://registry.npmmirror.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz}
+
+  lodash.toarray@4.4.0:
+    resolution: {integrity: sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw==, tarball: https://registry.npmmirror.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz}
+
   lodash@4.17.21:
     resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
 
@@ -2219,6 +2404,9 @@ packages:
     resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
     engines: {node: '>=8.6'}
 
+  mime-match@1.0.2:
+    resolution: {integrity: sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==, tarball: https://registry.npmmirror.com/mime-match/-/mime-match-1.0.2.tgz}
+
   minimatch@3.1.2:
     resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
 
@@ -2266,8 +2454,11 @@ packages:
   muggle-string@0.4.1:
     resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
 
+  namespace-emitter@2.0.1:
+    resolution: {integrity: sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==, tarball: https://registry.npmmirror.com/namespace-emitter/-/namespace-emitter-2.0.1.tgz}
+
   nanoid@3.3.11:
-    resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
+    resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==, tarball: https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz}
     engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
     hasBin: true
 
@@ -2282,6 +2473,9 @@ packages:
   natural-compare@1.4.0:
     resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
 
+  next-tick@1.1.0:
+    resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==, tarball: https://registry.npmmirror.com/next-tick/-/next-tick-1.1.0.tgz}
+
   node-addon-api@7.1.1:
     resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
 
@@ -2443,6 +2637,10 @@ packages:
     resolution: {integrity: sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==}
     engines: {node: '>=18'}
 
+  prismjs@1.30.0:
+    resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==, tarball: https://registry.npmmirror.com/prismjs/-/prismjs-1.30.0.tgz}
+    engines: {node: '>=6'}
+
   punycode@2.3.1:
     resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
     engines: {node: '>=6'}
@@ -2559,6 +2757,18 @@ packages:
     resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
     engines: {node: '>=8'}
 
+  slate-history@0.66.0:
+    resolution: {integrity: sha512-6MWpxGQZiMvSINlCbMW43E2YBSVMCMCIwQfBzGssjWw4kb0qfvj0pIdblWNRQZD0hR6WHP+dHHgGSeVdMWzfng==, tarball: https://registry.npmmirror.com/slate-history/-/slate-history-0.66.0.tgz}
+    peerDependencies:
+      slate: '>=0.65.3'
+
+  slate@0.72.8:
+    resolution: {integrity: sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw==, tarball: https://registry.npmmirror.com/slate/-/slate-0.72.8.tgz}
+
+  snabbdom@3.6.3:
+    resolution: {integrity: sha512-W2lHLLw2qR2Vv0DcMmcxXqcfdBaIcoN+y/86SmHv8fn4DazEQSH6KN3TjZcWvwujW56OHiiirsbHWZb4vx/0fg==, tarball: https://registry.npmmirror.com/snabbdom/-/snabbdom-3.6.3.tgz}
+    engines: {node: '>=12.17.0'}
+
   sortablejs@1.14.0:
     resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==}
 
@@ -2570,6 +2780,9 @@ packages:
     resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
     engines: {node: '>=0.10.0'}
 
+  ssr-window@3.0.0:
+    resolution: {integrity: sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==, tarball: https://registry.npmmirror.com/ssr-window/-/ssr-window-3.0.0.tgz}
+
   ssr-window@4.0.2:
     resolution: {integrity: sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ==}
 
@@ -2619,6 +2832,9 @@ packages:
     resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
     engines: {node: '>=12.22'}
 
+  tiny-warning@1.0.3:
+    resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==, tarball: https://registry.npmmirror.com/tiny-warning/-/tiny-warning-1.0.3.tgz}
+
   tinyexec@0.3.2:
     resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
 
@@ -2655,6 +2871,9 @@ packages:
     resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
     engines: {node: '>=10'}
 
+  type@2.7.3:
+    resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==, tarball: https://registry.npmmirror.com/type/-/type-2.7.3.tgz}
+
   typescript@5.4.5:
     resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==}
     engines: {node: '>=14.17'}
@@ -2842,7 +3061,7 @@ packages:
         optional: true
 
   vue-demi@0.14.10:
-    resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
+    resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==, tarball: https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz}
     engines: {node: '>=12'}
     hasBin: true
     peerDependencies:
@@ -2944,6 +3163,9 @@ packages:
     engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
     hasBin: true
 
+  wildcard@1.1.2:
+    resolution: {integrity: sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==, tarball: https://registry.npmmirror.com/wildcard/-/wildcard-1.1.2.tgz}
+
   word-wrap@1.2.5:
     resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
     engines: {node: '>=0.10.0'}
@@ -3734,12 +3956,16 @@ snapshots:
 
   '@sxzz/popperjs-es@2.11.7': {}
 
+  '@transloadit/prettier-bytes@0.0.7': {}
+
   '@trysound/sax@0.2.0': {}
 
   '@tsconfig/node20@20.1.5': {}
 
   '@types/estree@1.0.7': {}
 
+  '@types/event-emitter@0.3.5': {}
+
   '@types/lodash-es@4.17.12':
     dependencies:
       '@types/lodash': 4.17.20
@@ -3999,6 +4225,35 @@ snapshots:
       - rollup
       - supports-color
 
+  '@uppy/companion-client@2.2.2':
+    dependencies:
+      '@uppy/utils': 4.1.3
+      namespace-emitter: 2.0.1
+
+  '@uppy/core@2.3.4':
+    dependencies:
+      '@transloadit/prettier-bytes': 0.0.7
+      '@uppy/store-default': 2.1.1
+      '@uppy/utils': 4.1.3
+      lodash.throttle: 4.1.1
+      mime-match: 1.0.2
+      namespace-emitter: 2.0.1
+      nanoid: 3.3.11
+      preact: 10.27.2
+
+  '@uppy/store-default@2.1.1': {}
+
+  '@uppy/utils@4.1.3':
+    dependencies:
+      lodash.throttle: 4.1.1
+
+  '@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4)':
+    dependencies:
+      '@uppy/companion-client': 2.2.2
+      '@uppy/core': 2.3.4
+      '@uppy/utils': 4.1.3
+      nanoid: 3.3.11
+
   '@vitejs/plugin-vue-jsx@4.1.2(vite@5.4.16(@types/node@20.17.29)(sass@1.86.1))(vue@3.5.13(typescript@5.4.5))':
     dependencies:
       '@babel/core': 7.26.10
@@ -4251,6 +4506,114 @@ snapshots:
       vue: 3.5.13(typescript@5.4.5)
       xe-utils: 3.7.5
 
+  '@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)':
+    dependencies:
+      '@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
+      dom7: 3.0.0
+      is-url: 1.2.4
+      lodash.throttle: 4.1.1
+      nanoid: 3.3.11
+      slate: 0.72.8
+      snabbdom: 3.6.3
+
+  '@wangeditor/code-highlight@1.0.3(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.3)':
+    dependencies:
+      '@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
+      dom7: 3.0.0
+      prismjs: 1.30.0
+      slate: 0.72.8
+      snabbdom: 3.6.3
+
+  '@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)':
+    dependencies:
+      '@types/event-emitter': 0.3.5
+      '@uppy/core': 2.3.4
+      '@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
+      dom7: 3.0.0
+      event-emitter: 0.3.5
+      html-void-elements: 2.0.1
+      i18next: 20.6.1
+      is-hotkey: 0.2.0
+      lodash.camelcase: 4.3.0
+      lodash.clonedeep: 4.5.0
+      lodash.debounce: 4.0.8
+      lodash.foreach: 4.5.0
+      lodash.isequal: 4.5.0
+      lodash.throttle: 4.1.1
+      lodash.toarray: 4.4.0
+      nanoid: 3.3.11
+      scroll-into-view-if-needed: 2.2.31
+      slate: 0.72.8
+      slate-history: 0.66.0(slate@0.72.8)
+      snabbdom: 3.6.3
+
+  '@wangeditor/editor-for-vue@5.1.12(@wangeditor/editor@5.1.23)(vue@3.5.13(typescript@5.4.5))':
+    dependencies:
+      '@wangeditor/editor': 5.1.23
+      vue: 3.5.13(typescript@5.4.5)
+
+  '@wangeditor/editor@5.1.23':
+    dependencies:
+      '@uppy/core': 2.3.4
+      '@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
+      '@wangeditor/basic-modules': 1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
+      '@wangeditor/code-highlight': 1.0.3(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.3)
+      '@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
+      '@wangeditor/list-module': 1.0.5(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.3)
+      '@wangeditor/table-module': 1.1.4(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
+      '@wangeditor/upload-image-module': 1.0.2(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.foreach@4.5.0)(slate@0.72.8)(snabbdom@3.6.3)
+      '@wangeditor/video-module': 1.1.4(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
+      dom7: 3.0.0
+      is-hotkey: 0.2.0
+      lodash.camelcase: 4.3.0
+      lodash.clonedeep: 4.5.0
+      lodash.debounce: 4.0.8
+      lodash.foreach: 4.5.0
+      lodash.isequal: 4.5.0
+      lodash.throttle: 4.1.1
+      lodash.toarray: 4.4.0
+      nanoid: 3.3.11
+      slate: 0.72.8
+      snabbdom: 3.6.3
+
+  '@wangeditor/list-module@1.0.5(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.3)':
+    dependencies:
+      '@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
+      dom7: 3.0.0
+      slate: 0.72.8
+      snabbdom: 3.6.3
+
+  '@wangeditor/table-module@1.1.4(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)':
+    dependencies:
+      '@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
+      dom7: 3.0.0
+      lodash.isequal: 4.5.0
+      lodash.throttle: 4.1.1
+      nanoid: 3.3.11
+      slate: 0.72.8
+      snabbdom: 3.6.3
+
+  '@wangeditor/upload-image-module@1.0.2(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.foreach@4.5.0)(slate@0.72.8)(snabbdom@3.6.3)':
+    dependencies:
+      '@uppy/core': 2.3.4
+      '@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
+      '@wangeditor/basic-modules': 1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
+      '@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
+      dom7: 3.0.0
+      lodash.foreach: 4.5.0
+      slate: 0.72.8
+      snabbdom: 3.6.3
+
+  '@wangeditor/video-module@1.1.4(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)':
+    dependencies:
+      '@uppy/core': 2.3.4
+      '@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
+      '@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
+      dom7: 3.0.0
+      nanoid: 3.3.11
+      slate: 0.72.8
+      snabbdom: 3.6.3
+
   acorn-jsx@5.3.2(acorn@8.14.1):
     dependencies:
       acorn: 8.14.1
@@ -4460,6 +4823,11 @@ snapshots:
 
   csstype@3.1.3: {}
 
+  d@1.0.2:
+    dependencies:
+      es5-ext: 0.10.64
+      type: 2.7.3
+
   dagre@0.8.5:
     dependencies:
       graphlib: 2.1.8
@@ -4511,6 +4879,10 @@ snapshots:
 
   dom-zindex@1.0.6: {}
 
+  dom7@3.0.0:
+    dependencies:
+      ssr-window: 3.0.0
+
   dom7@4.0.6:
     dependencies:
       ssr-window: 4.0.2
@@ -4563,6 +4935,24 @@ snapshots:
 
   es-module-lexer@1.6.0: {}
 
+  es5-ext@0.10.64:
+    dependencies:
+      es6-iterator: 2.0.3
+      es6-symbol: 3.1.4
+      esniff: 2.0.1
+      next-tick: 1.1.0
+
+  es6-iterator@2.0.3:
+    dependencies:
+      d: 1.0.2
+      es5-ext: 0.10.64
+      es6-symbol: 3.1.4
+
+  es6-symbol@3.1.4:
+    dependencies:
+      d: 1.0.2
+      ext: 1.7.0
+
   esbuild@0.21.5:
     optionalDependencies:
       '@esbuild/aix-ppc64': 0.21.5
@@ -4729,6 +5119,13 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  esniff@2.0.1:
+    dependencies:
+      d: 1.0.2
+      es5-ext: 0.10.64
+      event-emitter: 0.3.5
+      type: 2.7.3
+
   espree@9.6.1:
     dependencies:
       acorn: 8.14.1
@@ -4753,6 +5150,11 @@ snapshots:
 
   esutils@2.0.3: {}
 
+  event-emitter@0.3.5:
+    dependencies:
+      d: 1.0.2
+      es5-ext: 0.10.64
+
   execa@9.5.2:
     dependencies:
       '@sindresorhus/merge-streams': 4.0.0
@@ -4770,6 +5172,10 @@ snapshots:
 
   exsolve@1.0.4: {}
 
+  ext@1.7.0:
+    dependencies:
+      type: 2.7.3
+
   fast-deep-equal@3.1.3: {}
 
   fast-diff@1.3.0: {}
@@ -4891,10 +5297,18 @@ snapshots:
 
   hookable@5.5.3: {}
 
+  html-void-elements@2.0.1: {}
+
   human-signals@8.0.1: {}
 
+  i18next@20.6.1:
+    dependencies:
+      '@babel/runtime': 7.27.0
+
   ignore@5.3.2: {}
 
+  immer@9.0.21: {}
+
   immutable@5.1.1: {}
 
   import-fresh@3.3.1:
@@ -4935,6 +5349,8 @@ snapshots:
     dependencies:
       is-extglob: 2.1.1
 
+  is-hotkey@0.2.0: {}
+
   is-inside-container@1.0.0:
     dependencies:
       is-docker: 3.0.0
@@ -4947,10 +5363,14 @@ snapshots:
 
   is-plain-object@3.0.1: {}
 
+  is-plain-object@5.0.0: {}
+
   is-stream@4.0.1: {}
 
   is-unicode-supported@2.1.0: {}
 
+  is-url@1.2.4: {}
+
   is-what@4.1.16: {}
 
   is-wsl@3.1.0:
@@ -5025,8 +5445,22 @@ snapshots:
       lodash: 4.17.21
       lodash-es: 4.17.21
 
+  lodash.camelcase@4.3.0: {}
+
+  lodash.clonedeep@4.5.0: {}
+
+  lodash.debounce@4.0.8: {}
+
+  lodash.foreach@4.5.0: {}
+
+  lodash.isequal@4.5.0: {}
+
   lodash.merge@4.6.2: {}
 
+  lodash.throttle@4.1.1: {}
+
+  lodash.toarray@4.4.0: {}
+
   lodash@4.17.21: {}
 
   loose-envify@1.4.0:
@@ -5062,6 +5496,10 @@ snapshots:
       braces: 3.0.3
       picomatch: 2.3.1
 
+  mime-match@1.0.2:
+    dependencies:
+      wildcard: 1.1.2
+
   minimatch@3.1.2:
     dependencies:
       brace-expansion: 1.1.11
@@ -5105,6 +5543,8 @@ snapshots:
 
   muggle-string@0.4.1: {}
 
+  namespace-emitter@2.0.1: {}
+
   nanoid@3.3.11: {}
 
   nanoid@5.1.5: {}
@@ -5113,6 +5553,8 @@ snapshots:
 
   natural-compare@1.4.0: {}
 
+  next-tick@1.1.0: {}
+
   node-addon-api@7.1.1:
     optional: true
 
@@ -5267,6 +5709,8 @@ snapshots:
     dependencies:
       parse-ms: 4.0.0
 
+  prismjs@1.30.0: {}
+
   punycode@2.3.1: {}
 
   quansync@0.2.10: {}
@@ -5380,12 +5824,27 @@ snapshots:
 
   slash@3.0.0: {}
 
+  slate-history@0.66.0(slate@0.72.8):
+    dependencies:
+      is-plain-object: 5.0.0
+      slate: 0.72.8
+
+  slate@0.72.8:
+    dependencies:
+      immer: 9.0.21
+      is-plain-object: 5.0.0
+      tiny-warning: 1.0.3
+
+  snabbdom@3.6.3: {}
+
   sortablejs@1.14.0: {}
 
   source-map-js@1.2.1: {}
 
   speakingurl@14.0.1: {}
 
+  ssr-window@3.0.0: {}
+
   ssr-window@4.0.2: {}
 
   strip-ansi@6.0.1:
@@ -5434,6 +5893,8 @@ snapshots:
 
   throttle-debounce@5.0.2: {}
 
+  tiny-warning@1.0.3: {}
+
   tinyexec@0.3.2: {}
 
   to-regex-range@5.0.1:
@@ -5463,6 +5924,8 @@ snapshots:
 
   type-fest@0.20.2: {}
 
+  type@2.7.3: {}
+
   typescript@5.4.5: {}
 
   ufo@1.5.4: {}
@@ -5806,6 +6269,8 @@ snapshots:
     dependencies:
       isexe: 2.0.0
 
+  wildcard@1.1.2: {}
+
   word-wrap@1.2.5: {}
 
   wrappy@1.0.2: {}

+ 460 - 0
src/components/EditEducation.vue

@@ -0,0 +1,460 @@
+<script setup lang="ts">
+import { ref, reactive, shallowRef, onBeforeUnmount, onMounted } from 'vue';
+import { PlusOutlined } from '@ant-design/icons-vue';
+import type { FormInstance } from 'ant-design-vue';
+import type { UploadFile } from 'ant-design-vue/es/upload/interface';
+import type { EducationModel } from '@/model/education.model';
+import { getEducationDetailMethod, editEducationMethod } from '@/request/api/education.api';
+import { searchTagsFromSelectableMethod } from '@/request/api/patient.api';
+import { VxeUI } from 'vxe-pc-ui';
+import { useRequest } from 'alova/client';
+import { notification } from 'ant-design-vue';
+// 编辑器引入
+import '@wangeditor/editor/dist/css/style.css';
+import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
+
+import { UploadIFile } from '@/request/api/follow.api';
+
+const props = defineProps<{
+  data?: EducationModel;
+}>();
+
+const emits = defineEmits<{
+  submit: [data?: EducationModel];
+  preview: [content: string];
+  back: [];
+}>();
+// 获取用户标签
+const { data: selectable, loading: tagsLoading } = useRequest(searchTagsFromSelectableMethod, { initialData: [] });
+// 更新宣教
+const { loading: updating, send: submitUpdate } = useRequest(editEducationMethod, { immediate: false });
+const formRef = ref<FormInstance>();
+const loading = ref<boolean>(false);
+
+// 表单数据
+const form = reactive<EducationModel>({
+  id: '',
+  title: '',
+  briefImg: '',
+  tagIds: [],
+  content: '',
+  createBy: '',
+  createTime: '',
+  status: '0',
+  pushType: '1',
+  isSwitch: false,
+  tagNameStr: '',
+  tagNames: [],
+});
+
+// 推送形式选项
+const pushMethodOptions = [
+  { label: '自动推送', value: '0' },
+  { label: '仅通过通知管理推送', value: '1' },
+];
+// 图片上传相关
+const imageFileList = ref<UploadFile[]>([]);
+const uploadProps = reactive({ showRemoveIcon: true });
+const previewVisible = ref(false);
+const previewImage = ref('');
+
+// 图片上传
+const customUpload = (e: any) => {
+  UploadIFile(e.file)
+    .then((res: any) => {
+      form.briefImg = res.url || res.data?.url || '';
+      e.onSuccess(res, e);
+    })
+    .catch((err) => {
+      e.onError(err);
+    });
+};
+
+// 预览图片
+const handleImagePreview = async (file: UploadFile) => {
+  previewImage.value = file.response?.url ?? file.thumbUrl ?? '';
+  previewVisible.value = true;
+};
+
+// 图片删除
+const handleRemove = () => {
+  form.briefImg = '';
+  imageFileList.value = [];
+};
+
+// 检查富文本内容是否为空(去除 HTML 标签后检查文本内容)
+const isContentEmpty = (html: string): boolean => {
+  if (!html) return true;
+  // 创建一个临时 DOM 元素来解析 HTML
+  const tempDiv = document.createElement('div');
+  tempDiv.innerHTML = html;
+  // 获取文本内容并去除空白字符
+  const textContent = tempDiv.textContent || tempDiv.innerText || '';
+  return textContent.trim().length === 0;
+};
+
+// 表单验证规则
+const rules: any = {
+  title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
+  status: [{ required: true, message: '请选择启用状态', trigger: 'change' }],
+  pushType: [{ required: true, message: '请选择推送形式', trigger: 'change' }],
+  tagIds: [{ required: true, message: '请选择用户标签', trigger: 'change' }],
+  briefImg: [{ required: true, message: '请上传文章主图', trigger: 'change' }],
+  content: [
+    {
+      validator: (_rule: any, value: string) => {
+        if (isContentEmpty(value)) {
+          return Promise.reject(new Error('请填写宣教内容'));
+        }
+        return Promise.resolve();
+      },
+      trigger: 'blur',
+    },
+  ],
+};
+// 保存
+const handleSave = async () => {
+  try {
+    // 保存前同步编辑器内容到表单数据
+    if (editorRef.value) {
+      form.content = editorRef.value.getHtml();
+    }
+    // 手动触发 content 字段的验证,确保错误信息显示在表单项下方
+    await formRef.value?.validateFields(['content']);
+    // 验证所有字段
+    await formRef.value?.validate();
+    updating.value = true;
+    const tagNames = form.tagIds.map((id: string) => selectable.value.find((option: any) => option.id === id)?.name || '') as any[];
+    // 在提交前,先将其值同步到 status 字段
+    const status = form.isSwitch ? '0' : '1';
+    // 创建提交数据,排除不需要的字段,但保留原 form 对象不变
+    const submitData = { ...form };
+    submitData.status = status;
+    delete submitData.patientMatchRule && delete submitData.isSwitch; 
+    console.log(submitData, 'submitData===submitData');
+    submitUpdate({
+      ...submitData,
+      tagNameStr: tagNames.join(',') || '',
+      tagNames: tagNames,
+    }).then(() => {
+      // 保存成功后,同步更新 form 的 status,保持 isSwitch 不变
+      form.status = status;
+      notification.success({
+        message: '保存成功',
+      });
+      emits('submit', { ...form });
+    });
+  } catch (error) {
+    console.error('表单验证失败', error);
+  } finally {
+    loading.value = false;
+  }
+};
+
+// 预览页面
+const handlePreviewClick = () => {
+  // 获取编辑器当前内容
+  const editorContent = editorRef.value?.getHtml() || form.content || '';
+  emits('preview', editorContent);
+};
+
+// 返回
+const handleBack = () => {
+  emits('back');
+  VxeUI.modal.close(`education-modal`);
+};
+
+// 富文本编辑器
+// 编辑器实例,必须用 shallowRef
+const editorRef = shallowRef();
+
+// 内容 HTML
+const valueHtml = ref('');
+
+// 编辑器模式
+const mode = 'default';
+// 编辑器配置
+const editorConfig = {
+  placeholder: '请输入文字',
+  MENU_CONF: {
+    uploadImage: {
+      server: '', // 图片上传接口,如果需要可以配置
+      fieldName: 'file',
+      // 自定义上传
+      async customUpload(file: File, insertFn: (url: string, alt: string, href: string) => void) {
+        try {
+          const res: any = await UploadIFile(file);
+          const url = res.url || res.data?.url || '';
+          if (url) {
+            insertFn(url, file.name, url);
+          }
+        } catch (error) {
+          console.error('图片上传失败', error);
+        }
+      },
+    },
+    uploadVideo: {
+      server: '', // 视频上传接口
+      fieldName: 'file',
+      // 自定义上传
+      async customUpload(file: File, insertFn: (url: string, poster: string) => void) {
+        try {
+          const res: any = await UploadIFile(file);
+          const url = res.url || res.data?.url || '';
+          if (url) {
+            insertFn(url, '');
+          }
+        } catch (error) {
+          console.error('视频上传失败', error);
+        }
+      },
+    },
+  },
+};
+
+// 工具栏配置
+const toolbarConfig = {
+  excludeKeys: [], // 排除的菜单项
+};
+// 编辑器
+const handleCreated = (editor: any) => {
+  console.log('created', editor);
+  editorRef.value = editor; // 记录 editor 实例
+};
+const handleChange = (editor: any) => {
+  // 同步编辑器内容到表单数据
+  form.content = editor.getHtml();
+  console.log('change:', editor.getHtml());
+  // 当内容变化时,如果内容不为空,清除验证错误
+  if (formRef.value && !isContentEmpty(form.content)) {
+    formRef.value.clearValidate(['content']);
+  }
+};
+const handleDestroyed = (editor: any) => {
+  console.log('destroyed', editor);
+};
+const handleFocus = (editor: any) => {
+  console.log('focus', editor);
+};
+const handleBlur = (editor: any) => {
+  console.log('blur', editor);
+};
+const customAlert = (info: any, type: any) => {
+  alert(`【自定义提示】${type} - ${info}`);
+};
+const customPaste = (editor: any, event: any, callback: any) => {
+  // 允许默认粘贴行为
+  callback(true);
+};
+// 获取宣教详情
+const getEducationDetail = async (id?: string) => {
+  const res: any = await getEducationDetailMethod(id || '');
+  if (res) {
+    Object.assign(form, res);
+    form.isSwitch = form.status === '0' ? true : false;
+    if (form.briefImg) {
+      imageFileList.value = [
+        {
+          uid: '-1',
+          name: 'image.png',
+          status: 'done',
+          url: form.briefImg,
+        } as UploadFile,
+      ];
+    }
+    // 如果有内容,设置编辑器内容
+    if (form.content && editorRef.value) {
+      editorRef.value.setHtml(form.content);
+    }
+    // 如果标签为空,则清空标签
+    if (!form.tagIds || form.tagIds.length === 0) {
+      form.tagIds = [];
+      form.tagNames = [];
+      form.tagNameStr = '';
+    }
+  }
+};
+onMounted(() => {
+  if (props.data && props.data.id) {
+    getEducationDetail(props.data.id);
+  }
+});
+// 组件销毁时,也及时销毁编辑器
+onBeforeUnmount(() => {
+  const editor = editorRef.value;
+  if (editor == null) return;
+
+  editor.destroy();
+});
+</script>
+
+<template>
+  <div class="education-form-container">
+    <!-- 顶部按钮 -->
+    <div class="header-actions">
+      <vxe-button type="primary" content="预览" @click="handlePreviewClick"></vxe-button>
+      <vxe-button type="primary" content="返回" @click="handleBack"></vxe-button>
+      <vxe-button type="submit" status="primary" content="保存" @click="handleSave" :loading="updating"></vxe-button>
+    </div>
+
+    <!-- 表单内容 -->
+    <div class="form-content">
+      <a-form ref="formRef" :model="form" :rules="rules" layout="horizontal" :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
+        <div class="form-row">
+          <!-- 标题 -->
+          <a-form-item label="标题" name="title" required class="form-row-item">
+            <a-input v-model:value="form.title" placeholder="请输入" />
+          </a-form-item>
+
+          <!-- 启用状态 -->
+          <a-form-item label="启用" name="status" required class="form-row-item">
+            <a-switch v-model:checked="form.isSwitch" />
+          </a-form-item>
+        </div>
+        <!-- 推送形式 -->
+        <a-form-item label="推送形式" name="pushType" required class="form-row-item">
+          <a-radio-group v-model:value="form.pushType">
+            <a-radio v-for="option in pushMethodOptions" :key="option.value" :value="option.value">
+              {{ option.label }}
+            </a-radio>
+          </a-radio-group>
+        </a-form-item>
+
+        <!-- 用户标签 -->
+        <a-form-item label="用户标签" name="tagIds" required class="form-row-item">
+          <a-select v-model:value="form.tagIds" placeholder="请选择" style="width: 100%" mode="multiple" show-search>
+            <a-select-option v-for="option in selectable" :key="option.id" :value="option.id">
+              {{ option.name }}
+            </a-select-option>
+          </a-select>
+        </a-form-item>
+
+        <!-- 文章主图 -->
+        <a-form-item label="文章主图" name="briefImg" required class="form-row-item">
+          <a-upload
+            :show-upload-list="uploadProps"
+            v-model:file-list="imageFileList"
+            list-type="picture-card"
+            @preview="handleImagePreview"
+            @remove="handleRemove"
+            :max-count="1"
+            :custom-request="customUpload"
+            accept="image/*"
+          >
+            <div v-if="imageFileList.length < 1">
+              <PlusOutlined />
+              <div style="margin-top: 8px">上传</div>
+            </div>
+          </a-upload>
+        </a-form-item>
+
+        <!-- 宣教内容 -->
+        <a-form-item label="宣教内容" name="content" required class="form-row-item">
+          <div style="border: 1px solid #ccc; z-index: 100">
+            <Toolbar style="border-bottom: 1px solid #ccc" :editor="editorRef" :defaultConfig="toolbarConfig" :mode="mode" />
+            <Editor
+              style="height: 500px; overflow-y: hidden"
+              v-model="valueHtml"
+              :defaultConfig="editorConfig"
+              :mode="mode"
+              @onCreated="handleCreated"
+              @onChange="handleChange"
+              @onDestroyed="handleDestroyed"
+              @onFocus="handleFocus"
+              @onBlur="handleBlur"
+              @customAlert="customAlert"
+              @customPaste="customPaste"
+            />
+          </div>
+        </a-form-item>
+      </a-form>
+
+      <!-- 图片预览 -->
+      <a-modal v-model:open="previewVisible" :footer="null" centered>
+        <img alt="preview" style="width: 100%" :src="previewImage" />
+      </a-modal>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.education-form-container {
+  padding: 24px;
+  background: #fff;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+.header-actions {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+  margin-bottom: 24px;
+  padding-bottom: 16px;
+  border-bottom: 1px solid #f0f0f0;
+}
+
+.form-content {
+  flex: 1;
+  overflow: auto;
+}
+
+.form-row {
+  display: flex;
+  gap: 16px;
+  align-items: flex-start;
+  // margin-bottom: 24px;
+}
+
+.form-row-item {
+  flex: 1;
+  margin-bottom: 0;
+}
+
+.form-row-item :deep(.ant-form-item) {
+  margin-bottom: 0;
+  display: flex;
+  flex-direction: column;
+}
+
+.form-row-item :deep(.ant-form-item-label) {
+  flex: 0 0 auto;
+  width: auto;
+  min-width: 80px;
+  text-align: left;
+  padding-right: 8px;
+  padding-bottom: 0;
+}
+
+.form-row-item :deep(.ant-form-item-control) {
+  flex: 1;
+  width: 100%;
+}
+
+:deep(.ant-form-item-label > label.ant-form-item-required:not(.ant-form-item-required-mark-optional)) {
+  &::before {
+    display: inline-block;
+    margin-right: 4px;
+    color: #ff4d4f;
+    font-size: 14px;
+    font-family: SimSun, sans-serif;
+    line-height: 1;
+    content: '*';
+  }
+}
+
+:deep(.ant-form-item) {
+  margin-bottom: 24px;
+}
+
+:deep(.ant-input),
+:deep(.ant-select-selector) {
+  border-color: #d9d9d9;
+}
+
+:deep(.ant-radio-group) {
+  display: flex;
+  gap: 16px;
+}
+</style>

+ 234 - 0
src/components/EditNotify.vue

@@ -0,0 +1,234 @@
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue';
+import type { FormInstance } from 'ant-design-vue';
+import dayjs, { type Dayjs } from 'dayjs';
+import { VxeUI } from 'vxe-pc-ui';
+const props = defineProps<{
+  data?: any;
+}>();
+
+const emits = defineEmits<{
+  submit: [data?: any];
+  back: [];
+}>();
+
+const formRef = ref<FormInstance>();
+const loading = ref<boolean>(false);
+
+// 表单数据
+const form = reactive({
+  name: '', // 名称
+  userTag1: '', // 用户标签1
+  enabled: false, // 启用状态
+  notificationChannel: '1', // 通知渠道:1-企业微信
+  wechatContent: '1', // 微信内容:1-站内
+  sendTime: null as Dayjs | null, // 发送时间
+  sendContent: '', // 发送内容
+  articleLink: '', // 宣教文章链接
+});
+
+// 用户标签选项(示例数据,实际应从API获取)
+const userTagOptions = ref([
+  { label: '标签1', value: '1' },
+  { label: '标签2', value: '2' },
+  { label: '标签3', value: '3' },
+]);
+
+// 通知渠道选项
+const notificationChannelOptions = [{ label: '企业微信', value: '1' }];
+// 微信内容
+const wechatContentOptions = [
+  { label: '站内', value: '1' },
+];
+// 表单验证规则
+const rules = {
+  name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
+  enabled: [{ required: true, message: '请选择启用状态', trigger: 'change' }],
+  notificationChannel: [{ required: true, message: '请选择通知渠道', trigger: 'change' }],
+  sendTime: [{ required: true, message: '请选择发送时间', trigger: 'change' }],
+  sendContent: [{ required: true, message: '请输入发送内容', trigger: 'blur' }],
+};
+
+// 初始化数据
+onMounted(() => {
+  if (props.data) {
+    Object.assign(form, props.data);
+    if (form.sendTime) {
+      form.sendTime = dayjs(form.sendTime);
+    }
+  }
+});
+
+// 保存
+const handleSave = async () => {
+  try {
+    await formRef.value?.validate();
+    loading.value = true;
+    const submitData = {
+      ...form,
+      sendTime: form.sendTime ? form.sendTime.format('YYYY-MM-DD HH:mm:ss') : null,
+    };
+    emits('submit', submitData);
+  } catch (error) {
+    console.error('表单验证失败', error);
+  } finally {
+    loading.value = false;
+  }
+};
+
+// 返回
+const handleBack = () => {
+  emits('back');
+  VxeUI.modal.close(`edit-notify-modal`);
+};
+
+</script>
+
+<template>
+  <div class="notify-form-container">
+    <!-- 顶部按钮 -->
+    <div class="header-actions">
+      <vxe-button type="primary" content="返回" @click="handleBack"></vxe-button>
+      <vxe-button type="submit" status="primary" content="保存" @click="handleSave" :loading="loading"></vxe-button>
+    </div>
+
+    <!-- 表单内容 -->
+    <div class="form-content">
+      <a-form ref="formRef" :model="form" :rules="rules" layout="horizontal" :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
+        <!-- 名称、用户标签、启用 - 一行排列 -->
+        <div class="form-row">
+          <a-form-item label="名称" name="name" required class="form-row-item">
+            <a-input v-model:value="form.name" placeholder="请输入" />
+          </a-form-item>
+
+          <a-form-item label="用户标签" name="userTag1" class="form-row-item">
+            <a-select v-model:value="form.userTag1" placeholder="请选择" :options="userTagOptions" style="width: 100%" allow-clear />
+          </a-form-item>
+
+          <a-form-item label="启用" name="enabled" required class="form-row-item">
+            <a-switch v-model:checked="form.enabled" />
+          </a-form-item>
+        </div>
+        <!-- 通知渠道 -->
+        <a-form-item label="通知渠道" name="notificationChannel" required class="form-row-item">
+          <a-radio-group v-model:value="form.notificationChannel">
+            <a-radio v-for="option in notificationChannelOptions" :key="option.value" :value="option.value">
+              {{ option.label }}
+            </a-radio>
+          </a-radio-group>
+        </a-form-item>
+
+        <!-- 微信内容标签 -->
+        <a-form-item v-if="form.notificationChannel === '1'" label="微信内容" class="form-row-item">
+          <a-radio v-for="option in wechatContentOptions" :key="option.value" :value="option.value">
+            {{ option.label }}
+          </a-radio>
+        </a-form-item>
+
+        <!-- 发送时间 -->
+        <a-form-item label="发送时间" name="sendTime" required class="form-row-item">
+          <a-date-picker v-model:value="form.sendTime" placeholder="请输入" show-time format="YYYY-MM-DD HH:mm:ss" style="width: 100%" />
+        </a-form-item>
+
+        <!-- 发送内容 -->
+        <a-form-item label="发送内容" name="sendContent" required class="form-row-item">
+          <a-textarea v-model:value="form.sendContent" placeholder="请输入" allow-clear />
+        </a-form-item>
+
+        <!-- 宣教文章链接 -->
+        <a-form-item label="宣教文章链接" name="articleLink" class="form-row-item">
+          <a-input v-model:value="form.articleLink" placeholder="请输入搜索" allow-clear />
+        </a-form-item>
+      </a-form>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.notify-form-container {
+  padding: 24px;
+  background: #fff;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+.header-actions {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+  margin-bottom: 24px;
+  padding-bottom: 16px;
+  border-bottom: 1px solid #f0f0f0;
+}
+
+.form-content {
+  flex: 1;
+  overflow: auto;
+}
+
+.form-row {
+  display: flex;
+  gap: 16px;
+  align-items: flex-start;
+  margin-bottom: 24px;
+}
+
+.form-row-item {
+  flex: 1;
+  margin-bottom: 0;
+}
+
+.form-row-item :deep(.ant-form-item) {
+  margin-bottom: 0;
+  display: flex;
+  flex-direction: column;
+}
+
+.form-row-item :deep(.ant-form-item-label) {
+  flex: 0 0 auto;
+  width: auto;
+  min-width: 80px;
+  text-align: left;
+  padding-right: 8px;
+  padding-bottom: 0;
+}
+
+.form-row-item :deep(.ant-form-item-control) {
+  flex: 1;
+  width: 100%;
+}
+
+.wechat-content-label {
+  font-size: 14px;
+  color: rgba(0, 0, 0, 0.85);
+  font-weight: 500;
+}
+
+:deep(.ant-form-item-label > label.ant-form-item-required:not(.ant-form-item-required-mark-optional)) {
+  &::before {
+    display: inline-block;
+    margin-right: 4px;
+    color: #ff4d4f;
+    font-size: 14px;
+    font-family: SimSun, sans-serif;
+    line-height: 1;
+    content: '*';
+  }
+}
+
+:deep(.ant-form-item) {
+  margin-bottom: 24px;
+}
+
+:deep(.ant-input),
+:deep(.ant-select-selector),
+:deep(.ant-picker) {
+  border-color: #d9d9d9;
+}
+
+:deep(.ant-radio-group) {
+  display: flex;
+  gap: 16px;
+}
+</style>

+ 106 - 0
src/components/EducationPreview.vue

@@ -0,0 +1,106 @@
+<script setup lang="ts">
+const props = defineProps<{
+  content?: string;
+}>();
+</script>
+
+<template>
+  <div class="education-preview-content" v-html="props.content || '<p>暂无内容</p>'"></div>
+</template>
+
+<style scoped lang="scss">
+.education-preview-content {
+  padding-left: 10px;
+  min-height: 400px;
+  line-height: 1.8;
+  color: #333;
+
+  :deep(img) {
+    max-width: 100%;
+    height: auto;
+    display: block;
+    margin: 16px 0;
+  }
+
+  :deep(video) {
+    max-width: 100%;
+    height: auto;
+    display: block;
+    margin: 16px 0;
+  }
+
+  :deep(p) {
+    // margin: 12px 0;
+  }
+
+  :deep(h1),
+  :deep(h2),
+  :deep(h3),
+  :deep(h4),
+  :deep(h5),
+  :deep(h6) {
+    margin: 16px 0 12px 0;
+    font-weight: bold;
+  }
+
+  :deep(ul),
+  :deep(ol) {
+    margin: 12px 0;
+    padding-left: 24px;
+  }
+
+  :deep(li) {
+    margin: 8px 0;
+  }
+
+  :deep(blockquote) {
+    margin: 12px 0;
+    padding: 12px 16px;
+    border-left: 4px solid #d9d9d9;
+    background: #f5f5f5;
+  }
+
+  :deep(code) {
+    padding: 2px 4px;
+    background: #f5f5f5;
+    border-radius: 2px;
+    font-family: 'Courier New', monospace;
+  }
+
+  :deep(pre) {
+    padding: 12px;
+    background: #f5f5f5;
+    border-radius: 4px;
+    overflow-x: auto;
+    margin: 12px 0;
+  }
+
+  :deep(a) {
+    color: #1890ff;
+    text-decoration: none;
+
+    &:hover {
+      text-decoration: underline;
+    }
+  }
+
+  :deep(table) {
+    width: 100%;
+    border-collapse: collapse;
+    margin: 12px 0;
+
+    th,
+    td {
+      border: 1px solid #d9d9d9;
+      padding: 8px 12px;
+      text-align: left;
+    }
+
+    th {
+      background: #fafafa;
+      font-weight: bold;
+    }
+  }
+}
+</style>
+

+ 148 - 0
src/components/InstitutionEdit.vue

@@ -0,0 +1,148 @@
+<script setup lang="ts">
+import { type InstitutionModel } from '@/model/system.model';
+import { branchMethod, editInstitutionMethod } from '@/request/api/system.api';
+import { useRequest } from 'alova/client';
+import { type VxeFormListeners, type VxeFormProps } from 'vxe-pc-ui';
+import { VxeUI } from 'vxe-pc-ui';
+type FormModel = Partial<InstitutionModel>;
+
+const props = defineProps<{ data: FormModel }>();
+const emits = defineEmits<{
+  submit: [data?: InstitutionModel];
+}>();
+
+const model = ref<FormModel>({ ...props.data });
+
+watchEffect(() => {
+  if (props.data) {
+    model.value = { ...props.data };
+  }
+});
+
+const branch = ref<any[]>([]);
+const { loading: branchLoading } = 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 insArr = ref<any[]>([]);
+const insLoading = ref(false);
+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;
+}
+watch(
+  () => model.value?.orgId,
+  async (newVal, oldVal) => {
+    // 只有当值真正改变时才执行(避免初始化时重复调用)
+    if (newVal !== oldVal) {
+      if (newVal) {
+        await getInstitution(newVal);
+      } else {
+        // 清空组织时,清空上级机构列表和已选择的上级机构
+        insArr.value = [];
+        if (model.value) {
+          model.value.parentId = undefined;
+        }
+      }
+    }
+  },
+  { immediate: true, deep: true }
+);
+const { loading: submitting, send: submit } = useRequest(editInstitutionMethod, { immediate: false }).onSuccess(() => {
+  emits('submit');
+});
+
+const formProps = reactive<VxeFormProps<FormModel>>({
+  titleWidth: 120,
+  titleAlign: 'right',
+  titleColon: true,
+  titleAsterisk: true,
+  data: computed(() => model.value) as any,
+  items: [
+    {
+      field: 'orgId',
+      title: '组织名称',
+      span: 24,
+      itemRender: {
+        name: 'VxeSelect',
+        props: {
+          // multiple: true,
+          clearable: true,
+          loading: computed(() => branchLoading.value),
+          options: computed(() => branch.value),
+          optionProps: {
+            value: 'value',
+            label: 'label',
+          },
+        },
+        events: {
+          change({ value }: any) {
+            // 当组织选择改变时,直接调用接口获取上级机构
+            if (value) {
+              getInstitution(value);
+            } else {
+              // 清空组织时,清空上级机构列表和已选择的上级机构
+              insArr.value = [];
+              if (model.value) {
+                model.value.parentId = undefined;
+              }
+            }
+          },
+        },
+      },
+    },
+    { field: 'name', title: '机构名称', span: 24, itemRender: { name: 'VxeInput' } },
+    {
+      field: 'parentId',
+      title: '上级机构',
+      span: 24,
+      itemRender: {
+        name: 'VxeTreeSelect',
+        props: {
+          loading: computed(() => insLoading.value),
+          options: computed(() => insArr.value),
+          optionProps: { value: 'id', label: 'label' },
+          clearable: true,
+        },
+      },
+    },
+    { field: 'action', align: 'center', span: 24, slots: { default: 'active' } },
+  ],
+  rules: {
+    roleIds: [{ required: true, message: '请选择组织' }],
+    nickName: [{ required: true, message: '请输入机构名称' }],
+  },
+});
+const formEmits: VxeFormListeners<FormModel> = {
+  submit({ data }) {
+    submit(data);
+  },
+};
+function cancel() {
+  VxeUI.modal.close('institution-edit-modal');
+}
+</script>
+<template>
+  <vxe-form v-bind="formProps" v-on="formEmits" :loading="submitting">
+    <template #active>
+      <vxe-button type="submit" status="primary" content="提交" :loading="submitting"></vxe-button>
+      <vxe-button content="取消" :disabled="submitting" @click="cancel"></vxe-button>
+    </template>
+  </vxe-form>
+</template>
+<style scoped lang="scss"></style>

+ 267 - 0
src/components/OrganizationManagement.vue

@@ -0,0 +1,267 @@
+<script setup lang="ts">
+import { VxeUI, type VxeFormProps, type VxeFormListeners } from 'vxe-pc-ui';
+import { useRequest } from 'alova/client';
+import { addOrganizationMethod } from '@/request/api/system.api';
+import { notification } from 'ant-design-vue';
+import type { OrganizationModel } from '@/model/system.model';
+(notification.config as any)({
+  zIndex: 10000, // 直接设置层级
+});
+
+const props = defineProps<{ data: OrganizationModel }>();
+
+const emits = defineEmits<{
+  submit: [data?: OrganizationModel];
+}>();
+
+const model = ref<OrganizationModel>({} as OrganizationModel);
+
+watchEffect(() => {
+  if (props.data) {
+    model.value = { ...props.data };
+  }
+});
+
+const { loading: submitting, send: submit } = useRequest(addOrganizationMethod, { immediate: false }).onSuccess(() => {
+  emits('submit');
+});
+
+const formItems = computed(() => {
+  const baseItems: any[] = [
+    {
+      field: 'name',
+      title: '组织名称',
+      span: 20,
+      itemRender: {
+        name: 'VxeInput',
+        props: {
+          placeholder: '请输入',
+        },
+      },
+    },
+    {
+      field: 'appId',
+      title: '小程序ID',
+      span: 20,
+      itemRender: {
+        name: 'VxeInput',
+        props: {
+          placeholder: '请输入',
+        },
+      },
+    },
+    {
+      field: 'appSecret',
+      title: '密钥',
+      span: 20,
+      itemRender: {
+        name: 'VxeInput',
+        props: {
+          placeholder: '请输入',
+        },
+      },
+    },
+    {
+      field: 'remark',
+      title: '备注',
+      span: 24,
+      slots: {
+        default: 'remarksSlot',
+      },
+    },
+    { align: 'center', span: 24, slots: { default: 'active' } },
+  ];
+  return baseItems;
+});
+
+const formProps = reactive<VxeFormProps>({
+  titleWidth: 100,
+  titleAlign: 'right',
+  titleColon: true,
+  data: computed(() => model.value),
+  items: [] as any, // 临时设置为空数组,我们将在模板中使用动态items
+  rules: {
+    name: [{ required: true, message: '请输入组织名称' }],
+    appId: [{ required: true, message: '请输入小程序ID' }],
+    appSecret: [{ required: true, message: '请输入密钥' }],
+  },
+});
+
+const formEmits: VxeFormListeners = {
+  submit({ data }) {
+    data.remark ??= '';
+    submit(data).then(() => {
+      notification.success({
+        message: '操作成功',
+      });
+      VxeUI.modal.close('organization-edit-modall');
+    });
+  },
+};
+
+function cancel() {
+  VxeUI.modal.close('organization-edit-modal');
+}
+
+onBeforeMount(async () => {
+  if (props.data) {
+    model.value = { ...props.data };
+  }
+});
+
+
+</script>
+
+<template>
+  <div class="form-container">
+    <vxe-form
+      :title-width="formProps.titleWidth"
+      :title-align="formProps.titleAlign"
+      :title-colon="formProps.titleColon"
+      :data="formProps.data"
+      :items="formItems"
+      :rules="formProps.rules"
+      v-on="formEmits"
+      :loading="submitting"
+    >
+      <template #deviceIdTitleSlot> <span style="color: #f56c6c; font-size: 20px">*</span> 设备ID </template>
+
+      <template #deviceIdSlot>
+        <div class="device-ids-container">
+          <vxe-input v-model="deviceIdsSafe[index]" placeholder="请输入" style="width: 200px" />
+        </div>
+      </template>
+
+      <template #remarksSlot>
+        <div class="section-container">
+          <textarea
+            v-model="model.remark"
+            placeholder="请输入"
+            rows="3"
+            style="width: 100%; padding: 8px; border: 1px solid #d9d9d9; border-radius: 4px; resize: vertical; font-family: inherit"
+          />
+        </div>
+      </template>
+
+      <template #active>
+        <vxe-button type="reset" content="取消" :disabled="submitting" @click="cancel"></vxe-button>
+        <vxe-button type="submit" status="warning" content="确定" :loading="submitting"></vxe-button>
+      </template>
+    </vxe-form>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.form-container {
+  padding: 20px;
+}
+
+.device-ids-container {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  gap: 8px;
+  align-items: flex-start;
+
+  .device-id-item {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    margin-bottom: 8px;
+  }
+}
+
+.section-container {
+  margin-bottom: 20px;
+
+  .section-title {
+    font-size: 16px;
+    font-weight: bold;
+    margin-bottom: 10px;
+    color: #333;
+  }
+
+  .section-divider {
+    height: 1px;
+    background-color: #d9d9d9;
+    margin-bottom: 15px;
+  }
+}
+
+.process-config {
+  .config-item {
+    display: flex;
+    align-items: center;
+    margin-bottom: 16px;
+    flex-wrap: wrap;
+    gap: 8px;
+
+    .config-label {
+      font-weight: bold;
+      min-width: 80px;
+    }
+
+    .report-label {
+      margin-left: 40px;
+      color: #666;
+      margin-right: 10px;
+    }
+
+    .radio-group {
+      display: flex;
+      align-items: center;
+      gap: 30px;
+
+      .radio-item {
+        display: flex;
+        align-items: center;
+        gap: 4px;
+        cursor: pointer;
+
+        input[type='radio'] {
+          margin: 0;
+          cursor: pointer;
+        }
+
+        span {
+          font-size: 14px;
+        }
+      }
+
+      .badge {
+        background-color: #faad14;
+        color: white;
+        border-radius: 12px;
+        padding: 2px 8px;
+        font-size: 12px;
+        min-width: 20px;
+        text-align: center;
+      }
+    }
+  }
+}
+
+:deep(.vxe-form--item) {
+  .vxe-form--item-wrapper {
+    .vxe-form--item-content {
+      .vxe-input {
+        width: 100%;
+      }
+    }
+  }
+}
+
+.required-field {
+  :deep(.vxe-form--item-title) {
+    position: relative;
+
+    &::before {
+      content: '*';
+      color: #ff4d4f;
+      position: absolute;
+      left: -8px;
+      top: 0;
+    }
+  }
+}
+</style>

+ 111 - 0
src/components/PatientBelong.vue

@@ -0,0 +1,111 @@
+<script setup lang="ts">
+import { type OrganizationModel } from '@/model/system.model';
+import { branchMethod } from '@/request/api/system.api';
+import { setPatientBelongMethod } from '@/request/api/system.api';
+import { useRequest } from 'alova/client';
+import { type VxeFormListeners, type VxeFormProps } from 'vxe-pc-ui';
+import { VxeUI } from 'vxe-pc-ui';
+type FormModel = Partial<OrganizationModel>;
+
+const props = defineProps<{ data: OrganizationModel }>();
+const emits = defineEmits<{
+  submit: [data?: OrganizationModel];
+}>();
+
+const { loading: submitting, send: submit } = useRequest(setPatientBelongMethod, { immediate: false }).onSuccess(({ data }) => {
+  emits('submit');
+});
+const insArr = ref<any[]>([]);
+const insLoading = ref(false);
+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 formProps = reactive<VxeFormProps<FormModel>>({
+  titleWidth: 120,
+  titleAlign: 'right',
+  titleColon: true,
+  titleAsterisk: true,
+  data: { ...props.data },
+  items: [
+    {
+      field: 'insId',
+      title: '机构',
+      span: 24,
+      itemRender: {
+        name: 'VxeTreeSelect',
+        props: {
+          loading: computed(() => insLoading.value),
+          options: computed(() => insArr.value),
+          optionProps: { value: 'id' },
+          align: 'right',
+        },
+      },
+      className: 'form-item-center',
+    },
+    { align: 'center', span: 24, slots: { default: 'active' }, className: 'form-buttons-bottom' },
+  ],
+  rules: {
+    // deptId: [{ required: true, message: '请选择机构' }],
+  },
+});
+const formEmits: VxeFormListeners<FormModel> = {
+  submit({ data }) {
+    submit(data);
+  },
+};
+function cancel() {
+  VxeUI.modal.close('patient-belong-modal');
+}
+
+onBeforeMount(() => {
+  if (props.data?.id) {
+    formProps.data = { ...props.data };
+    getInstitution(props.data?.id);
+  }
+});
+</script>
+<template>
+  <div class="form-container">
+    <div class="flex flex-col items-center justify-center">
+      <div class="text-center mb-8 mt-6 title">当患者没有绑定机构时,默认归属于哪一个机构?</div>
+      <div class="text-center mb-12 color-black">请选择</div>
+    </div>
+    <vxe-form v-bind="formProps" v-on="formEmits" :loading="submitting">
+      <template #active>
+        <vxe-button type="submit" status="primary" content="提交" :loading="submitting"></vxe-button>
+        <vxe-button content="取消" :disabled="submitting" @click="cancel()"></vxe-button>
+      </template>
+    </vxe-form>
+  </div>
+</template>
+<style scoped lang="scss">
+.form-container {
+  display: flex;
+  flex-direction: column;
+  min-height: 100%;
+}
+
+.title {
+  color: #ab4343;
+  font-size: 16px;
+}
+
+:deep(.form-item-center) {
+  display: flex;
+  justify-content: center;
+
+  .vxe-form--item-content {
+    width: 300px;
+  }
+}
+
+:deep(.form-buttons-bottom) {
+  margin-top: auto;
+  padding-top: 150px;
+}
+</style>

+ 261 - 0
src/components/RichTextEditor.vue

@@ -0,0 +1,261 @@
+<script setup lang="ts">
+import { ref, watch, onMounted, nextTick } from 'vue';
+
+const props = defineProps<{
+  modelValue?: string;
+}>();
+
+const emits = defineEmits<{
+  'update:modelValue': [value: string];
+  change: [value: string];
+}>();
+
+const editorRef = ref<HTMLDivElement>();
+const content = ref(props.modelValue || '');
+
+// 监听外部值变化
+watch(
+  () => props.modelValue,
+  (newVal) => {
+    if (newVal !== content.value && editorRef.value) {
+      editorRef.value.innerHTML = newVal || '';
+      content.value = newVal || '';
+    }
+  }
+);
+
+// 内容变化处理
+const handleInput = () => {
+  if (editorRef.value) {
+    const html = editorRef.value.innerHTML;
+    content.value = html;
+    emits('update:modelValue', html);
+    emits('change', html);
+  }
+};
+
+// 工具栏命令
+const execCommand = (command: string, value?: string | boolean) => {
+  document.execCommand(command, false, value as string);
+  editorRef.value?.focus();
+  handleInput();
+};
+
+// 格式化按钮
+const formatBold = () => execCommand('bold');
+const formatItalic = () => execCommand('italic');
+const formatUnderline = () => execCommand('underline');
+
+// 对齐
+const alignLeft = () => execCommand('justifyLeft');
+const alignCenter = () => execCommand('justifyCenter');
+const alignRight = () => execCommand('justifyRight');
+const alignJustify = () => execCommand('justifyFull');
+
+// 列表
+const insertUnorderedList = () => execCommand('insertUnorderedList');
+const insertOrderedList = () => execCommand('insertOrderedList');
+
+// 其他
+const formatBlockquote = () => execCommand('formatBlock', 'blockquote');
+const indent = () => execCommand('indent');
+const outdent = () => execCommand('outdent');
+const insertLink = () => {
+  const url = prompt('请输入链接地址:');
+  if (url) {
+    execCommand('createLink', url);
+  }
+};
+const insertCode = () => execCommand('formatBlock', 'pre');
+
+// 撤销/重做
+const undo = () => execCommand('undo');
+const redo = () => execCommand('redo');
+
+// 插入图片
+const insertImage = () => {
+  const url = prompt('请输入图片地址:');
+  if (url) {
+    execCommand('insertImage', url);
+  }
+};
+
+// 插入视频
+const insertVideo = () => {
+  const url = prompt('请输入视频地址:');
+  if (url && editorRef.value) {
+    const video = document.createElement('video');
+    video.controls = true;
+    video.src = url;
+    video.style.maxWidth = '100%';
+    editorRef.value.appendChild(video);
+    handleInput();
+  }
+};
+
+// 段落格式
+const formatParagraph = (tag: string) => {
+  execCommand('formatBlock', tag);
+};
+
+onMounted(() => {
+  if (editorRef.value && props.modelValue) {
+    editorRef.value.innerHTML = props.modelValue;
+  }
+});
+</script>
+
+<template>
+  <div class="rich-text-editor">
+    <!-- 工具栏第一行 -->
+    <div class="toolbar toolbar-row-1">
+      <a-select
+        :value="'p'"
+        style="width: 100px"
+        @change="(val) => formatParagraph(val)"
+        placeholder="段落"
+      >
+        <a-select-option value="p">段落</a-select-option>
+        <a-select-option value="h1">标题1</a-select-option>
+        <a-select-option value="h2">标题2</a-select-option>
+        <a-select-option value="h3">标题3</a-select-option>
+      </a-select>
+
+      <div class="toolbar-divider"></div>
+
+      <a-button-group>
+        <a-button @click="formatBold" title="粗体" size="small">
+          <strong style="font-weight: bold">B</strong>
+        </a-button>
+        <a-button @click="formatItalic" title="斜体" size="small">
+          <em style="font-style: italic">I</em>
+        </a-button>
+        <a-button @click="formatUnderline" title="下划线" size="small">
+          <u style="text-decoration: underline">U</u>
+        </a-button>
+      </a-button-group>
+
+      <div class="toolbar-divider"></div>
+
+      <a-button-group>
+        <a-button @click="alignLeft" title="左对齐" size="small">⬅</a-button>
+        <a-button @click="alignCenter" title="居中" size="small">⬍</a-button>
+        <a-button @click="alignRight" title="右对齐" size="small">➡</a-button>
+        <a-button @click="alignJustify" title="两端对齐" size="small">⬌</a-button>
+      </a-button-group>
+
+      <div class="toolbar-divider"></div>
+
+      <a-button-group>
+        <a-button @click="insertUnorderedList" title="无序列表" size="small">•</a-button>
+        <a-button @click="insertOrderedList" title="有序列表" size="small">1.</a-button>
+      </a-button-group>
+
+      <div class="toolbar-divider"></div>
+
+      <a-button @click="formatBlockquote" title="引用" size="small">"</a-button>
+      <a-button @click="outdent" title="减少缩进" size="small">◀</a-button>
+      <a-button @click="indent" title="增加缩进" size="small">▶</a-button>
+      <a-button @click="insertLink" title="链接" size="small">🔗</a-button>
+      <a-button @click="formatBlockquote" title="引用块" size="small">[]</a-button>
+      <a-button @click="insertCode" title="代码块" size="small">{}</a-button>
+    </div>
+
+    <!-- 工具栏第二行 -->
+    <div class="toolbar toolbar-row-2">
+      <a-button @click="undo" title="撤销" size="small">↶</a-button>
+      <a-button @click="redo" title="重做" size="small">↷</a-button>
+      <a-button @click="insertImage" title="图片" size="small">🖼️</a-button>
+      <a-button @click="insertVideo" title="视频" size="small">📹</a-button>
+      <a-button title="计数" size="small" class="counter-btn">1</a-button>
+    </div>
+
+    <!-- 编辑区域 -->
+    <div
+      ref="editorRef"
+      class="editor-content"
+      contenteditable="true"
+      @input="handleInput"
+      @paste="handleInput"
+      placeholder="请输入文字"
+    ></div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.rich-text-editor {
+  border: 1px solid #d9d9d9;
+  border-radius: 4px;
+  background: #fff;
+  display: flex;
+  flex-direction: column;
+}
+
+.toolbar {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 12px;
+  border-bottom: 1px solid #d9d9d9;
+  background: #fafafa;
+  flex-wrap: wrap;
+}
+
+.toolbar-row-1 {
+  border-bottom: 1px solid #e8e8e8;
+}
+
+.toolbar-row-2 {
+  border-top: 1px solid #e8e8e8;
+}
+
+.toolbar-divider {
+  width: 1px;
+  height: 20px;
+  background: #d9d9d9;
+  margin: 0 4px;
+}
+
+.editor-content {
+  min-height: 300px;
+  padding: 12px;
+  outline: none;
+  overflow-y: auto;
+  line-height: 1.6;
+  color: #000;
+
+  &:empty::before {
+    content: attr(placeholder);
+    color: #bfbfbf;
+  }
+
+  :deep(img) {
+    max-width: 100%;
+    height: auto;
+  }
+
+  :deep(video) {
+    max-width: 100%;
+    height: auto;
+  }
+}
+
+:deep(.ant-btn) {
+  border-color: #d9d9d9;
+  color: #000;
+  height: 28px;
+  padding: 0 8px;
+
+  &:hover {
+    border-color: #40a9ff;
+    color: #40a9ff;
+  }
+}
+
+.counter-btn {
+  background: #ffd700 !important;
+  border-color: #ffd700 !important;
+  color: #000 !important;
+}
+</style>
+

+ 18 - 0
src/model/education.model.ts

@@ -0,0 +1,18 @@
+export interface EducationModel {
+  id: string; //宣教id
+  title: string; //标题
+  briefImg: string; //列表图片
+  content: string; //内容
+  tagIds: string[] | string;
+  tagNames: string[] | string;
+  tagNameStr: string; //标签名称字符串
+  pushType: '1' | '2'; //推送类型:0-自动推送,1-仅通过通知管理推送
+  status:string; //状态:0-启用,1-禁用
+  createTime: string; //创建时间
+  createBy: string; //创建人
+  isSwitch: boolean; //是否启用
+  
+}
+
+export type EducationQuery = Partial<EducationModel>;
+

+ 28 - 9
src/model/system.model.ts

@@ -68,15 +68,6 @@ export function fromTag(data: Record<string, any>): TagModel {
   }) as unknown as TagModel;
 }
 
-
-// export interface PlanQuery {
-//   id?: string;
-//   status: '0' | '1';
-//   name: string;
-//   label?: string;
-//   enabled: boolean;
-// }
-
 export interface PlanModel {
   id: string;
   name: string;
@@ -101,3 +92,31 @@ export interface PlanModel {
 }
 
 export type PlanQuery = Partial<PlanModel>;
+
+export interface OrganizationModel {
+  id: string; //组织id
+  name: string; //组织名称
+  appId: string; //小程序id
+  appSecret: string; //小程序密钥
+  insId: string; //机构id
+  insName: string; //机构名称
+  createTime: string; //创建时间
+  createBy: string; //创建人
+  remark: string; //备注
+}
+
+export type OrganizationQuery = Partial<OrganizationModel>;
+
+export interface InstitutionModel {
+  id: string; //机构id
+  name: string; //机构名称
+  orgId:string; //组织id
+  orgName:string; //组织名称
+  parentId: string; //上级机构id
+  parentName: string; //上级机构名称
+  status:string; //状态
+  createTime: string; //创建时间
+  createBy: string; //创建人
+}
+
+export type InstitutionQuery = Partial<InstitutionModel>;

+ 303 - 0
src/pages/index/healthy/education.vue

@@ -0,0 +1,303 @@
+<script setup lang="ts">
+import { h } from 'vue';
+import EditEducation from '@/components/EditEducation.vue';
+import EducationPreview from '@/components/EducationPreview.vue';
+
+import type { EducationModel, EducationQuery } from '@/model/education.model';
+
+// 接口数据
+import { deleteEducationMethod, educationMethod, updateEducationStatusMethod } from '@/request/api/education.api';
+import { usePagination, useRequest } from 'alova/client';
+import { notification } from 'ant-design-vue';
+
+import { type VxeFormListeners, type VxeFormProps, type VxeGridInstance, type VxeGridListeners, type VxeGridProps, VxeUI } from 'vxe-pc-ui';
+
+
+
+const model = shallowRef<EducationQuery>();
+const searchFormProps = reactive<VxeFormProps<EducationQuery>>({
+  titleWidth: 100,
+  titleAlign: 'right',
+  titleColon: true,
+  data: {},
+  items: [
+    {
+      field: 'title',
+      title: '标题',
+      span: 4,
+      itemRender: { name: 'VxeInput', props: { placeholder: '请输入' } },
+    },
+
+    // {
+    //   field: 'status',
+    //   title: '针对',
+    //   span: 5,
+    //   itemRender: {
+    //     name: 'VxeRadioGroup',
+    //     options: [
+    //       { label: '理疗项目', value: '1' },
+    //       { label: '医生', value: '2' },
+    //       { label: '无针对性', value: '3' },
+    //     ],
+    //   },
+    // },
+    {
+      field: 'createBy',
+      title: '创建者',
+      span: 4,
+      itemRender: { name: 'VxeInput', props: { placeholder: '请输入' } },
+    },
+    {
+      field: 'status',
+      title: '状态',
+      span: 4,
+      itemRender: {
+        name: 'VxeRadioGroup',
+        options: [
+          { label: '启用', value: '0' },
+          { label: '禁用', value: '1' },
+        ],
+      },
+    },
+    {
+      span: 7,
+      itemRender: {
+        name: 'VxeButtonGroup',
+        options: [
+          { name: 'submits', type: 'submit', content: '查询', status: 'primary' },
+          { name: 'reset', type: 'reset', content: '清空' },
+          { name: 'add', content: '新增', status: 'primary' },
+        ],
+        events: {
+          click(slotParams, { name }) {
+            if (name === 'add') {
+              // 新增
+              editEducation();
+            }
+          },
+        },
+      },
+    },
+  ],
+});
+
+const searchFormEmits: VxeFormListeners<EducationQuery> = {
+  // 查询随访计划
+  submit({ data }) {
+    model.value = { ...data };
+  },
+  // 重置
+  reset({ data }) {
+    model.value = { ...data };
+  },
+};
+const gridRef = ref<VxeGridInstance<EducationModel>>();
+const gridOptions = reactive<VxeGridProps<EducationModel>>({
+  id: 'tag-list',
+  border: true,
+  showOverflow: true,
+  height: 'auto',
+  autoResize: false,
+  syncResize: true,
+  scrollY: { enabled: true, gt: 0 },
+  toolbarConfig: {
+    custom: true,
+    zoom: true,
+    slots: {
+      // buttons: 'handle',
+      tools: 'toolbar-extra',
+    },
+  },
+  columnConfig: {
+    resizable: true,
+  },
+  customConfig: {
+    storage: true,
+  },
+  columns: [
+    { type: 'seq', width: 70, fixed: 'left' },
+    { field: 'title', title: '标题' },
+    { field: 'tagNames', title: '用户标签' },
+    { field: 'createBy', title: '创建者' },
+    { field: 'createTime', title: '创建时间' },
+    {
+      field: 'status',
+      title: '启用状态',
+      align: 'center',
+      cellRender: {
+        name: 'VxeSwitch',
+        props: {
+          openLabel: '启用',
+          openValue: '0',
+          closeLabel: '禁用',
+          closeValue: '1',
+        },
+        events: {
+          change({ row, rowIndex }, { value }) {
+            row.status = { '1': '0', '0': '1' }[value as string] as any;
+            updatePlanStatus(row, rowIndex, value);
+          },
+        },
+      },
+    },
+    {
+      field: 'action',
+      title: '操作',
+      align: 'center',
+      width: 180,
+      showOverflow: false,
+      cellRender: {
+        name: 'VxeButtonGroup',
+        props: {
+          mode: 'text',
+        },
+        options: [
+          // { content: '详情', status: 'primary', name: 'detailEducation' },
+          { content: '编辑', status: 'primary', name: 'editEducation' },
+          { content: '删除', status: 'primary', name: 'deleteEducation' },
+        ],
+        events: {
+          click({ row, rowIndex }, { name }) {
+            let method;
+            if (name === 'editEducation') {
+              method = editEducation;
+            } else if (name === 'deleteEducation') {
+              method = deleteEducation;
+            }
+            method?.(row, rowIndex);
+          },
+        },
+      },
+    },
+  ],
+  data: [],
+});
+const gridEvents: VxeGridListeners = {};
+
+const { loading, page, pageSize, total, onSuccess, replace, refresh, remove } = usePagination((page, size) => educationMethod(page, size, model.value), {
+  initialData: { data: [], total: 0 },
+  initialPage: 1,
+  initialPageSize: 100,
+  watchingStates: [model],
+  immediate: false,
+});
+onSuccess(({ data: { data } }) => {
+  gridRef.value?.loadData(data);
+});
+
+onMounted(() => {
+  model.value = toRaw(searchFormProps.data);
+});
+
+function updatePlanStatus(model: EducationModel, index: number, status: EducationModel['status']) {
+  const { id, title } = model;
+  const label = { '1': '禁用', '0': '启用' }[status];
+  VxeUI.modal.confirm({
+    title: `启用状态`,
+    content: `确认要 ${label} ${title} 宣教吗?`,
+    showClose: false,
+    onConfirm() {
+      updateEducationStatusMethod({ id, status }).then(() => {
+        notification.success({
+          message: `${label}宣教: ${title}`,
+          description: '操作成功',
+        });
+        model.status = status;
+        replace(model, index);
+      });
+    },
+  });
+}
+
+function deleteEducation(model: EducationModel, index: number) {
+  const { title } = model;
+  VxeUI.modal.confirm({
+    title: `删除宣教`,
+    content: `确认要删除 ${title} 宣教吗?`,
+    showClose: false,
+    onConfirm() {
+      deleteEducationMethod(model).then(() => {
+        notification.success({
+          message: `删除宣教: ${title}`,
+          description: '操作成功',
+        });
+        refresh(page.value);
+      });
+    },
+  });
+}
+
+function editEducation(model?: EducationModel, index?: number) {
+  VxeUI.modal.open({
+    title: model?.id ? `修改宣教` : `新增宣教`,
+    fullscreen: true,
+    escClosable: true,
+    destroyOnClose: true,
+    id: `education-modal`,
+    remember: true,
+    storage: true,
+    slots: {
+      default() {
+        return h(EditEducation, <any>{
+          data: model,
+          onSubmit: (data: EducationModel) => {
+            refresh(page.value);
+            VxeUI.modal.close(`education-modal`);
+          },
+          onPreview: (content: string) => {
+            // 打开预览弹窗
+            VxeUI.modal.open({
+              title: '预览宣教内容',
+              fullscreen: true,
+              escClosable: true,
+              destroyOnClose: true,
+              id: `education-preview-modal`,
+              slots: {
+                default: () => {
+                  return h(EducationPreview, {
+                    content: content,
+                  });
+                },
+              },
+            });
+          },
+        });
+      },
+    },
+  });
+}
+</script>
+<template>
+  <div class="page-container flex flex-col">
+    <header class="flex-none mt-4">
+      <vxe-form v-bind="searchFormProps" v-on="searchFormEmits"></vxe-form>
+    </header>
+    <main class="flex-auto overflow-hidden">
+      <vxe-grid ref="gridRef" v-bind="gridOptions" v-on="gridEvents" :loading="loading">
+        <template #cell="{ row }">{{ row.progress === '1' ? '进行中' : row.progress === '2' ? '已结束' : row.progress === '0' ? '未开始' : '' }} </template>
+        <template #patients="{ row }">
+          <div :class="row.isFilter === 'Y' ? '' : 'text-red'">
+            {{ row.isFilter === 'Y' ? '已筛选' : '未筛选' }}
+          </div>
+        </template>
+        <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);
+}
+</style>

+ 149 - 0
src/pages/index/notify/manage.vue

@@ -0,0 +1,149 @@
+<script setup lang="ts">
+import { ref, unref, shallowReactive, defineAsyncComponent } from 'vue';
+import { usePermission } from '@/core/usePermission';
+
+const panels = shallowReactive([
+  {
+    id: 'notify-manage-list',
+    title: '通知制定',
+    component: defineAsyncComponent(() => import('@/service/NotifyManageList.vue')),
+  },
+  {
+    id: 'notify-manage-record',
+    title: '发送记录',
+    component: defineAsyncComponent(() => import('@/service/NotifyManageRecord.vue')),
+  },
+].filter(item => !unref(item.hide)));
+
+const activePanel = ref(panels[0].id);
+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">
+    <!-- 标签栏 -->
+    <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>
+
+<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;
+  }
+}
+
+.content-wrapper {
+  height: calc(100vh - 60px - 24px - 32px - 60px); // 减去标签栏高度
+  overflow: auto;
+}
+
+.trend-up {
+  color: #ff4d4f;
+  border-color: #ff4d4f;
+}
+
+.trend-down {
+  color: #87d068;
+  border-color: #87d068;
+}
+</style>

+ 154 - 0
src/pages/index/satisfaction/survey.vue

@@ -0,0 +1,154 @@
+<script setup lang="ts">
+import { ref, unref, shallowReactive, defineAsyncComponent } from 'vue';
+import { usePermission } from '@/core/usePermission';
+
+const panels = shallowReactive([
+  {
+    id: 'satisfaction-survey-list',
+    title: '满意度问卷',
+    component: defineAsyncComponent(() => import('@/satisfaction/SurveyList.vue')),
+  },
+  {
+    id: 'satisfaction-send-record',
+    title: '发送记录',
+    component: defineAsyncComponent(() => import('@/satisfaction/SendRecord.vue')),
+  },
+  {
+    id: 'satisfaction-survey-statistics',
+    title: '满意度统计',
+    component: defineAsyncComponent(() => import('@/satisfaction/Statistics.vue')),
+  },
+].filter(item => !unref(item.hide)));
+
+const activePanel = ref(panels[0].id);
+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">
+    <!-- 标签栏 -->
+    <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>
+
+<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;
+  }
+}
+
+.content-wrapper {
+  height: calc(100vh - 60px - 24px - 32px - 60px); // 减去标签栏高度
+  overflow: auto;
+}
+
+.trend-up {
+  color: #ff4d4f;
+  border-color: #ff4d4f;
+}
+
+.trend-down {
+  color: #87d068;
+  border-color: #87d068;
+}
+</style>

+ 219 - 0
src/pages/index/system/institution.vue

@@ -0,0 +1,219 @@
+<script setup lang="ts">
+import InstitutionEdit from '@/components/InstitutionEdit.vue';
+import UserPassword from '@/components/UserPassword.vue';
+import UserPreview from '@/components/UserPreview.vue';
+import UserQRCode from '@/components/UserQRCode.vue';
+import { type InstitutionModel, type InstitutionQuery } from '@/model/system.model';
+
+import { branchMethod, institutionMethod, editInstitutionMethod, deleteInstitutionMethod } from '@/request/api/system.api';
+import { usePagination, useRequest } from 'alova/client';
+import { notification } from 'ant-design-vue';
+
+import { VxeButton, type VxeFormListeners, type VxeFormProps, type VxeGridInstance, type VxeGridListeners, type VxeGridProps, VxeUI } from 'vxe-pc-ui';
+
+const { data: branch, loading: branchLoading } = useRequest(branchMethod);
+const organizationOptions = ref<{ label: string; value: string }[]>([
+  { label: 'liuzhi', value: '1' },
+  { label: 'alice', value: '2' },
+]);
+const model = shallowRef<InstitutionQuery>();
+const searchFormProps = reactive<VxeFormProps<InstitutionQuery>>({
+  titleWidth: 100,
+  titleAlign: 'right',
+  titleColon: true,
+  data: {},
+  items: [
+    { field: 'userName', title: '组织名称', span: 8, itemRender: { name: 'VxeSelect', props: { options: organizationOptions, optionProps: { value: 'value', label: 'label' } } } },
+    { field: 'nickName', title: '机构名称', span: 8, itemRender: { name: 'VxeInput' } },
+    {
+      span: 8,
+      itemRender: {
+        name: 'VxeButtonGroup',
+        options: [
+          { type: 'submit', content: '查询', status: 'primary' },
+          { type: 'reset', content: '清空' },
+        ],
+      },
+    },
+  ],
+});
+const searchFormEmits: VxeFormListeners<InstitutionQuery> = {
+  submit({ data }) {
+    model.value = { ...data };
+  },
+  reset({ data }) {
+    model.value = { ...data };
+  },
+};
+
+const gridRef = ref<VxeGridInstance<InstitutionModel>>();
+const gridOptions = reactive<VxeGridProps<InstitutionModel>>({
+  id: 'user-list',
+  border: true,
+  showOverflow: true,
+  height: 'auto',
+  autoResize: false,
+  syncResize: true,
+  scrollY: { enabled: true, gt: 0 },
+  toolbarConfig: {
+    custom: true,
+    zoom: true,
+    slots: {
+      buttons: 'handle',
+      tools: 'toolbar-extra',
+    },
+  },
+  columnConfig: {
+    resizable: true,
+  },
+  customConfig: {
+    storage: true,
+  },
+  columns: [
+    { type: 'seq', width: 70, fixed: 'left' },
+    { field: 'name', title: '机构名称' },
+    { field: 'parentName', title: '上级机构' },
+    { field: 'orgName', title: '组织名称' },
+    { field: 'createTime', title: '创建时间' },
+    { field: 'createBy', title: '创建者' },
+    {
+      title: '操作',
+      align: 'center',
+      width: 180,
+      showOverflow: false,
+      cellRender: {
+        name: 'VxeButtonGroup',
+        props: {
+          mode: 'text',
+        },
+        options: [
+          { content: '修改', status: 'warning', name: 'editInstitution' },
+          { content: '删除', status: 'error', name: 'deleteInstitution' },
+          { content: '小程序码', status: 'primary', name: 'QRCode' },
+        ],
+        events: {
+          click({ row, rowIndex }, { name }) {
+            let method;
+            if (name === 'editInstitution') {
+              method = editInstitution;
+            } else if (name === 'deleteInstitution') {
+              method = deleteInstitution;
+            } else if (name === 'QRCode') {
+              method = QRCode;
+            }
+            method?.(row, rowIndex);
+          },
+        },
+      },
+    },
+  ],
+  data: [],
+});
+
+const { loading, page, pageSize, total, onSuccess, replace, refresh, remove } = usePagination((page, size) => institutionMethod(page, size, model.value), {
+  initialData: { data: [], total: 0 },
+  initialPage: 1,
+  initialPageSize: 100,
+  watchingStates: [model],
+  immediate: false,
+});
+onSuccess(({ data: { data } }) => {
+  gridRef.value?.loadData(data);
+});
+
+onMounted(() => {
+  model.value = toRaw(searchFormProps.data);
+});
+
+function deleteInstitution(model: InstitutionModel, index: number) {
+  const { name } = model;
+  VxeUI.modal.confirm({
+    title: `删除机构`,
+    content: `确认要删除 ${name} 机构吗?`,
+    showClose: false,
+    onConfirm() {
+      deleteInstitutionMethod(model).then(() => {
+        notification.success({
+          message: `删除机构: ${name}`,
+          description: '操作成功',
+        });
+        refresh(page.value);
+      });
+    },
+  });
+}
+
+function editInstitution(model?: InstitutionModel, index?: number) {
+  VxeUI.modal.open({
+    title: model?.id ? `修改机构` : `新增机构`,
+    escClosable: true,
+    destroyOnClose: true,
+    id: `institution-edit-modal`,
+    remember: true,
+    storage: true,
+    slots: {
+      default() {
+        return h(InstitutionEdit, <any>{
+          data: model,
+          onSubmit(data?: InstitutionModel) {
+            refresh(page.value);
+            VxeUI.modal.close(`institution-edit-modal`);
+          },
+        });
+      },
+    },
+  });
+}
+
+function QRCode(model: InstitutionModel) {
+  const { name } = model;
+  VxeUI.modal.open({
+    title: `${name} 专属小程序码`,
+    escClosable: true,
+    destroyOnClose: true,
+    id: `user-preview-qr-code`,
+    remember: true,
+    storage: true,
+    padding: false,
+    width: 256 + 12 * 2,
+    slots: {
+      default() {
+        return h(UserQRCode, <any>{
+          dataset: model,
+        });
+      },
+    },
+  });
+}
+</script>
+<template>
+  <div class="page-container flex flex-col">
+    <header class="flex-none">
+      <vxe-form v-bind="searchFormProps" v-on="searchFormEmits" />
+    </header>
+    <main class="flex-auto overflow-hidden">
+      <vxe-grid ref="gridRef" v-bind="gridOptions" v-on="gridEvents" :loading="loading">
+        <template #handle>
+          <vxe-button status="primary" permission-code="system:user:add" @click="editInstitution()">新增</vxe-button>
+        </template>
+        <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);
+}
+</style>

+ 229 - 0
src/pages/index/system/organization.vue

@@ -0,0 +1,229 @@
+<script setup lang="ts">
+import OrganizationManagement from '@/components/OrganizationManagement.vue';
+import type { OrganizationModel, OrganizationQuery } from '@/model/system.model';
+
+import { organizationMethod, deleteOrganizationMethod } from '@/request/api/system.api';
+import { usePagination } from 'alova/client';
+import { notification } from 'ant-design-vue';
+import PatientBelong from '@/components/PatientBelong.vue';
+import { type VxeFormListeners, type VxeFormProps, type VxeGridInstance, type VxeGridListeners, type VxeGridProps, VxeUI } from 'vxe-pc-ui';
+const organizationOptions = ref<{ label: string; value: string }[]>([
+  { label: 'liuzhi', value: '1' },
+  { label: 'alice', value: '2' },
+]);
+const model = shallowRef<OrganizationQuery>();
+const searchFormProps = reactive<VxeFormProps<OrganizationQuery>>({
+  titleWidth: 100,
+  titleAlign: 'right',
+  titleColon: true,
+  data: {
+    name: '',
+  },
+  items: [
+    { field: 'name', title: '组织名称', span: 8, itemRender: { name: 'VxeSelect', props: { options: organizationOptions, optionProps: { value: 'value', label: 'label' } } } },
+
+    {
+      span: 16,
+      itemRender: {
+        name: 'VxeButtonGroup',
+        props: {
+          align: 'right',
+        },
+        options: [
+          { type: 'submit', content: '查询', status: 'primary' },
+          { type: 'reset', content: '清空' },
+        ],
+      },
+    },
+  ],
+});
+const searchFormEmits: VxeFormListeners<OrganizationQuery> = {
+  submit({ data }) {
+    model.value = { ...data };
+  },
+  reset({ data }) {
+    model.value = { ...data };
+  },
+};
+
+const gridRef = ref<VxeGridInstance<OrganizationModel>>();
+const gridOptions = reactive<VxeGridProps<OrganizationModel>>({
+  id: 'role-list',
+  border: true,
+  showOverflow: true,
+  height: 'auto',
+  autoResize: false,
+  syncResize: true,
+  scrollY: { enabled: true, gt: 0 },
+  toolbarConfig: {
+    custom: true,
+    zoom: true,
+    slots: {
+      buttons: 'handle',
+      tools: 'toolbar-extra',
+    },
+  },
+  columnConfig: {
+    resizable: true,
+  },
+  customConfig: {
+    storage: true,
+  },
+  columns: [
+    { type: 'seq', width: 70, fixed: 'left' },
+    { field: 'name', title: '组织名称' },
+    { field: 'appId', title: '小程序ID' },
+    { field: 'remark', title: '备注' },
+    { field: 'insName', title: '机构名称' },
+    { field: 'createTime', title: '创建时间' },
+    { field: 'createBy', title: '创建者' },
+    {
+      title: '操作',
+      align: 'center',
+      width: 200,
+      showOverflow: false,
+      cellRender: {
+        name: 'VxeButtonGroup',
+        props: {
+          mode: 'text',
+        },
+        options: [
+          { content: '修改', status: 'warning', name: 'editOrganization' },
+          { content: '删除', status: 'error', name: 'deleteOrganization' },
+          { content: '患者归属设置', status: 'primary', name: 'setPatientBelong' },
+        ],
+        events: {
+          click({ row, rowIndex }, { name }) {
+            let method;
+            if (name === 'editOrganization') {
+              method = editOrganization;
+            } else if (name === 'deleteOrganization') {
+              method = deleteOrganization;
+            } else if (name === 'setPatientBelong') {
+              method = setPatientBelong;
+            }
+            method?.(row, rowIndex);
+          },
+        },
+      },
+    },
+  ],
+  data: [],
+});
+const gridEvents: VxeGridListeners = {};
+
+const { loading, page, pageSize, total, onSuccess, replace, refresh, remove } = usePagination((page, size) => organizationMethod(page, size, model.value), {
+  initialData: { data: [], total: 0 },
+  initialPage: 1,
+  initialPageSize: 100,
+  watchingStates: [model],
+  immediate: false,
+});
+onSuccess(({ data: { data } }) => {
+  gridRef.value?.loadData(data);
+});
+
+onMounted(() => {
+  model.value = toRaw(searchFormProps.data);
+});
+
+function deleteOrganization(model: OrganizationModel, index: number) {
+  const { name } = model;
+  VxeUI.modal.confirm({
+    title: `删除组织`,
+    content: `确认要删除 ${name} 组织吗?`,
+    showClose: false,
+    onConfirm() {
+      deleteOrganizationMethod(model).then(() => {
+        notification.success({
+          message: `删除组织: ${name}`,
+          description: '操作成功',
+        });
+        refresh(page.value);
+      });
+    },
+  });
+}
+
+function editOrganization(model?: OrganizationModel, index?: number) {
+  VxeUI.modal.open({
+    title: model?.id ? `修改组织` : `新增组织`,
+    escClosable: true,
+    destroyOnClose: true,
+    id: `organization-edit-modal`,
+    remember: true,
+    storage: true,
+    position: {
+      top: Math.min(100, window.innerHeight * 0.15),
+    },
+    width: 800,
+    height: 600,
+    slots: {
+      default() {
+        return h(OrganizationManagement, <any>{
+          data: model,
+          onSubmit(data: OrganizationModel) {
+            refresh(page.value);
+            VxeUI.modal.close(`organization-edit-modal`);
+          },
+        });
+      },
+    },
+  });
+}
+
+function setPatientBelong(model: OrganizationModel, index: number) {
+  VxeUI.modal.open({
+    title: `患者归属设置`,
+    escClosable: true,
+    destroyOnClose: true,
+    id: `patient-belong-modal`,
+    remember: true,
+    storage: true,
+    width: 800,
+    height: 600,
+    slots: {
+      default() {
+        return h(PatientBelong, <any>{
+          data: model,
+          onSubmit(data: OrganizationModel) {
+            refresh(page.value);
+            VxeUI.modal.close(`patient-belong-modal`);
+          },
+        });
+      },
+    },
+  });
+}
+</script>
+<template>
+  <div class="page-container flex flex-col">
+    <header class="flex-none">
+      <vxe-form v-bind="searchFormProps" v-on="searchFormEmits"></vxe-form>
+    </header>
+    <main class="flex-auto overflow-hidden">
+      <vxe-grid ref="gridRef" v-bind="gridOptions" v-on="gridEvents" :loading="loading">
+        <template #handle>
+          <vxe-button status="primary" permission-code="system:role:add" @click="editOrganization()">新增</vxe-button>
+        </template>
+        <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);
+}
+</style>

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

@@ -69,17 +69,40 @@ export function getMenusMethod(account: AccountModel) {
   return request.Get<AccountModel, any[]>(`/system/menu/getRouters`, {
     headers: { Authorization: account.token },
     transform(data) {
-    // data[5]?.children?.push({
-    //     path: 'configured',
-    //     meta: { title: '辨识仪配置' }
-    //   },
-    //   {
-    //     path: 'reportManagement',
-    //     meta: { title: '报告管理' }
-    //   },
-    // );
-    //   console.log(data, 'push之后的data', transformMenus(data));
+      data.push(
+        {
+          path: '/',
+          children: [
+            {
+              path: 'healthy/education',
+              name: 'healthyEducation',
+              meta: { title: '健康宣教' },
+            },
+          ],
+        },
+        // {
+        //   path: '/',
+        //   children: [
+        //     {
+        //       path: 'notify/manage',
+        //       name: 'notifyManage',
+        //       meta: { title: '通知管理' },
+        //     },
+        //   ],
+        // },
+        // {
+        //   path: '/',
+        //   children: [
+        //     {
+        //       path: 'satisfaction/survey',
+        //       name: 'satisfactionSurvey',
+        //       meta: { title: '满意度调查' },
+        //     },
+        //   ],
+        // }
+      );
+      console.log(data, 'push之后的data', transformMenus(data));
       return { ...account, menus: transformMenus(data) };
-    }
+    },
   });
 }

+ 31 - 0
src/request/api/education.api.ts

@@ -0,0 +1,31 @@
+import type { List } from '@/model';
+import type { EducationModel, EducationQuery } from '@/model/education.model';
+
+import request from '@/request/alova';
+
+// 获取宣教列表
+export function educationMethod(page: number, size: number, query?: EducationQuery) {
+  return request.Post<List<EducationModel>, List<any>>(`/fdhb-pc/psaManage/pagePsa`, query ?? {}, {
+    hitSource: /education$/,
+    params: { pageNum: page, pageSize: size, ...query },
+  });
+}
+
+// 新增/修改宣教
+export function editEducationMethod(data: Partial<EducationModel>) {
+  return data.id ? request.Post(`/fdhb-pc/psaManage/updatePsa`, data, { name: 'edit-education' }) : request.Post(`/fdhb-pc/psaManage/addPsa`, data, { name: 'add-education' });
+}
+// 删除宣教
+export function deleteEducationMethod(data: Partial<EducationModel>) {
+  return request.Post(`/fdhb-pc/psaManage/deletePsa/${data.id}`, { name: 'delete-education' });
+}
+
+//启用-停用健康宣教
+export function updateEducationStatusMethod(data: Partial<EducationModel>) {
+  return request.Get(`/fdhb-pc/psaManage/updateStatus/${data.id}/${data.status}`, { name: 'update-education-status' });
+}
+
+//根据健康宣教ID获取健康宣教详情
+export function getEducationDetailMethod(id: string) {
+  return request.Post(`/fdhb-pc/psaManage/detail/${id}`, { name: 'get-education-detail' });
+}

+ 84 - 66
src/request/api/system.api.ts

@@ -1,12 +1,11 @@
-import type { List, Tree }                                                     from '@/model';
-import type { RoleModel, RoleQuery, TagModel, TagQuery, UserModel, UserQuery } from '@/model/system.model';
+import type { List, Tree } from '@/model';
+import type { RoleModel, RoleQuery, TagModel, TagQuery, UserModel, UserQuery, OrganizationModel, OrganizationQuery, InstitutionModel, InstitutionQuery } from '@/model/system.model';
 import { fromTag } from '@/model/system.model';
 
 import request from '@/request/alova';
 
-
 export function branchMethod(one?: number, two?: number, three?: number) {
-  return request.Get<Tree<{ id: string, label: string }>>(
+  return request.Get<Tree<{ id: string; label: string }>>(
     `/system/user/deptTree${one !== undefined && one !== null ? '/' + one : ''}${two !== undefined && two !== null ? '/' + two : ''}${three !== undefined && three !== null ? '/' + three : ''}`
   );
 }
@@ -19,70 +18,55 @@ export function usersMethod(page: number, size: number, query?: UserQuery) {
 }
 
 export function userMethod(data: Partial<UserModel>) {
-  return request.Get<UserModel, any>(
-    `/system/user/${ data.userId }`,
-    {
-      hitSource: 'edit-user',
-      transform(data) {
-        let roleIds = data?.roleIds ?? [];
-        if ( !roleIds.length ) roleIds = data?.roles?.map?.((t: any) => t.roleId);
-        return { ...data, roleIds };
-      },
+  return request.Get<UserModel, any>(`/system/user/${data.userId}`, {
+    hitSource: 'edit-user',
+    transform(data) {
+      let roleIds = data?.roleIds ?? [];
+      if (!roleIds.length) roleIds = data?.roles?.map?.((t: any) => t.roleId);
+      return { ...data, roleIds };
     },
-  );
+  });
 }
 
 export function editUserMethod(data: Partial<UserModel>) {
-  return data.userId
-         ? request.Put(`/system/user`, data, { name: 'edit-user' })
-         : request.Post(`/system/user`, data, { name: 'edit-user' });
+  return data.userId ? request.Put(`/system/user`, data, { name: 'edit-user' }) : request.Post(`/system/user`, data, { name: 'edit-user' });
 }
 
 export function deleteUserMethod(data: Partial<UserModel>) {
-  return request.Delete(`/system/user/${ data.userId }`, { name: 'delete-user' });
+  return request.Delete(`/system/user/${data.userId}`, { name: 'delete-user' });
 }
 
 export function updateUserPasswordMethod(data: Record<string, any>, update = false) {
-  if ( update ) {
+  if (update) {
     const params = { oldPassword: data.old, newPassword: data.password };
     return request.Put(`/system/user/profile/updatePwd`, {}, { params });
   }
   return request.Put(`/system/user/resetPwd`, data);
 }
 
-
 export function rolesAllMethod() {
   return request.Get<List<RoleModel>>(`/system/role/optionselect`);
 }
 
-
 export function rolesMethod(page: number, size: number, query?: RoleQuery) {
-  return request.Get<List<RoleModel>>(
-    `/system/role/list`,
-    {
-      hitSource: /role$/,
-      params: { pageNum: page, pageSize: size, ...query },
-    },
-  );
+  return request.Get<List<RoleModel>>(`/system/role/list`, {
+    hitSource: /role$/,
+    params: { pageNum: page, pageSize: size, ...query },
+  });
 }
 
 export function roleMethod(data: Partial<RoleModel>) {
-  return request.Get<RoleModel>(
-    `/system/role/${ data.roleId }`,
-    {
-      hitSource: 'edit-role',
-    },
-  );
+  return request.Get<RoleModel>(`/system/role/${data.roleId}`, {
+    hitSource: 'edit-role',
+  });
 }
 
 export function editRoleMethod(data: Partial<RoleModel>) {
-  return data.roleId
-         ? request.Put(`/system/role`, data, { name: 'edit-role' })
-         : request.Post(`/system/role`, data, { name: 'edit-role' });
+  return data.roleId ? request.Put(`/system/role`, data, { name: 'edit-role' }) : request.Post(`/system/role`, data, { name: 'edit-role' });
 }
 
 export function deleteRoleMethod(data: Partial<RoleModel>) {
-  return request.Delete(`/system/role/${ data.roleId }`, { name: 'delete-role' });
+  return request.Delete(`/system/role/${data.roleId}`, { name: 'delete-role' });
 }
 
 export function updateRoleStatusMethod(data: Partial<RoleModel>) {
@@ -92,36 +76,33 @@ export function updateRoleStatusMethod(data: Partial<RoleModel>) {
 type RoleMenu = { id: string; label: string; children?: RoleMenu[] };
 
 export function getRoleMenusMethod(data?: Partial<RoleModel>) {
-  return data?.roleId ? request.Get<{ selected: string[], menus: RoleMenu[] }, { menus: RoleMenu[], checkedKeys: string[] }>(
-    `/system/menu/roleMenuTreeselect/${ data.roleId }`,
-    {
-      hitSource: 'edit-role',
-      meta: { unconvert: true },
-      transform(data) {
-        const flat = (menus?: RoleMenu[]): RoleMenu[] => Array.isArray(menus) ? menus.flatMap(item => [item, ...flat(item.children)]) : [];
-        const menus = flat(data.menus);
-        const selected = Array.isArray(data.checkedKeys) ? data.checkedKeys.filter(key => !menus.find(menu => menu.id === key)?.children) : [];
-        return { menus: data.menus, selected };
-      },
-    },
-  ) : request.Get<{ selected: string[], menus: RoleMenu[] }, RoleMenu[]>(`/system/menu/treeselect`, {
-      transform(data) {
-        return { menus: data, selected: [] };
-      },
-    },
-  );
-
+  return data?.roleId
+    ? request.Get<{ selected: string[]; menus: RoleMenu[] }, { menus: RoleMenu[]; checkedKeys: string[] }>(`/system/menu/roleMenuTreeselect/${data.roleId}`, {
+        hitSource: 'edit-role',
+        meta: { unconvert: true },
+        transform(data) {
+          const flat = (menus?: RoleMenu[]): RoleMenu[] => (Array.isArray(menus) ? menus.flatMap((item) => [item, ...flat(item.children)]) : []);
+          const menus = flat(data.menus);
+          const selected = Array.isArray(data.checkedKeys) ? data.checkedKeys.filter((key) => !menus.find((menu) => menu.id === key)?.children) : [];
+          return { menus: data.menus, selected };
+        },
+      })
+    : request.Get<{ selected: string[]; menus: RoleMenu[] }, RoleMenu[]>(`/system/menu/treeselect`, {
+        transform(data) {
+          return { menus: data, selected: [] };
+        },
+      });
 }
 
 export function tagsMethod(page: number, size: number, query?: TagQuery & { type?: Required<TagQuery>['types'][number] }) {
-  if ( Array.isArray(query?.parentIds)) query.parentId = query.parentIds.join(',');
-  else if ( query?.parentIds ) query.parentId = query?.parentIds as string;
+  if (Array.isArray(query?.parentIds)) query.parentId = query.parentIds.join(',');
+  else if (query?.parentIds) query.parentId = query?.parentIds as string;
   if (query?.types?.length === 1) query.type = query.types[0];
   return request.Post<List<TagModel>, List<any>>(`/fdhb-pc/tagManage/pageTag`, query ?? {}, {
     hitSource: /tag$/,
     params: { pageNum: page, pageSize: size },
     transform({ total, data }) {
-      return { total, data: data.map(fromTag) }
+      return { total, data: data.map(fromTag) };
     },
   });
 }
@@ -130,7 +111,7 @@ export function tagsSearchMethod(query?: TagQuery) {
   return request.Post<List<TagModel>, TagModel[]>(`/fdhb-pc/tagManage/selectTag`, query ?? {}, {
     hitSource: /tag$/,
     transform(data) {
-      return { total: data.length, data: data.map(fromTag) }
+      return { total: data.length, data: data.map(fromTag) };
     },
   });
 }
@@ -142,11 +123,10 @@ export function tagMethod(data: Partial<TagModel>) {
   });
 }
 
-
 export function tagEditMethod(data: Partial<TagModel>) {
   return data.id
-         ? request.Post(`/fdhb-pc/tagManage/updateTag`, { ...data, tagId: data.id }, { name: 'edit-tag' })
-         : request.Post(`/fdhb-pc/tagManage/addTag`, data, { name: 'edit-tag' });
+    ? request.Post(`/fdhb-pc/tagManage/updateTag`, { ...data, tagId: data.id }, { name: 'edit-tag' })
+    : request.Post(`/fdhb-pc/tagManage/addTag`, data, { name: 'edit-tag' });
 }
 
 export function tagDeleteMethod(data: Partial<TagModel>) {
@@ -157,7 +137,6 @@ export function tagDeleteMethod(data: Partial<TagModel>) {
   });
 }
 
-
 export function tagUpdateStatusMethod(data: Partial<TagModel>) {
   return request.Get(`/fdhb-pc/tagManage/updateStatus`, {
     name: 'update-tag',
@@ -168,8 +147,47 @@ export function tagUpdateStatusMethod(data: Partial<TagModel>) {
 
 // 添加机构
 export function addInstitutionMethod(data: { name: string; parentId: string; description?: string }) {
-  return request.Post<{ id: string; name: string }>(`/system/dept/add`, data, { 
+  return request.Post<{ id: string; name: string }>(`/system/dept/add`, data, {
     name: 'add-institution',
     cacheFor: null,
   });
 }
+// 获取组织管理列表
+export function organizationMethod(page: number, size: number, query?: OrganizationQuery) {
+  return request.Post<List<OrganizationModel>, List<any>>(`/fdhb-pc/orgManage/pageOrg`, query ?? {}, {
+    hitSource: /organization$/,
+    params: { pageNum: page, pageSize: size, ...query },
+  });
+}
+// 新增/修改组织
+export function addOrganizationMethod(data: Partial<OrganizationModel>) {
+  return data.id
+    ? request.Post(`/fdhb-pc/orgManage/updateOrg`, data, { name: 'edit-organization' })
+    : request.Post(`/fdhb-pc/orgManage/addOrg`, data, { name: 'add-organization' });
+}
+// 删除组织
+export function deleteOrganizationMethod(data: Partial<OrganizationModel>) {
+  return request.Post(`/fdhb-pc/orgManage/deleteOrg/${data.id}`, {
+    name: 'delete-organization',
+  });
+}
+// 患者归属设置
+export function setPatientBelongMethod(data: Partial<OrganizationModel>) {
+  return request.Post(`/fdhb-pc/orgManage/defaultIns/${data.id}/${data.insId}`, { name: 'set-patient-belong' });
+}
+
+// 获取机构列表
+export function institutionMethod(page: number, size: number, query?: InstitutionQuery) {
+  return request.Post<List<InstitutionModel>, List<any>>(`/fdhb-pc/insManage/pageIns`, query ?? {}, {
+    hitSource: /institution$/,
+    params: { pageNum: page, pageSize: size, ...query },
+  });
+}
+// 新增/删除机构
+export function editInstitutionMethod(data: Partial<InstitutionModel>) {
+  return data.id ? request.Post(`/fdhb-pc/insManage/updateIns`, data, { name: 'edit-institution' }) : request.Post(`/fdhb-pc/insManage/addIns`, data, { name: 'add-institution' });
+}
+// 删除机构
+export function deleteInstitutionMethod(data: Partial<InstitutionModel>) {
+  return request.Post(`/fdhb-pc/insManage/deleteIns/${data.id}`, { name: 'delete-institution' });
+}

+ 38 - 22
src/router/index.ts

@@ -1,7 +1,6 @@
-import pinia, { useAccountStore }         from '@/stores';
+import pinia, { useAccountStore } from '@/stores';
 import { createRouter, createWebHistory } from 'vue-router';
 
-
 const router = createRouter({
   history: createWebHistory(import.meta.env.BASE_URL),
   routes: [
@@ -12,58 +11,75 @@ const router = createRouter({
       children: [
         { path: 'patient/history', component: () => import(`@/pages/index/patient/history.vue`) },
         {
-          path: 'patient/room', components: {
+          path: 'patient/room',
+          components: {
             default: () => import(`@/pages/index/patient/room@default.vue`),
             aside: () => import(`@/pages/index/patient/room@aside.vue`),
           },
         },
         {
-          path: 'system', children: [
+          path: 'system',
+          children: [
             { path: 'user', component: () => import(`@/pages/index/system/user.vue`) },
             { path: 'role', component: () => import(`@/pages/index/system/role.vue`) },
             { path: 'tag', component: () => import(`@/pages/index/system/tag.vue`) },
+            { path: 'organization', component: () => import(`@/pages/index/system/organization.vue`) },
+            { path: 'institution', component: () => import(`@/pages/index/system/institution.vue`) },
           ],
         },
         { path: '', redirect: '/patient/history' },
         {
-          path: 'follow', children: [
+          path: 'follow',
+          children: [
             { path: 'plan', component: () => import(`@/pages/index/follow/plan.vue`) },
             { path: 'task', component: () => import(`@/pages/index/follow/task.vue`) },
             { path: 'assessment', component: () => import(`@/pages/index/follow/assessment.vue`) },
           ],
-        },   
+        },
         {
-          path: 'care', children: [
-            { path: 'supplier', component: () => import(`@/pages/index/care/supplier.vue`), },
-            { path: 'serviceItems', component: () => import(`@/pages/index/care/serviceItems.vue`), },
-            { path: 'text', component: () => import(`@/pages/index/care/text.vue`), },
-            { path: 'systemService', component: () => import(`@/pages/index/care/systemService.vue`), },
-            { path: 'institutionService', component: () => import(`@/pages/index/care/institutionService.vue`), },
-            { 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: 'care',
+          children: [
+            { path: 'supplier', component: () => import(`@/pages/index/care/supplier.vue`) },
+            { path: 'serviceItems', component: () => import(`@/pages/index/care/serviceItems.vue`) },
+            { path: 'text', component: () => import(`@/pages/index/care/text.vue`) },
+            { path: 'systemService', component: () => import(`@/pages/index/care/systemService.vue`) },
+            { path: 'institutionService', component: () => import(`@/pages/index/care/institutionService.vue`) },
+            { 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: '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`) },
-            
           ],
-        },   
+        },
         {
-          path: 'tcmRecuperation', children: [
+          path: 'tcmRecuperation',
+          children: [
             { path: 'preserve', component: () => import(`@/pages/index/tcmRecuperation/preserve.vue`) },
             { path: 'system', component: () => import(`@/pages/index/tcmRecuperation/system.vue`) },
             { path: 'institution', component: () => import(`@/pages/index/tcmRecuperation/institution.vue`) },
-        
           ],
         },
+        {
+          path: 'healthy',
+          children: [{ path: 'education', component: () => import(`@/pages/index/healthy/education.vue`) }],
+        },
+        {
+          path: 'notify',
+          children: [{ path: 'manage', component: () => import(`@/pages/index/notify/manage.vue`) }],
+        },
+        {
+          path: 'satisfaction',
+          children: [{ path: 'survey', component: () => import(`@/pages/index/satisfaction/survey.vue`) }],
+        },
       ],
       beforeEnter(to, from, next) {
-        if ( useAccountStore(pinia).token ) {
+        if (useAccountStore(pinia).token) {
           next();
         } else {
           next({ path: '/login' });

+ 311 - 0
src/satisfaction/EditQuestionnaire.vue

@@ -0,0 +1,311 @@
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue';
+import type { FormInstance } from 'ant-design-vue';
+import { VxeUI } from 'vxe-pc-ui';
+import { PlusOutlined, ArrowLeftOutlined, SaveOutlined } from '@ant-design/icons-vue';
+
+const props = defineProps<{
+  data?: any;
+}>();
+
+const emits = defineEmits<{
+  submit: [data?: any];
+  back: [];
+}>();
+
+const formRef = ref<FormInstance>();
+const loading = ref<boolean>(false);
+
+// 问题接口
+interface Question {
+  id: string;
+  content: string;
+}
+
+// 问题分组接口
+interface QuestionGroup {
+  id: string;
+  name: string;
+  questions: Question[];
+}
+
+// 表单数据
+const form = reactive({
+  name: '', // 问卷名称
+  openingRemarks: '', // 开头语
+  closingRemarks: '', // 结束语
+  questionGroups: [
+    {
+      id: '1',
+      name: '',
+      questions: [
+        {
+          id: '1',
+          content: '',
+        },
+      ],
+    },
+  ] as QuestionGroup[],
+});
+
+// 表单验证规则
+const rules = {
+  name: [{ required: true, message: '请输入问卷名称', trigger: 'blur' }],
+};
+
+// 初始化数据
+onMounted(() => {
+  if (props.data) {
+    if (props.data.name) form.name = props.data.name;
+    if (props.data.openingRemarks) form.openingRemarks = props.data.openingRemarks;
+    if (props.data.closingRemarks) form.closingRemarks = props.data.closingRemarks;
+    if (props.data.questionGroups) form.questionGroups = props.data.questionGroups;
+  }
+});
+
+// 添加问题分组
+const addQuestionGroup = () => {
+  const newGroup: QuestionGroup = {
+    id: Date.now().toString(),
+    name: '',
+    questions: [
+      {
+        id: Date.now().toString() + '_1',
+        content: '',
+      },
+    ],
+  };
+  form.questionGroups.push(newGroup);
+};
+
+// 添加问题
+const addQuestion = (groupId: string) => {
+  const group = form.questionGroups.find(g => g.id === groupId);
+  if (group) {
+    const newQuestion: Question = {
+      id: Date.now().toString(),
+      content: '',
+    };
+    group.questions.push(newQuestion);
+  }
+};
+
+// 保存
+const handleSave = async () => {
+  try {
+    await formRef.value?.validate();
+    loading.value = true;
+    const submitData = {
+      ...form,
+    };
+    emits('submit', submitData);
+  } catch (error) {
+    console.error('表单验证失败', error);
+  } finally {
+    loading.value = false;
+  }
+};
+
+// 返回
+const handleBack = () => {
+  emits('back');
+  VxeUI.modal.close(`edit-questionnaire-modal`);
+};
+</script>
+
+<template>
+  <div class="questionnaire-form-container">
+    <!-- 表单内容 -->
+    <div class="form-content">
+      <a-form ref="formRef" :model="form" :rules="rules" layout="horizontal" :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
+        <!-- 问卷名称 -->
+        <a-form-item label="问卷名称" name="name" required>
+          <a-input v-model:value="form.name" placeholder="请输入" />
+        </a-form-item>
+
+        <!-- 开头语 -->
+        <a-form-item label="开头语">
+          <a-textarea v-model:value="form.openingRemarks" placeholder="请输入" :rows="4" />
+        </a-form-item>
+
+        <!-- 结束语 -->
+        <a-form-item label="结束语">
+          <a-textarea v-model:value="form.closingRemarks" placeholder="请输入" :rows="4" />
+        </a-form-item>
+
+        <!-- 问题分组列表 -->
+        <div class="question-groups-section">
+          <div v-for="(group, groupIndex) in form.questionGroups" :key="group.id" class="question-group-item">
+            <!-- 问题分组 -->
+            <a-form-item :label="`问题分组${groupIndex + 1}`">
+              <div class="group-input-wrapper">
+                <a-input v-model:value="group.name" placeholder="请输入" />
+                <button class="add-btn" @click="addQuestionGroup">
+                  <PlusOutlined />
+                </button>
+              </div>
+            </a-form-item>
+
+            <!-- 问题列表 -->
+            <div class="questions-list">
+              <div v-for="(question, questionIndex) in group.questions" :key="question.id" class="question-item">
+                <a-form-item :label="`问题${questionIndex + 1}`">
+                  <div class="question-input-wrapper">
+                    <a-input v-model:value="question.content" placeholder="请输入" />
+                    <button class="add-btn" @click="addQuestion(group.id)">
+                      <PlusOutlined />
+                    </button>
+                  </div>
+                </a-form-item>
+              </div>
+            </div>
+          </div>
+        </div>
+      </a-form>
+    </div>
+
+    <!-- 底部按钮 -->
+    <div class="footer-actions">
+      <a-button type="default" @click="handleBack">
+        <template #icon>
+          <ArrowLeftOutlined />
+        </template>
+        取消
+      </a-button>
+      <a-button type="primary" @click="handleSave" :loading="loading">
+        <template #icon>
+          <SaveOutlined />
+        </template>
+        保存
+      </a-button>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.questionnaire-form-container {
+  padding: 24px;
+  background: #fff;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+.form-content {
+  flex: 1;
+  overflow: auto;
+  margin-bottom: 24px;
+}
+
+// 问题分组部分
+.question-groups-section {
+  display: flex;
+  flex-direction: column;
+  gap: 0;
+}
+
+.question-group-item {
+  margin-bottom: 0;
+}
+
+// 输入框包装器(包含输入框和加号按钮)
+.group-input-wrapper,
+.question-input-wrapper {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  width: 100%;
+
+  :deep(.ant-input) {
+    flex: 1;
+  }
+}
+
+// 加号按钮
+.add-btn {
+  width: 32px;
+  height: 32px;
+  border: 1px dashed #d9d9d9;
+  background: #fff;
+  border-radius: 4px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  color: rgba(0, 0, 0, 0.65);
+  transition: all 0.3s;
+  flex-shrink: 0;
+  padding: 0;
+
+  &:hover {
+    border-color: #ff7a45;
+    color: #ff7a45;
+  }
+
+  &:focus {
+    outline: none;
+  }
+}
+
+// 问题列表
+.questions-list {
+  margin-left: 0;
+  padding-left: 0;
+}
+
+.question-item {
+  margin-bottom: 0;
+}
+
+// 底部按钮
+.footer-actions {
+  display: flex;
+  justify-content: center;
+  gap: 12px;
+  padding-top: 16px;
+  border-top: 1px solid #f0f0f0;
+
+  :deep(.ant-btn-primary) {
+    background: #ff7a45;
+    border-color: #ff7a45;
+
+    &:hover {
+      background: #ff9256;
+      border-color: #ff9256;
+    }
+  }
+
+  :deep(.ant-btn-default) {
+    color: #1890ff;
+    border-color: #1890ff;
+
+    &:hover {
+      color: #40a9ff;
+      border-color: #40a9ff;
+    }
+  }
+}
+
+:deep(.ant-form-item-label > label.ant-form-item-required:not(.ant-form-item-required-mark-optional)) {
+  &::before {
+    display: inline-block;
+    margin-right: 4px;
+    color: #ff4d4f;
+    font-size: 14px;
+    font-family: SimSun, sans-serif;
+    line-height: 1;
+    content: '*';
+  }
+}
+
+:deep(.ant-form-item) {
+  margin-bottom: 24px;
+}
+
+:deep(.ant-input),
+:deep(.ant-textarea),
+:deep(.ant-select-selector),
+:deep(.ant-picker) {
+  border-color: #d9d9d9;
+}
+</style>

+ 488 - 0
src/satisfaction/SeeQuestionnaire.vue

@@ -0,0 +1,488 @@
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue';
+import type { FormInstance } from 'ant-design-vue';
+import { VxeUI } from 'vxe-pc-ui';
+import { PlusOutlined, DeleteOutlined, ArrowLeftOutlined, SaveOutlined } from '@ant-design/icons-vue';
+
+const props = defineProps<{
+  data?: any;
+}>();
+
+const emits = defineEmits<{
+  submit: [data?: any];
+  back: [];
+}>();
+
+const formRef = ref<FormInstance>();
+const loading = ref<boolean>(false);
+
+// 问卷数据结构
+interface Question {
+  id: string;
+  content: string;
+  required?: boolean;
+}
+
+interface Section {
+  id: string;
+  title: string;
+  questions: Question[];
+}
+
+// 表单数据
+const form = reactive({
+  title: '就诊体验满意度', // 问卷标题
+  introduction:
+    '尊敬的患者及家属:\n 您好!感谢您选择我院就诊。为了不断的提升我们的医院服务质量,更好地为您和其他患者提供优质、高效的医疗服务,我们特开展此次就诊满意度回访调查,您的反馈对我们至关重要。请您根据实际就诊体验,恳请您抽出宝贵时间,根据您的实际就诊体验填写以下内容。我们将对您的信息严格保密。感谢您的支持与配合!', // 介绍文字
+  scoreExplanation: '1分表示非常不满意, 2分表示不满意, 3分表示一般, 4分表示满意, 5分表示非常满意', // 分数说明
+  sections: [
+    {
+      id: '1',
+      title: '一、就诊环境评价',
+      questions: [
+        { id: '1', content: '医院门诊 / 住院部的整洁度:', required: true },
+        { id: '2', content: '医院的通风情况:', required: true },
+        { id: '3', content: '医院的采光情况:', required: true },
+        { id: '4', content: '医院的噪音控制:', required: true },
+        { id: '5', content: '候诊区域的舒适度 (座椅、空间等)', required: true },
+      ],
+    },
+  ] as Section[],
+});
+
+// 初始化数据
+onMounted(() => {
+  if (props.data) {
+    if (props.data.title) form.title = props.data.title;
+    if (props.data.introduction) form.introduction = props.data.introduction;
+    if (props.data.scoreExplanation) form.scoreExplanation = props.data.scoreExplanation;
+    if (props.data.sections) form.sections = props.data.sections;
+  }
+});
+
+// 添加问题
+const addQuestion = (sectionId: string) => {
+  const section = form.sections.find((s) => s.id === sectionId);
+  if (section) {
+    const newQuestion: Question = {
+      id: Date.now().toString(),
+      content: '',
+      required: true,
+    };
+    section.questions.push(newQuestion);
+  }
+};
+
+// 删除问题
+const removeQuestion = (sectionId: string, questionId: string) => {
+  const section = form.sections.find((s) => s.id === sectionId);
+  if (section) {
+    const index = section.questions.findIndex((q) => q.id === questionId);
+    if (index > -1) {
+      section.questions.splice(index, 1);
+    }
+  }
+};
+
+// 添加章节
+const addSection = () => {
+  const sectionNumber = form.sections.length + 1;
+  const sectionTitles = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
+  const newSection: Section = {
+    id: Date.now().toString(),
+    title: `${sectionTitles[sectionNumber - 1] || sectionNumber}、`,
+    questions: [],
+  };
+  form.sections.push(newSection);
+};
+
+// 删除章节
+const removeSection = (sectionId: string) => {
+  const index = form.sections.findIndex((s) => s.id === sectionId);
+  if (index > -1 && form.sections.length > 1) {
+    form.sections.splice(index, 1);
+  }
+};
+
+// 保存
+const handleSave = async () => {
+  try {
+    loading.value = true;
+    const submitData = {
+      ...form,
+    };
+    emits('submit', submitData);
+  } catch (error) {
+    console.error('保存失败', error);
+  } finally {
+    loading.value = false;
+  }
+};
+
+// 返回
+const handleBack = () => {
+  emits('back');
+  VxeUI.modal.close(`edit-questionnaire-modal`);
+};
+</script>
+
+<template>
+  <div class="questionnaire-editor-container">
+    <!-- 表单内容 -->
+    <div class="form-content">
+      <a-form ref="formRef" :model="form" layout="vertical">
+        <!-- 问卷标题 -->
+        <h1 class="questionnaire-title-section">
+          {{ form.title }}
+        </h1>
+
+        <!-- 介绍文字 -->
+        <div class="introduction-section">
+          {{ form.introduction }}
+        </div>
+
+        <!-- 分数说明 -->
+        <div class="score-explanation-section">
+          <div class="label">分数说明:</div>
+          <div class="score-explanation-input label">
+            {{ form.scoreExplanation }}
+          </div>
+        </div>
+
+        <!-- 章节列表 -->
+        <div class="sections-container">
+          <div v-for="section in form.sections" :key="section.id" class="section-item">
+            <!-- 章节标题 -->
+            <!-- <div class="section-header"> -->
+            <h2 class="section-title-input label">
+              {{ section.title }}
+            </h2>
+            <!-- <a-button
+                v-if="form.sections.length > 1"
+                type="text"
+                danger
+                @click="removeSection(section.id)"
+                class="delete-section-btn"
+              >
+                <DeleteOutlined />
+                删除章节
+              </a-button> -->
+            <!-- </div> -->
+
+            <!-- 问题列表 -->
+            <div class="questions-list">
+              <div v-for="(question, questionIndex) in section.questions" :key="question.id" class="question-item">
+                <div class="question-content">
+                  <span class="question-number">{{ questionIndex + 1 }}、</span>
+                  <div class="question-input label">
+                    {{ question.content }}
+                  </div>
+                  <!-- <a-button
+                    v-if="section.questions.length > 1"
+                    type="text"
+                    danger
+                    size="small"
+                    @click="removeQuestion(section.id, question.id)"
+                    class="delete-question-btn"
+                  >
+                    <DeleteOutlined />
+                  </a-button> -->
+                </div>
+                <!-- 评分选项(1-5分) -->
+                <div class="score-options">
+                  <a-radio-group :value="1">
+                    <a-radio :value="1">1分</a-radio>
+                    <a-radio :value="2">2分</a-radio>
+                    <a-radio :value="3">3分</a-radio>
+                    <a-radio :value="4">4分</a-radio>
+                    <a-radio :value="5">5分</a-radio>
+                  </a-radio-group>
+                  <!-- <span class="score-hint">(预览模式,实际填写时可选择)</span> -->
+                </div>
+              </div>
+
+              <!-- 添加问题按钮 -->
+              <!-- <a-button
+                type="dashed"
+                block
+                @click="addQuestion(section.id)"
+                class="add-question-btn"
+              >
+                <PlusOutlined />
+                添加问题
+              </a-button> -->
+            </div>
+          </div>
+
+          <!-- 添加章节按钮 -->
+          <!-- <a-button type="dashed" block @click="addSection" class="add-section-btn">
+            <PlusOutlined />
+            添加章节
+          </a-button> -->
+        </div>
+      </a-form>
+    </div>
+
+    <!-- 底部按钮 -->
+    <!-- <div class="footer-actions">
+      <vxe-button type="primary" status="primary" @click="handleBack">
+        <template #icon>
+          <ArrowLeftOutlined />
+        </template>
+        取消
+      </vxe-button>
+      <vxe-button type="submit" status="warning" @click="handleSave" :loading="loading">
+        <template #icon>
+          <SaveOutlined />
+        </template>
+        保存
+      </vxe-button>
+    </div> -->
+  </div>
+</template>
+
+<style scoped lang="scss">
+.questionnaire-editor-container {
+  // border: 1px solid #f0f0f0;
+  padding: 24px;
+  background: #fff;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+.form-content {
+  flex: 1;
+  overflow: auto;
+  margin-bottom: 24px;
+  max-width: 800px;
+  margin: 0 auto 24px;
+  width: 100%;
+}
+
+// 问卷标题
+.questionnaire-title-section {
+  text-align: center;
+  margin-bottom: 32px;
+
+  .title-input {
+    font-size: 24px;
+    font-weight: bold;
+    text-align: center;
+
+    :deep(.ant-input) {
+      font-size: 24px;
+      font-weight: bold;
+      text-align: center;
+      border: none;
+      border-bottom: 2px solid #d9d9d9;
+      border-radius: 0;
+      padding: 12px 0;
+      background: transparent;
+
+      &:focus,
+      &:hover {
+        border-bottom-color: #40a9ff;
+        box-shadow: none;
+      }
+    }
+  }
+}
+
+// 介绍文字
+.introduction-section {
+  margin-bottom: 24px;
+
+  .introduction-textarea {
+    :deep(.ant-input) {
+      font-size: 14px;
+      line-height: 2;
+      white-space: pre-wrap;
+      // border: 1px solid #d9d9d9;
+      padding: 12px;
+      // border-radius: 4px;
+    }
+  }
+}
+
+// 分数说明
+.score-explanation-section {
+  margin-bottom: 24px;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+
+  .label {
+    font-size: 14px;
+    color: rgba(0, 0, 0, 0.85);
+    font-weight: 500;
+    white-space: nowrap;
+  }
+
+  .score-explanation-input {
+    flex: 1;
+
+    :deep(.ant-input) {
+      font-size: 14px;
+    }
+  }
+}
+
+// 章节容器
+.sections-container {
+  display: flex;
+  flex-direction: column;
+  gap: 32px;
+}
+
+// 章节项
+.section-item {
+  border: 1px solid #f0f0f0;
+  border-radius: 4px;
+  padding: 16px;
+  background: #fafafa;
+}
+
+// 章节标题
+.section-header {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 16px;
+
+  .section-title-input {
+    flex: 1;
+
+    :deep(.ant-input) {
+      font-size: 16px;
+      font-weight: bold;
+      border: none;
+      background: transparent;
+      padding: 4px 8px;
+      border-bottom: 1px dashed transparent;
+
+      &:focus,
+      &:hover {
+        border-bottom-color: #d9d9d9;
+        background: #fff;
+      }
+    }
+  }
+
+  .delete-section-btn {
+    flex-shrink: 0;
+  }
+}
+
+// 问题列表
+.questions-list {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+// 问题项
+.question-item {
+  background: #fff;
+  padding: 16px;
+  border-radius: 4px;
+  border: 1px solid #e8e8e8;
+}
+
+// 问题内容
+.question-content {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 12px;
+
+  .question-number {
+    font-size: 14px;
+    color: rgba(0, 0, 0, 0.85);
+    font-weight: 500;
+    flex-shrink: 0;
+  }
+
+  .question-input {
+    flex: 1;
+    font-weight: bold;
+    :deep(.ant-input) {
+      font-size: 14px;
+      border: none;
+      border-bottom: 1px dashed transparent;
+      padding: 4px 8px;
+
+      &:focus,
+      &:hover {
+        border-bottom-color: #d9d9d9;
+      }
+    }
+  }
+
+  .delete-question-btn {
+    flex-shrink: 0;
+  }
+}
+
+// 评分选项
+.score-options {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding-left: 24px;
+  flex-wrap: wrap;
+
+  :deep(.ant-radio-group) {
+    display: flex;
+    gap: 24px;
+    flex-wrap: wrap;
+  }
+
+  :deep(.ant-radio-wrapper) {
+    font-size: 14px;
+    margin-right: 0;
+  }
+
+  .score-hint {
+    font-size: 12px;
+    color: rgba(0, 0, 0, 0.45);
+    font-style: italic;
+    margin-left: auto;
+  }
+}
+
+// 添加问题按钮
+.add-question-btn {
+  margin-top: 8px;
+}
+
+// 添加章节按钮
+.add-section-btn {
+  margin-top: 16px;
+}
+
+// 底部按钮
+.footer-actions {
+  display: flex;
+  justify-content: center;
+  gap: 12px;
+  padding-top: 16px;
+  border-top: 1px solid #f0f0f0;
+
+  :deep(.vxe-button--status-warning) {
+    background: #ff7a45;
+    border-color: #ff7a45;
+
+    &:hover {
+      background: #ff9256;
+      border-color: #ff9256;
+    }
+  }
+}
+
+:deep(.ant-input),
+:deep(.ant-textarea),
+:deep(.ant-select-selector) {
+  border-color: #d9d9d9;
+}
+</style>

+ 321 - 0
src/satisfaction/SendRecord.vue

@@ -0,0 +1,321 @@
+<script setup lang="ts">
+import { ref, reactive, shallowRef, onBeforeUnmount, onMounted } from 'vue';
+import type { SystemItemModel, SystemIteQuery } from '@/model/care.model';
+import SeeQuestionnaire from '@/satisfaction/SeeQuestionnaire.vue';
+import SeeSatisfaction from '@/satisfaction/SeeQuestionnaire.vue';
+// 接口数据
+
+import { pageConfirmedCpMethod, deleteConfirmedCpMethod } from '@/request/api/care.api';
+import { usePagination, useRequest } from 'alova/client';
+import { notification } from 'ant-design-vue';
+import dayjs from 'dayjs';
+import { type VxeFormListeners, type VxeFormProps, type VxeGridInstance, type VxeGridListeners, type VxeGridProps, VxeUI } from 'vxe-pc-ui';
+
+const model = shallowRef<SystemIteQuery>();
+const searchFormProps = reactive<VxeFormProps<SystemIteQuery>>({
+  titleWidth: 100,
+  titleAlign: 'right',
+  titleColon: true,
+  data: { types: ['1'] as any, status: '0' as any },
+  items: [
+    {
+      field: 'name',
+      title: '问卷名称',
+      span: 6,
+      itemRender: { name: 'VxeInput', props: { placeholder: '请输入满意度问卷名称' } },
+    },
+    {
+      field: 'sendTime',
+      title: '发送日期',
+      span: 6,
+      slots: {
+        default: 'createTimes',
+      },
+    },
+    {
+      field: 'status',
+      title: '填写状态',
+      span: 6,
+      itemRender: {
+        name: 'VxeRadioGroup',
+        props: {
+          options: [
+            { label: '已填写', value: '0' },
+            { label: '未填写', value: '1' },
+          ],
+        },
+      },
+    },
+    {
+      span: 6,
+      itemRender: {
+        name: 'VxeButtonGroup',
+        options: [
+          { name: 'submits', type: 'submit', content: '查询', status: 'primary' },
+          { name: 'reset', type: 'reset', content: '重置', status: 'warning' },
+          // { name: 'add', content: '新增', status: 'primary' },
+        ],
+        events: {
+          click(slotParams: any, { name }: any) {
+            if (name === 'add') {
+              // 新增
+              editConfirmed();
+            }
+          },
+        },
+      },
+    },
+  ],
+});
+const searchFormEmits: VxeFormListeners<SystemIteQuery> = {
+  // 查询随访计划
+  submit({ data }) {
+    onSearch(data);
+  },
+
+  // 重置
+  reset({ data }) {
+    onSearch(data);
+  },
+};
+function onSearch(data: SystemIteQuery) {
+  const types: string[] = (data as any).types?.length ? (data as any).types : ['1'];
+  const status: string = (data as any).status ?? '0';
+  const isForWrap: SystemIteQuery['isForWrap'] = types.includes('1') ? 'Y' : null;
+  const isForInfer: SystemIteQuery['isForInfer'] = types.includes('2') ? 'Y' : null;
+
+  model.value = { ...data, types: [...types] as any, status: status as any, isForWrap, isForInfer };
+  nextTick(() => {
+    (searchFormProps.data as any)!.types = [...types];
+    (searchFormProps.data as any)!.status = status;
+    (searchFormProps.data as any)!.isForInfer = isForInfer;
+    (searchFormProps.data as any)!.isForWrap = isForWrap;
+  });
+}
+const gridRef = ref<VxeGridInstance<SystemItemModel>>();
+const gridOptions = reactive<VxeGridProps<SystemItemModel>>({
+  id: 'tag-list',
+  border: true,
+  showOverflow: true,
+  height: 'auto',
+  autoResize: true,
+  syncResize: true,
+  scrollY: { enabled: true, gt: 0 },
+  toolbarConfig: {
+    custom: true,
+    zoom: true,
+    slots: {
+      // buttons: 'handle',
+      tools: 'toolbar-extra',
+    },
+  },
+  columnConfig: {
+    resizable: true,
+  },
+  customConfig: {
+    storage: true,
+  },
+  columns: [
+    { type: 'seq', width: 70, fixed: 'left' },
+    { field: 'name', title: '名称' },
+    { field: 'conditioningProgramType', title: '问卷名称' },
+    { field: 'cpFixedPricingRule.unitPrice', title: '用户姓名' },
+    { field: 'cpFixedPricingRule.pricingUnit', title: '手机号码' },
+    { field: 'cpFixedPricingRule.convertDose', title: '得分' },
+    { field: 'cpFixedPricingRule.convertDose', title: '发送时间' },
+    { field: 'cpFixedPricingRule.convertDose', title: '提交时间' },
+    // { field: 'conditioningProgramSupplierName', title: '启用状态', slots: { default: 'conditioningProgramSupplierNameCell' } },
+    {
+      field: 'action',
+      title: '操作',
+      align: 'center',
+      width: 150,
+      showOverflow: false,
+      cellRender: {
+        name: 'VxeButtonGroup',
+        props: {
+          mode: 'text',
+        },
+        options: [
+          { content: '查看', status: 'primary', name: 'seeDetail' },
+          // { content: '编辑', status: 'primary', name: 'editConfirmed' },
+          // { content: '删除', status: 'primary', name: 'deleteConfirmed' },
+        ],
+        events: {
+          click({ row, rowIndex }: any, { name }: any) {
+            let method;
+            if (name === 'seeDetail') {
+              method = seeDetail;
+            } else if (name === 'editConfirmed') {
+              method = editConfirmed;
+            } else if (name === 'deleteConfirmed') {
+              method = deleteConfirmed;
+            }
+            method?.(row, rowIndex);
+          },
+        },
+      },
+    },
+  ],
+  data: [],
+});
+const gridEvents: VxeGridListeners = {};
+
+const {
+  loading,
+  page,
+  pageSize,
+  total,
+  onSuccess,
+  replace,
+  refresh,
+  remove,
+  send: sendRefresh,
+} = usePagination((page, size) => pageConfirmedCpMethod(page, size, model.value), {
+  initialData: { data: [], total: 0 },
+  initialPage: 1,
+  initialPageSize: 100,
+  watchingStates: [model],
+  immediate: true,
+});
+onSuccess((res: any) => {
+  gridRef.value?.loadData(res?.data?.data ?? []);
+});
+onMounted(() => {
+  onSearch(toRaw(searchFormProps.data) as any);
+});
+
+function deleteConfirmed(model: SystemItemModel, index: number) {
+  const { name } = model;
+  VxeUI.modal.confirm({
+    title: `删除项目`,
+    content: `确认要删除 ${name} 项目吗?`,
+    showClose: false,
+    onConfirm() {
+      deleteConfirmedCpMethod(model).then(() => {
+        notification.success({
+          message: `删除项目: ${name}`,
+          description: '操作成功',
+        });
+        refresh(page.value);
+      });
+    },
+  });
+}
+
+function editConfirmed(model?: SystemItemModel, index?: number) {
+  const addType = `itemsList`;
+  VxeUI.modal.open({
+    title: model?.conditioningProgramType ?? '项目',
+    fullscreen: true,
+    escClosable: true,
+    destroyOnClose: true,
+    id: `edit-notify-modal`,
+    remember: true,
+    storage: true,
+    slots: {
+      default() {
+        return h(SeeQuestionnaire, <any>{
+          data: {
+            ...model,
+            addType,
+          },
+          onSubmit(data: SystemItemModel) {
+            refresh(page.value);
+            VxeUI.modal.close(`edit-notify-modal`);
+          },
+        });
+      },
+    },
+  });
+}
+
+function seeDetail(model?: SystemItemModel, index?: number) {
+  VxeUI.modal.open({
+    title: model?.name,
+    fullscreen: true,
+    escClosable: true,
+    destroyOnClose: true,
+    id: `see-satisfaction-modal`,
+    remember: true,
+    storage: true,
+    slots: {
+      default() {
+        return h(SeeSatisfaction, <any>{
+          data: model,
+        });
+      },
+    },
+  });
+}
+// 日期验证
+const updateTimeStart = ref<string>('');
+const updateTimeEnd = ref<string>('');
+// 禁用结束时间的日期(早于开始时间的日期)
+function disabledEndDate(current: any) {
+  if (!updateTimeStart.value) return false;
+  return current && current < dayjs(updateTimeStart.value);
+}
+defineExpose({
+  send: sendRefresh,
+});
+</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 #conditioningProgramSupplierNameCell="{ row }">
+          {{ row.conditioningProgramSupplierName == '0' ? '启用' : '禁用' }}
+        </template>
+        <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 {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+.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>

+ 430 - 0
src/satisfaction/SetQuestionnaire.vue

@@ -0,0 +1,430 @@
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue';
+import type { FormInstance } from 'ant-design-vue';
+import { VxeUI } from 'vxe-pc-ui';
+import { PlusOutlined, ArrowLeftOutlined, SaveOutlined } from '@ant-design/icons-vue';
+
+const props = defineProps<{
+  data?: any;
+}>();
+
+const emits = defineEmits<{
+  submit: [data?: any];
+  back: [];
+}>();
+
+const formRef = ref<FormInstance>();
+const loading = ref<boolean>(false);
+
+// 触发条件逻辑类型:且/或
+const triggerLogic = ref<'and' | 'or'>('and');
+
+// 条件类型选项
+const conditionTypeOptions = [
+  { label: '就诊后', value: 'afterVisit' },
+  { label: '患者标签', value: 'patientTag' },
+];
+
+// 操作符选项(用于患者标签)
+// const operatorOptions = [
+//   { label: '包含', value: 'contains' },
+//   { label: '不包含', value: 'notContains' },
+//   { label: '等于', value: 'equals' },
+// ];
+
+// 用户标签选项(示例数据,实际应从API获取)
+// const userTagOptions = ref([
+//   { label: '标签1', value: '1' },
+//   { label: '标签2', value: '2' },
+//   { label: '标签3', value: '3' },
+// ]);
+
+// 触发条件列表
+interface TriggerCondition {
+  id: string;
+  type: string; // 'afterVisit' | 'patientTag'
+  value?: string | string[]; // 就诊后的小时数 或 患者标签数组
+  operator?: string; // 操作符:contains, notContains, equals
+}
+
+const triggerConditions = ref<TriggerCondition[]>([
+  {
+    id: '1',
+    type: 'afterVisit',
+    value: '',
+  },
+  {
+    id: '2',
+    type: 'patientTag',
+    operator: 'contains',
+    value: [],
+  },
+]);
+
+// 表单数据
+const form = reactive({
+  name: '', // 问卷名称
+  enabled: false, // 启用状态
+  notificationChannel: [], // 通知渠道:站内等
+  conditionType: '', // 触发条件类型
+  conditionValue: '', // 触发条件值
+});
+
+// 表单验证规则
+const rules = {
+  // name: [{ required: true, message: '请输入问卷名称', trigger: 'blur' }],
+  enabled: [{ required: true, message: '请选择启用状态', trigger: 'change' }],
+  notificationChannel: [{ required: true, message: '请选择通知渠道', trigger: 'change' }],
+};
+
+// 初始化数据
+onMounted(() => {
+  if (props.data) {
+    Object.assign(form, props.data);
+    if (props.data.triggerLogic) {
+      triggerLogic.value = props.data.triggerLogic;
+    }
+    if (props.data.triggerConditions) {
+      triggerConditions.value = props.data.triggerConditions;
+    }
+  }
+});
+
+// 添加条件行
+const addCondition = () => {
+  const newCondition: TriggerCondition = {
+    id: Date.now().toString(),
+    type: 'patientTag',
+    operator: 'contains',
+    value: [],
+  };
+  triggerConditions.value.push(newCondition);
+};
+
+// 保存
+const handleSave = async () => {
+  try {
+    await formRef.value?.validate();
+    loading.value = true;
+    const submitData = {
+      ...form,
+      triggerLogic: triggerLogic.value,
+      triggerConditions: triggerConditions.value,
+    };
+    emits('submit', submitData);
+  } catch (error) {
+    console.error('表单验证失败', error);
+  } finally {
+    loading.value = false;
+  }
+};
+
+// 返回
+const handleBack = () => {
+  emits('back');
+  VxeUI.modal.close(`edit-notify-modal`);
+};
+</script>
+
+<template>
+  <div class="satisfaction-form-container">
+    <!-- 表单内容 -->
+    <div class="form-content">
+      <a-form ref="formRef" :model="form" :rules="rules" layout="horizontal" :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
+        <!-- 问卷名称和启用 - 同一行 -->
+        <div class="form-row">
+          <a-form-item label="问卷名称" name="name" class="form-row-item">
+            <!-- <a-input v-model:value="form.name" placeholder="请输入" /> -->
+            {{ form.name }}
+          </a-form-item>
+
+          <a-form-item label="启用:" name="enabled" class="form-row-item" required>
+            <a-switch v-model:checked="form.enabled" />
+          </a-form-item>
+        </div>
+
+        <!-- 触发条件 -->
+        <div class="trigger-conditions-section">
+          <div class="flex items-center">
+          <div class="section-label">触发条件:</div>
+
+          <!-- 且/或按钮 -->
+          <!-- <div class="logic-buttons">
+            <button class="logic-btn" :class="{ active: triggerLogic === 'and' }" @click="triggerLogic = 'and'">且</button>
+            <button class="logic-btn" :class="{ active: triggerLogic === 'or' }" @click="triggerLogic = 'or'">或</button>
+          </div> -->
+        </div>
+
+          <!-- 条件列表 -->
+          <div class="condition-row">
+          <!-- <div class="conditions-list condition-row"> -->
+            
+            <!-- <div v-for="(condition, index) in triggerConditions" :key="condition.id" class="condition-row"> -->
+              <!-- <span v-if="index > 0" class="condition-logic-label">{{ triggerLogic === 'and' ? '且' : '或' }}</span> -->
+
+              <!-- 就诊后条件 -->
+              <!-- <template v-if="condition.type === 'afterVisit'"> -->
+                <a-select v-model:value="form.conditionType" :options="conditionTypeOptions" style="width: 200px" class="condition-select" />
+                <a-input v-model:value="form.conditionValue" placeholder="请输入" style="width: 160px" class="condition-input" />
+                <span class="condition-unit">小时</span>
+              <!-- </template> -->
+
+              <!-- 患者标签条件 -->
+              <!-- <template v-else-if="condition.type === 'patientTag'">
+                <a-select v-model:value="condition.type" :options="conditionTypeOptions" style="width: 120px" class="condition-select" />
+                <a-select v-model:value="condition.operator" :options="operatorOptions" style="width: 100px" class="condition-select" />
+                <a-select
+                  v-model:value="condition.value"
+                  mode="multiple"
+                  :options="userTagOptions"
+                  placeholder="请选择标签"
+                  style="width: 200px"
+                  class="condition-select"
+                  :max-tag-count="2"
+                >
+                  <template #tagRender="{ label, closable, onClose }">
+                    <a-tag :closable="closable" @close="onClose" style="margin-right: 3px">
+                      {{ label }}
+                    </a-tag>
+                  </template>
+                </a-select>
+
+                <button v-if="index === triggerConditions.length - 1" class="add-condition-btn" @click="addCondition">
+                  <PlusOutlined />
+                </button>
+              </template> -->
+            <!-- </div> -->
+          </div>
+        </div>
+
+        <!-- 通知渠道 -->
+        <a-form-item label="通知渠道:" name="notificationChannel" class="form-row-item" required>
+          <a-checkbox-group v-model:value="form.notificationChannel">
+            <a-checkbox value="inSite">站内</a-checkbox>
+          </a-checkbox-group>
+        </a-form-item>
+      </a-form>
+    </div>
+
+    <!-- 底部按钮 -->
+    <div class="footer-actions">
+      <vxe-button type="primary" status="primary" @click="handleBack">
+        <template #icon>
+          <ArrowLeftOutlined />
+        </template>
+        取消
+      </vxe-button>
+      <vxe-button
+        type="submit"
+        status="warning"
+        @click="handleSave"
+        :loading="loading"
+      >
+        <template #icon>
+          <SaveOutlined />
+        </template>
+        保存
+      </vxe-button>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.satisfaction-form-container {
+  padding: 24px;
+  background: #fff;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+.form-content {
+  flex: 1;
+  overflow: auto;
+  margin-bottom: 24px;
+}
+
+.form-row {
+  display: flex;
+  gap: 16px;
+  align-items: flex-start;
+  // margin-bottom: 24px;
+}
+
+.form-row-item {
+  flex: 1;
+  margin-bottom: 0;
+}
+
+.form-row-item :deep(.ant-form-item) {
+  margin-bottom: 0;
+  display: flex;
+  flex-direction: column;
+}
+
+.form-row-item :deep(.ant-form-item-label) {
+  flex: 0 0 auto;
+  width: auto;
+  min-width: 80px;
+  text-align: left;
+  padding-right: 8px;
+  padding-bottom: 0;
+}
+
+.form-row-item :deep(.ant-form-item-control) {
+  flex: 1;
+  width: 100%;
+}
+
+// 触发条件部分
+.trigger-conditions-section {
+  margin-bottom: 24px;
+}
+
+.section-label {
+  font-size: 14px;
+  color: rgba(0, 0, 0, 0.85);
+  margin-bottom: 12px;
+  font-weight: 500;
+}
+
+.logic-buttons {
+  display: flex;
+  gap: 8px;
+  margin-bottom: 16px;
+}
+
+.logic-btn {
+  padding: 4px 16px;
+  border: 1px solid #d9d9d9;
+  background: #fff;
+  color: rgba(0, 0, 0, 0.65);
+  cursor: pointer;
+  border-radius: 4px;
+  font-size: 14px;
+  transition: all 0.3s;
+
+  &:hover {
+    border-color: #ff7a45;
+    color: #ff7a45;
+  }
+
+  &.active {
+    background: #ff7a45;
+    border-color: #ff7a45;
+    color: #fff;
+  }
+}
+
+.conditions-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.condition-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+  margin-bottom: 8px;
+  &:first-child {
+    margin-left: 35px;
+  }
+}
+
+.condition-logic-label {
+  color: #ff7a45;
+  font-size: 14px;
+  font-weight: 500;
+  min-width: 24px;
+  margin-right: 4px;
+}
+
+.condition-select,
+.condition-input {
+  flex-shrink: 0;
+}
+
+.condition-unit {
+  font-size: 14px;
+  color: rgba(0, 0, 0, 0.65);
+  margin-left: 4px;
+}
+
+.add-condition-btn {
+  width: 32px;
+  height: 32px;
+  border: 1px dashed #d9d9d9;
+  background: #fff;
+  border-radius: 4px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  color: rgba(0, 0, 0, 0.65);
+  transition: all 0.3s;
+  flex-shrink: 0;
+
+  &:hover {
+    border-color: #ff7a45;
+    color: #ff7a45;
+  }
+}
+
+// 底部按钮
+.footer-actions {
+  display: flex;
+  justify-content: center;
+  gap: 12px;
+  padding-top: 16px;
+  border-top: 1px solid #f0f0f0;
+
+  :deep(.ant-btn-primary) {
+    background: #ff7a45;
+    border-color: #ff7a45;
+
+    &:hover {
+      background: #ff9256;
+      border-color: #ff9256;
+    }
+  }
+
+  :deep(.ant-btn-default) {
+    color: #1890ff;
+    border-color: #1890ff;
+
+    &:hover {
+      color: #40a9ff;
+      border-color: #40a9ff;
+    }
+  }
+}
+
+:deep(.ant-form-item-label > label.ant-form-item-required:not(.ant-form-item-required-mark-optional)) {
+  &::before {
+    display: inline-block;
+    margin-right: 4px;
+    color: #ff4d4f;
+    font-size: 14px;
+    font-family: SimSun, sans-serif;
+    line-height: 1;
+    content: '*';
+  }
+}
+
+:deep(.ant-form-item) {
+  margin-bottom: 24px;
+}
+
+:deep(.ant-input),
+:deep(.ant-select-selector),
+:deep(.ant-picker) {
+  border-color: #d9d9d9;
+}
+
+:deep(.ant-checkbox-group) {
+  display: flex;
+  gap: 16px;
+}
+</style>

+ 267 - 0
src/satisfaction/Statistics.vue

@@ -0,0 +1,267 @@
+<script setup lang="ts">
+import { computed, ref, onMounted } from 'vue';
+import type { Dayjs } from 'dayjs';
+import { use } from 'echarts/core';
+import { BarChart } from 'echarts/charts';
+import { GridComponent, TooltipComponent, TitleComponent } from 'echarts/components';
+import { CanvasRenderer } from 'echarts/renderers';
+import VChart from 'vue-echarts';
+// import { useRequest } from 'alova/client';
+// import type { SatisfactionStatisticItem  } from '@/request/api/satisfaction.api';
+// import { satisfactionStatisticsMethod } from '@/request/api/satisfaction.api';
+
+use([CanvasRenderer, BarChart, GridComponent, TooltipComponent, TitleComponent]);
+
+defineOptions({
+  name: 'SatisfactionStatisticsPage',
+});
+
+type RangeValue = [Dayjs, Dayjs] | null;
+
+const selectedRange = ref<RangeValue>(null);
+const statistics = ref<any[]>([]);
+
+const mockStatistics = [
+  { date: '2024-09-01', score: 5, count: 620 },
+  { date: '2024-09-05', score: 4, count: 140 },
+  { date: '2024-09-06', score: 3, count: 86 },
+  { date: '2024-09-07', score: 2, count: 18 },
+  { date: '2024-09-08', score: 1, count: 6 },
+  { date: '2024-09-10', score: 4, count: 212 },
+  { date: '2024-09-15', score: 5, count: 280 },
+  { date: '2024-09-20', score: 3, count: 35 },
+];
+
+// const { loading, send: fetchStatistics } = useRequest((params?: SatisfactionStatisticsQuery) => satisfactionStatisticsMethod(params), {
+//   immediate: false,
+// });
+
+function normalizeResponse(res: any): any[] {
+  const list = Array.isArray(res?.data)
+    ? res.data
+    : Array.isArray(res)
+      ? res
+      : Array.isArray(res?.records)
+        ? res.records
+        : [];
+  return list.map((item: any & Record<string, any>) => ({
+    score: Number(item.score ?? item.rating ?? item.result ?? 0),
+    count: Number(item.count ?? item.total ?? item.value ?? 0),
+    date: item.date ?? item.sendDate ?? item.createTime ?? item.statDate ?? '',
+  }));
+}
+
+async function loadStatistics(range?: RangeValue) {
+  const params = range
+    ? {
+        startDate: range[0].startOf('day').format('YYYY-MM-DD HH:mm:ss'),
+        endDate: range[1].endOf('day').format('YYYY-MM-DD HH:mm:ss'),
+      }
+    : undefined;
+  try {
+    // const res = await fetchStatistics(params);
+    // const list = normalizeResponse(res);
+    const list = mockStatistics;
+    statistics.value = list.length ? list : mockStatistics;
+  } catch (error) {
+    console.error('获取满意度统计失败,使用本地数据兜底。', error);
+    statistics.value = mockStatistics;
+  }
+}
+
+function handleRangeChange(dates: RangeValue) {
+  selectedRange.value = dates;
+  loadStatistics(dates ?? undefined);
+}
+
+const aggregatedCounts = computed(() => {
+  const map: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
+  statistics.value.forEach((item) => {
+    const score = Number(item.score);
+    if (score >= 1 && score <= 5) {
+      map[score] += Number(item.count ?? 0);
+    }
+  });
+  return map;
+});
+
+const totalSamples = computed(() => Object.values(aggregatedCounts.value).reduce((sum, val) => sum + val, 0));
+
+const chartOption = computed(() => {
+  const categories = ['1', '2', '3', '4', '5'];
+  const seriesData = categories.map((score) => aggregatedCounts.value[Number(score)] ?? 0);
+  return {
+    title: {
+      text: '就诊体验满意度',
+      left: 'left',
+      top: 0,
+      textStyle: { fontSize: 16, fontWeight: 600 },
+    },
+    tooltip: { trigger: 'axis' },
+    grid: { left: 60, right: 40, top: 60, bottom: 50 },
+    xAxis: {
+      type: 'category',
+      data: categories,
+      name: '分',
+      nameGap: 25,
+      axisTick: { alignWithLabel: true },
+      axisLabel: { fontSize: 12 },
+    },
+    yAxis: {
+      type: 'value',
+      name: '人',
+      minInterval: 1,
+      splitLine: {
+        lineStyle: { type: 'dashed', color: '#eaeaea' },
+      },
+      axisLine: { lineStyle: { color: '#d9d9d9' } },
+    },
+    series: [
+      {
+        type: 'bar',
+        data: seriesData,
+        barWidth: 40,
+        itemStyle: {
+          color: '#69c0ff',
+          borderRadius: [6, 6, 0, 0],
+        },
+        emphasis: {
+          focus: 'series',
+          itemStyle: {
+            color: '#4096ff',
+          },
+        },
+        label: {
+          show: true,
+          position: 'top',
+          formatter: '{c}',
+        },
+      },
+    ],
+  };
+});
+
+onMounted(() => loadStatistics());
+
+defineExpose({
+  send: () => loadStatistics(selectedRange.value ?? undefined),
+});
+</script>
+
+<template>
+  <div class="statistics-page">
+    <section class="filter-card">
+      <div class="filter-item">
+        <span class="label">发送日期:</span>
+        <a-range-picker
+          v-model:value="selectedRange"
+          allow-clear
+          format="YYYY-MM-DD"
+          :placeholder="['开始日期', '结束日期']"
+          @change="handleRangeChange"
+        />
+      </div>
+    </section>
+
+    <section class="chart-card">
+      <main>
+        <!-- <a-spin :spinning="loading"> -->
+          <template v-if="totalSamples">
+            <VChart class="chart" :option="chartOption" autoresize />
+            <!-- <v-chart :option="option" style="width: 350px; height: 200px" /> -->
+            <!-- <VChart class="chart" :option="chartOption" autoresize /> -->
+          </template>
+          <a-empty v-else description="暂无数据" />
+        <!-- </a-spin> -->
+      </main>
+    </section>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.statistics-page {
+  padding: 24px;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  // gap: 16px;
+  // background: #f5f6fa;
+  border: 1px solid #f0f0f0;
+}
+
+.filter-card {
+  background: #fff;
+  border-radius: 8px;
+  padding: 16px 24px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  // border: 1px solid #f0f0f0;
+}
+
+.filter-item {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+
+  .label {
+    color: rgba(0, 0, 0, 0.65);
+    font-weight: 500;
+  }
+}
+
+.filter-hint {
+  color: rgba(0, 0, 0, 0.45);
+}
+
+.chart-card {
+  flex: 1;
+  background: #fff;
+  border-radius: 8px;
+  padding: 24px;
+  // border: 1px solid #f0f0f0;
+  display: flex;
+  flex-direction: column;
+
+  header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 12px;
+  }
+
+  main {
+    flex: 1;
+    display: flex;
+    // align-items: center;
+    // justify-content: center;
+  }
+}
+
+.chart-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: rgba(0, 0, 0, 0.85);
+}
+
+.chart-subtitle {
+  font-size: 12px;
+  color: rgba(0, 0, 0, 0.45);
+  margin-top: 4px;
+}
+
+.summary {
+  font-size: 14px;
+  color: rgba(0, 0, 0, 0.65);
+
+  strong {
+    font-size: 20px;
+    color: #1677ff;
+  }
+}
+
+.chart {
+  width: 100%;
+  height: 420px;
+}
+</style>

+ 334 - 0
src/satisfaction/SurveyList.vue

@@ -0,0 +1,334 @@
+<script setup lang="ts">
+import { ref, reactive, shallowRef, onBeforeUnmount, onMounted } from 'vue';
+import type { SystemItemModel, SystemIteQuery } from '@/model/care.model';
+import SetQuestionnaire from '@/satisfaction/SetQuestionnaire.vue';
+import SeeSatisfaction from '@/satisfaction/SeeQuestionnaire.vue';
+import EditQuestionnaire from '@/satisfaction/EditQuestionnaire.vue';
+// 接口数据
+
+import { pageConfirmedCpMethod, deleteConfirmedCpMethod } from '@/request/api/care.api';
+import { usePagination, useRequest } from 'alova/client';
+import { notification } from 'ant-design-vue';
+import dayjs from 'dayjs';
+import { type VxeFormListeners, type VxeFormProps, type VxeGridInstance, type VxeGridListeners, type VxeGridProps, VxeUI } from 'vxe-pc-ui';
+
+const model = shallowRef<SystemIteQuery>();
+const searchFormProps = reactive<VxeFormProps<SystemIteQuery>>({
+  titleWidth: 100,
+  titleAlign: 'right',
+  titleColon: true,
+  data: { types: ['1'] as any, status: '0' as any },
+  items: [
+    {
+      field: 'name',
+      title: '问卷名称',
+      span: 6,
+      itemRender: { name: 'VxeInput', props: { placeholder: '请输入满意度问卷名称' } },
+    },
+    {
+      field: 'sendTime',
+      title: '状态',
+      span: 6,
+      itemRender: {
+        name: 'VxeSelect',
+        props: {
+          placeholder: '请选择状态',
+          options: [
+            { label: '启用', value: '0' },
+            { label: '停用', value: '1' },
+          ],
+        },
+      },
+    },
+    {
+      span: 6,
+      itemRender: {
+        name: 'VxeButtonGroup',
+        options: [
+          { name: 'submits', type: 'submit', content: '查询', status: 'primary' },
+          { name: 'reset', type: 'reset', content: '重置', status: 'warning' },
+          { name: 'add', content: '新增', status: 'primary' },
+        ],
+        events: {
+          click(slotParams: any, { name }: any) {
+            if (name === 'add') {
+              // 新增
+              editQuestionnaire();
+            }
+          },
+        },
+      },
+    },
+  ],
+});
+const searchFormEmits: VxeFormListeners<SystemIteQuery> = {
+  // 查询随访计划
+  submit({ data }) {
+    onSearch(data);
+  },
+
+  // 重置
+  reset({ data }) {
+    onSearch(data);
+  },
+};
+function onSearch(data: SystemIteQuery) {
+  const types: string[] = (data as any).types?.length ? (data as any).types : ['1'];
+  const status: string = (data as any).status ?? '0';
+  const isForWrap: SystemIteQuery['isForWrap'] = types.includes('1') ? 'Y' : null;
+  const isForInfer: SystemIteQuery['isForInfer'] = types.includes('2') ? 'Y' : null;
+
+  model.value = { ...data, types: [...types] as any, status: status as any, isForWrap, isForInfer };
+  nextTick(() => {
+    (searchFormProps.data as any)!.types = [...types];
+    (searchFormProps.data as any)!.status = status;
+    (searchFormProps.data as any)!.isForInfer = isForInfer;
+    (searchFormProps.data as any)!.isForWrap = isForWrap;
+  });
+}
+const gridRef = ref<VxeGridInstance<SystemItemModel>>();
+const gridOptions = reactive<VxeGridProps<SystemItemModel>>({
+  id: 'tag-list',
+  border: true,
+  showOverflow: true,
+  height: 'auto',
+  autoResize: true,
+  syncResize: true,
+  scrollY: { enabled: true, gt: 0 },
+  toolbarConfig: {
+    custom: true,
+    zoom: true,
+    slots: {
+      // buttons: 'handle',
+      tools: 'toolbar-extra',
+    },
+  },
+  columnConfig: {
+    resizable: true,
+  },
+  customConfig: {
+    storage: true,
+  },
+  columns: [
+    { type: 'seq', width: 70, fixed: 'left' },
+    { field: 'name', title: '问卷' },
+    { field: 'conditioningProgramType', title: '通知渠道' },
+    { field: 'cpFixedPricingRule.unitPrice', title: '触发条件' },
+    { field: 'cpFixedPricingRule.pricingUnit', title: '创建时间' },
+    { field: 'cpFixedPricingRule.convertDose', title: '已发条数' },
+    { field: 'conditioningProgramSupplierName', title: '启用状态', slots: { default: 'conditioningProgramSupplierNameCell' } },
+    {
+      field: 'action',
+      title: '操作',
+      align: 'center',
+      width: 180,
+      showOverflow: false,
+      cellRender: {
+        name: 'VxeButtonGroup',
+        props: {
+          mode: 'text',
+        },
+        options: [
+          { content: '查看', status: 'primary', name: 'seeDetail' },
+          { content: '设置', status: 'primary', name: 'setQuestionnaire' },
+        ],
+        events: {
+          click({ row, rowIndex }: any, { name }: any) {
+            let method;
+            if (name === 'seeDetail') {
+              method = seeDetail;
+            } else if (name === 'setQuestionnaire') {
+              method = setQuestionnaire;
+            } else if (name === 'deleteConfirmed') {
+              method = deleteConfirmed;
+            }
+            method?.(row, rowIndex);
+          },
+        },
+      },
+    },
+  ],
+  data: [],
+});
+const gridEvents: VxeGridListeners = {};
+
+const {
+  loading,
+  page,
+  pageSize,
+  total,
+  onSuccess,
+  replace,
+  refresh,
+  remove,
+  send: sendRefresh,
+} = usePagination((page, size) => pageConfirmedCpMethod(page, size, model.value), {
+  initialData: { data: [], total: 0 },
+  initialPage: 1,
+  initialPageSize: 100,
+  watchingStates: [model],
+  immediate: true,
+});
+onSuccess((res: any) => {
+  gridRef.value?.loadData(res?.data?.data ?? []);
+});
+onMounted(() => {
+  onSearch(toRaw(searchFormProps.data) as any);
+});
+
+function deleteConfirmed(model: SystemItemModel, index: number) {
+  const { name } = model;
+  VxeUI.modal.confirm({
+    title: `删除项目`,
+    content: `确认要删除 ${name} 项目吗?`,
+    showClose: false,
+    onConfirm() {
+      deleteConfirmedCpMethod(model).then(() => {
+        notification.success({
+          message: `删除项目: ${name}`,
+          description: '操作成功',
+        });
+        refresh(page.value);
+      });
+    },
+  });
+}
+// 新增满意度问卷
+function editQuestionnaire() {
+  VxeUI.modal.open({
+    title: '新增满意度问卷',
+    fullscreen: true,
+    escClosable: true,
+    destroyOnClose: true,
+    id: `edit-questionnaire-modal`,
+    slots: {
+      default() {
+        return h(EditQuestionnaire
+        , <any>{
+          data: {},
+          onSubmit(data: SystemItemModel) {
+            refresh(page.value);
+            VxeUI.modal.close(`edit-questionnaire-modal`);
+          },
+        });
+      },
+    },
+  });
+}
+// 设置满意度问卷
+function setQuestionnaire(model?: SystemItemModel, index?: number) {
+  const addType = `itemsList`;
+  VxeUI.modal.open({
+    title: model?.conditioningProgramType ?? '项目',
+    fullscreen: true,
+    escClosable: true,
+    destroyOnClose: true,
+    id: `edit-notify-modal`,
+    remember: true,
+    storage: true,
+    slots: {
+      default() {
+        return h(SetQuestionnaire, <any>{
+          data: {
+            ...model,
+            addType,
+          },
+          onSubmit(data: SystemItemModel) {
+            refresh(page.value);
+            VxeUI.modal.close(`edit-notify-modal`);
+          },
+        });
+      },
+    },
+  });
+}
+
+function seeDetail(model?: SystemItemModel, index?: number) {
+  VxeUI.modal.open({
+    title: model?.name,
+    fullscreen: true,
+    escClosable: true,
+    destroyOnClose: true,
+    id: `see-satisfaction-modal`,
+    remember: true,
+    storage: true,
+    slots: {
+      default() {
+        return h(SeeSatisfaction, <any>{
+          data: model,
+        });
+      },
+    },
+  });
+}
+// 日期验证
+const updateTimeStart = ref<string>('');
+const updateTimeEnd = ref<string>('');
+// 禁用结束时间的日期(早于开始时间的日期)
+function disabledEndDate(current: any) {
+  if (!updateTimeStart.value) return false;
+  return current && current < dayjs(updateTimeStart.value);
+}
+defineExpose({
+  send: sendRefresh,
+});
+</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 #conditioningProgramSupplierNameCell="{ row }">
+          {{ row.conditioningProgramSupplierName == '0' ? '启用' : '禁用' }}
+        </template>
+        <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 {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+.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>

+ 304 - 0
src/service/NotifyManageList.vue

@@ -0,0 +1,304 @@
+<script setup lang="ts">
+import { ref, reactive, shallowRef, onBeforeUnmount, onMounted } from 'vue';
+import type { SystemItemModel, SystemIteQuery } from '@/model/care.model';
+import EditNotify from '@/components/EditNotify.vue';
+// 接口数据
+
+import { pageConfirmedCpMethod, deleteConfirmedCpMethod } from '@/request/api/care.api';
+import { usePagination, useRequest } from 'alova/client';
+import { notification } from 'ant-design-vue';
+import dayjs from 'dayjs';
+import { type VxeFormListeners, type VxeFormProps, type VxeGridInstance, type VxeGridListeners, type VxeGridProps, VxeUI } from 'vxe-pc-ui';
+
+const model = shallowRef<SystemIteQuery>();
+const searchFormProps = reactive<VxeFormProps<SystemIteQuery>>({
+  titleWidth: 100,
+  titleAlign: 'right',
+  titleColon: true,
+  data: { types: ['1'] as any, status: '0' as any },
+  items: [
+    {
+      field: 'name',
+      title: '名称',
+      span: 6,
+      itemRender: { name: 'VxeInput', props: { placeholder: '请输入通知名称' } },
+    },
+    {
+      field: 'sendTime',
+      title: '发送时间',
+      span: 6,
+      slots: {
+        default: 'createTimes',
+      },
+    },
+    {
+      span: 6,
+      itemRender: {
+        name: 'VxeButtonGroup',
+        options: [
+          { name: 'submits', type: 'submit', content: '查询', status: 'primary' },
+          { name: 'reset', type: 'reset', content: '重置', status: 'warning' },
+          { name: 'add', content: '新增', status: 'primary' },
+        ],
+        events: {
+          click(slotParams: any, { name }: any) {
+            if (name === 'add') {
+              // 新增
+              editConfirmed();
+            }
+          },
+        },
+      },
+    },
+  ],
+});
+const searchFormEmits: VxeFormListeners<SystemIteQuery> = {
+  // 查询随访计划
+  submit({ data }) {
+    onSearch(data);
+  },
+
+  // 重置
+  reset({ data }) {
+    onSearch(data);
+  },
+};
+function onSearch(data: SystemIteQuery) {
+  const types: string[] = (data as any).types?.length ? (data as any).types : ['1'];
+  const status: string = (data as any).status ?? '0';
+  const isForWrap: SystemIteQuery['isForWrap'] = types.includes('1') ? 'Y' : null;
+  const isForInfer: SystemIteQuery['isForInfer'] = types.includes('2') ? 'Y' : null;
+
+  model.value = { ...data, types: [...types] as any, status: status as any, isForWrap, isForInfer };
+  nextTick(() => {
+    (searchFormProps.data as any)!.types = [...types];
+    (searchFormProps.data as any)!.status = status;
+    (searchFormProps.data as any)!.isForInfer = isForInfer;
+    (searchFormProps.data as any)!.isForWrap = isForWrap;
+  });
+}
+const gridRef = ref<VxeGridInstance<SystemItemModel>>();
+const gridOptions = reactive<VxeGridProps<SystemItemModel>>({
+  id: 'tag-list',
+  border: true,
+  showOverflow: true,
+  height: 'auto',
+  autoResize: true,
+  syncResize: true,
+  scrollY: { enabled: true, gt: 0 },
+  toolbarConfig: {
+    custom: true,
+    zoom: true,
+    slots: {
+      // buttons: 'handle',
+      tools: 'toolbar-extra',
+    },
+  },
+  columnConfig: {
+    resizable: true,
+  },
+  customConfig: {
+    storage: true,
+  },
+  columns: [
+    { type: 'seq', width: 70, fixed: 'left' },
+    { field: 'name', title: '名称' },
+    { field: 'conditioningProgramType', title: '通知渠道' },
+    { field: 'cpFixedPricingRule.unitPrice', title: '用户标签'},
+    { field: 'cpFixedPricingRule.pricingUnit', title: '发送时间'},
+    { field: 'cpFixedPricingRule.convertDose', title: '已发条数' },
+    { field: 'conditioningProgramSupplierName', title: '启用状态', slots: { default: 'conditioningProgramSupplierNameCell' } },
+    {
+      field: 'action',
+      title: '操作',
+      align: 'center',
+      width: 180,
+      showOverflow: false,
+      cellRender: {
+        name: 'VxeButtonGroup',
+        props: {
+          mode: 'text',
+        },
+        options: [
+          { content: '查看', status: 'primary', name: 'seeDetail' },
+          { content: '编辑', status: 'primary', name: 'editConfirmed' },
+          { content: '删除', status: 'primary', name: 'deleteConfirmed' },
+        ],
+        events: {
+          click({ row, rowIndex }: any, { name }: any) {
+            let method;
+            if (name === 'seeDetail') {
+              method = seeDetail;
+            } else if (name === 'editConfirmed') {
+              method = editConfirmed;
+            } else if (name === 'deleteConfirmed') {
+              method = deleteConfirmed;
+            }
+            method?.(row, rowIndex);
+          },
+        },
+      },
+    },
+  ],
+  data: [],
+});
+const gridEvents: VxeGridListeners = {};
+
+const {
+  loading,
+  page,
+  pageSize,
+  total,
+  onSuccess,
+  replace,
+  refresh,
+  remove,
+  send: sendRefresh,
+} = usePagination((page, size) => pageConfirmedCpMethod(page, size, model.value), {
+  initialData: { data: [], total: 0 },
+  initialPage: 1,
+  initialPageSize: 100,
+  watchingStates: [model],
+  immediate: true,
+});
+onSuccess((res: any) => {
+  gridRef.value?.loadData(res?.data?.data ?? []);
+});
+onMounted(() => {
+  onSearch(toRaw(searchFormProps.data) as any);
+});
+
+function deleteConfirmed(model: SystemItemModel, index: number) {
+  const { name } = model;
+  VxeUI.modal.confirm({
+    title: `删除项目`,
+    content: `确认要删除 ${name} 项目吗?`,
+    showClose: false,
+    onConfirm() {
+      deleteConfirmedCpMethod(model).then(() => {
+        notification.success({
+          message: `删除项目: ${name}`,
+          description: '操作成功',
+        });
+        refresh(page.value);
+      });
+    },
+  });
+}
+
+function editConfirmed(model?: SystemItemModel, index?: number) {
+  const addType = `itemsList`;
+  VxeUI.modal.open({
+    title: model?.conditioningProgramType ?? '项目',
+    fullscreen: true,
+    escClosable: true,
+    destroyOnClose: true,
+    id: `edit-notify-modal`,
+    remember: true,
+    storage: true,
+    slots: {
+      default() {
+        return h(EditNotify, <any>{
+          data: {
+            ...model,
+            addType,
+          },
+          onSubmit(data: SystemItemModel) {
+            refresh(page.value);
+            VxeUI.modal.close(`edit-notify-modal`);
+          },
+        });
+      },
+    },
+  });
+}
+
+function seeDetail(model?: SystemItemModel, index?: number) {
+  // VxeUI.modal.open({
+  //   title: model?.name,
+  //   height: 500,
+  //   width: 750,
+  //   id: `see-notify-modal`,
+  //   remember: true,
+  //   storage: true,
+  //   slots: {
+  //     default() {
+  //       return h(SeeNotify, <any>{
+  //         data: model,
+  //       });
+  //     },
+  //   },
+  // });
+}
+// 日期验证
+const updateTimeStart = ref<string>('');
+const updateTimeEnd = ref<string>('');
+// 禁用结束时间的日期(早于开始时间的日期)
+function disabledEndDate(current: any) {
+  if (!updateTimeStart.value) return false;
+  return current && current < dayjs(updateTimeStart.value);
+}
+defineExpose({
+  send: sendRefresh,
+});
+</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 #conditioningProgramSupplierNameCell="{ row }">
+          {{ row.conditioningProgramSupplierName == '0' ? '启用' : '禁用' }}
+        </template>
+        <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 {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+.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>

+ 360 - 0
src/service/NotifyManageRecord.vue

@@ -0,0 +1,360 @@
+<script setup lang="ts">
+import type { SystemItemModel, SystemIteQuery } from '@/model/care.model';
+import ServiceDetail from './ServiceDetail.vue';
+import AddItems from './AddItems.vue';
+import seeHealthEvaluation from './seeHealthEvaluation.vue';
+import HealthEvaluation from './HealthEvaluation.vue';
+import dayjs from 'dayjs';
+// 接口数据
+
+import { pageConfirmedCpMethod, deleteConfirmedCpMethod } from '@/request/api/care.api';
+import { usePagination, useRequest } from 'alova/client';
+import { notification } from 'ant-design-vue';
+
+import { type VxeFormListeners, type VxeFormProps, type VxeGridInstance, type VxeGridListeners, type VxeGridProps, VxeUI } from 'vxe-pc-ui';
+
+const model = shallowRef<SystemIteQuery>();
+const searchFormProps = reactive<VxeFormProps<SystemIteQuery>>({
+  titleWidth: 100,
+  titleAlign: 'right',
+  titleColon: true,
+  data: { types: ['1'] as any, status: '0' as any },
+  items: [
+  {
+      field: 'name',
+      title: '名称',
+      span: 6,
+      itemRender: { name: 'VxeInput', props: { placeholder: '请输入通知名称' } },
+    },
+    {
+      field: 'sendTime',
+      title: '发送时间',
+      span: 6,
+      slots: {
+        default: 'createTimes',
+      },
+    },
+    {
+      span: 6,
+      itemRender: {
+        name: 'VxeButtonGroup',
+        options: [
+          { name: 'submits', type: 'submit', content: '查询', status: 'primary' },
+          { name: 'reset', type: 'reset', content: '重置', status: 'warning' },
+          // { name: 'add', content: '新增', status: 'primary' },
+        ],
+        events: {
+          click(slotParams: any, { name }: any) {
+            if (name === 'add') {
+              // 新增
+              editConfirmed();
+            }
+          },
+        },
+      },
+    },
+   
+  ],
+});
+const searchFormEmits: VxeFormListeners<SystemIteQuery> = {
+  // 查询随访计划
+  submit({ data }) {
+    onSearch(data);
+  },
+  
+  // 重置
+  reset({ data }) {
+    onSearch(data);
+  },
+};
+function onSearch(data: SystemIteQuery) {
+  const types: string[] = (data as any).types?.length ? (data as any).types : ['1'];
+  const status: string = (data as any).status ?? '0';
+  const isForWrap: SystemIteQuery['isForWrap'] = types.includes('1') ? 'Y' : null;
+  const isForInfer: SystemIteQuery['isForInfer'] = types.includes('2') ? 'Y' : null;
+
+  model.value = { ...data, types: [...types] as any, status: status as any, isForWrap, isForInfer };
+  nextTick(() => {
+    (searchFormProps.data as any)!.types = [...types];
+    (searchFormProps.data as any)!.status = status;
+    (searchFormProps.data as any)!.isForInfer = isForInfer;
+    (searchFormProps.data as any)!.isForWrap = isForWrap;
+  })
+}
+const gridRef = ref<VxeGridInstance<SystemItemModel>>();
+const gridOptions = reactive<VxeGridProps<SystemItemModel>>({
+  id: 'tag-list',
+  border: true,
+  showOverflow: true,
+  height: 'auto',
+  autoResize: true,
+  syncResize: true,
+  scrollY: { enabled: true, gt: 0 },
+  toolbarConfig: {
+    custom: true,
+    zoom: true,
+    slots: {
+      // buttons: 'handle',
+      tools: 'toolbar-extra',
+    },
+  },
+  columnConfig: {
+    resizable: true,
+  },
+  customConfig: {
+    storage: true,
+  },
+  columns: [
+    { type: 'seq', width: 70, fixed: 'left' },
+    { field: 'name', title: '名称' },
+    { field: 'conditioningProgramType', title: '用户ID' },
+    { field: 'cpFixedPricingRule.unitPrice', title: '用户姓名', slots: { default: 'unitPriceCell' } },
+    { field: 'cpFixedPricingRule.pricingUnit', title: '手机号码', slots: { default: 'pricingUnitCell' } },
+    { field: 'conditioningProgramSupplierName', title: '发送时间' },
+  
+  ],
+  data: [],
+});
+const gridEvents: VxeGridListeners = {};
+
+const {
+  loading,
+  page,
+  pageSize,
+  total,
+  onSuccess,
+  replace,
+  refresh,
+  remove,
+  send: sendRefresh,
+} = usePagination((page, size) => pageConfirmedCpMethod(page, size, model.value), {
+  initialData: { data: [], total: 0 },
+  initialPage: 1,
+  initialPageSize: 100,
+  watchingStates: [model],
+  immediate: true,
+});
+onSuccess((res: any) => {
+  gridRef.value?.loadData(res?.data?.data ?? []);
+});
+onMounted(() => {
+  onSearch(toRaw(searchFormProps.data) as any);
+});
+
+function deleteConfirmed(model: SystemItemModel, index: number) {
+  const { name } = model;
+  VxeUI.modal.confirm({
+    title: `删除项目`,
+    content: `确认要删除 ${name} 项目吗?`,
+    showClose: false,
+    onConfirm() {
+      deleteConfirmedCpMethod(model).then(() => {
+        notification.success({
+          message: `删除项目: ${name}`,
+          description: '操作成功',
+        });
+        refresh(page.value);
+      });
+    },
+  });
+}
+
+function editConfirmed(model?: SystemItemModel, index?: number) {
+  const addType = `itemsList`;
+  if (model?.name === '健康咨询' || model?.name === '健康评估') {
+    VxeUI.modal.open({
+      title: model?.conditioningProgramType ?? '项目',
+      height: 400,
+      width: 750,
+      id: `health-consultation-modal`,
+      remember: true,
+      storage: true,
+      slots: {
+        default() {
+          return h(HealthEvaluation, <any>{
+            data: {
+              ...model,
+              addType,
+            },
+            onSubmit(data: SystemItemModel) {
+              refresh(page.value);
+              VxeUI.modal.close(`health-consultation-modal`);
+            },
+          });
+        },
+      },
+    });
+  } else {
+    VxeUI.modal.open({
+      title: model?.id ? `编辑项目` : `新增项目`,
+      height: 800,
+      width: 850,
+      // position: {
+      //   top: Math.min(100, window.innerHeight * 0.1),
+      // },
+      escClosable: true,
+      destroyOnClose: true,
+      id: `add-items-modal`,
+      remember: true,
+      storage: true,
+      slots: {
+        default() {
+          return h(AddItems, <any>{
+            data: { ...model, addType },
+            onSubmit(data: SystemItemModel) {
+              refresh(page.value);
+              VxeUI.modal.close(`add-items-modal`);
+            },
+          });
+        },
+      },
+    });
+  }
+}
+
+function seeDetail(model?: SystemItemModel, index?: number) {
+  if (model?.name === '健康咨询' || model?.name === '健康评估') {
+    VxeUI.modal.open({
+      title: model?.conditioningProgramType,
+      height: 500,
+      width: 750,
+      id: `see-health-evaluation-modal`,
+      remember: true,
+      storage: true,
+      slots: {
+        default() {
+          return h(seeHealthEvaluation, <any>{
+            data: model,
+          });
+        },
+      },
+    });
+  } else {
+    const addType = 'itemsList';
+    VxeUI.modal.open({
+      title: '查看',
+      height: 800,
+      width: 950,
+      escClosable: true,
+      destroyOnClose: true,
+      id: `service-detail-modal`,
+      remember: true,
+      storage: true,
+      slots: {
+        default() {
+          return h(ServiceDetail, <any>{
+            data: {
+              ...model,
+              addType,
+            },
+            onSubmit(data: SystemItemModel) {
+              refresh(page.value);
+              VxeUI.modal.close(`service-detail-modal`);
+            },
+          });
+        },
+      },
+    });
+  }
+}
+// 日期验证
+const updateTimeStart = ref<string>('');
+const updateTimeEnd = ref<string>('');
+// 禁用结束时间的日期(早于开始时间的日期)
+function disabledEndDate(current: any) {
+  if (!updateTimeStart.value) return false;
+  return current && current < dayjs(updateTimeStart.value);
+}
+defineExpose({
+  send: sendRefresh,
+});
+</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 #isForWrapCell="{ row }">
+          {{
+            (() => {
+              const isWrap = row.isForWrap === 'Y';
+              const isInfer = row.isForInfer === 'Y';
+              if (isWrap && isInfer) {
+                return '服务包项目;调理方案项目';
+              } else if (isWrap) {
+                return '服务包项目';
+              } else if (isInfer) {
+                return '调理方案项目';
+              } else {
+                return '';
+              }
+            })()
+          }}
+        </template>
+        <template #pricingUnitCell="{ row }">
+          {{ row.cpFixedPricingRule?.pricingUnit ? row.cpFixedPricingRule?.pricingUnit : '次' }}
+        </template>
+        <template #unitPriceCell="{ row }">
+          {{ row.pricingType === '1' ? `` : row.cpFixedPricingRule?.unitPrice }}
+        </template>
+        <template #convertDoseCell="{ row }">
+          {{
+            row.pricingType === '1'
+              ? `当"穴位/经络/部位 ≤${row?.cpDynamicPricingRule ? row?.cpDynamicPricingRule[1]?.max || 0 : 0}个时,
+          单价为${row?.cpDynamicPricingRule ? row?.cpDynamicPricingRule[0]?.price || 0 : 0}元,
+          当"穴位/经络/部位 >${row?.cpDynamicPricingRule ? row?.cpDynamicPricingRule[1]?.max || 0 : 0}个时,
+          单价为${row?.cpDynamicPricingRule ? row?.cpDynamicPricingRule[1]?.price || 0 : 0}元`
+              : ''
+          }}
+        </template>
+        <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 {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+.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>

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません