Jelajahi Sumber

Merge branch 'feature/随访管理' into develop

cc12458 1 tahun lalu
induk
melakukan
edd8aad1e5

+ 11 - 0
@types/components.d.ts

@@ -26,16 +26,25 @@ declare module 'vue' {
     AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
     AInputSearch: typeof import('ant-design-vue/es')['InputSearch']
     AMenu: typeof import('ant-design-vue/es')['Menu']
+    AModal: typeof import('ant-design-vue/es')['Modal']
     APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
+    ARadio: typeof import('ant-design-vue/es')['Radio']
+    ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
     AResult: typeof import('ant-design-vue/es')['Result']
     ARow: typeof import('ant-design-vue/es')['Row']
     ASelect: typeof import('ant-design-vue/es')['Select']
     ASpace: typeof import('ant-design-vue/es')['Space']
     ASpaceCompact: typeof import('ant-design-vue/es')['Compact']
     ASpin: typeof import('ant-design-vue/es')['Spin']
+    ASteps: typeof import('ant-design-vue/es')['Steps']
     ATag: typeof import('ant-design-vue/es')['Tag']
     ATextarea: typeof import('ant-design-vue/es')['Textarea']
     ATooltip: typeof import('ant-design-vue/es')['Tooltip']
+    AUpload: typeof import('ant-design-vue/es')['Upload']
+    Enabled: typeof import('./../src/components/Enabled.vue')['default']
+    Evaluation: typeof import('./../src/components/Evaluation.vue')['default']
+    Follow: typeof import('./../src/components/Follow.vue')['default']
+    NestedRadio: typeof import('./../src/components/NestedRadio.vue')['default']
     RecordsAnalysisPreview: typeof import('./../src/components/RecordsAnalysisPreview.vue')['default']
     RecordsIndicatorPreview: typeof import('./../src/components/RecordsIndicatorPreview.vue')['default']
     RecordsPatientPreview: typeof import('./../src/components/RecordsPatientPreview.vue')['default']
@@ -48,10 +57,12 @@ declare module 'vue' {
     RoleEdit: typeof import('./../src/components/RoleEdit.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
+    Swiper: typeof import('./../src/components/Swiper.vue')['default']
     TagEdit: typeof import('./../src/components/TagEdit.vue')['default']
     UserEdit: typeof import('./../src/components/UserEdit.vue')['default']
     UserPassword: typeof import('./../src/components/UserPassword.vue')['default']
     UserPreview: typeof import('./../src/components/UserPreview.vue')['default']
     UserQRCode: typeof import('./../src/components/UserQRCode.vue')['default']
+    ViewsEvaluation: typeof import('./../src/components/ViewsEvaluation.vue')['default']
   }
 }

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

@@ -19,11 +19,17 @@ declare module 'vue-router/auto-routes' {
    */
   export interface RouteNamedMap {
     '/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
+    '//follow/assessment': RouteRecordInfo<'//follow/assessment', '/follow/assessment', Record<never, never>, Record<never, never>>,
+    '//follow/plan': RouteRecordInfo<'//follow/plan', '/follow/plan', Record<never, never>, Record<never, never>>,
+    '//follow/task': RouteRecordInfo<'//follow/task', '/follow/task', Record<never, never>, Record<never, never>>,
     '//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>>,
     '//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>>,
+    '//tcmRecuperation/institution': RouteRecordInfo<'//tcmRecuperation/institution', '/tcmRecuperation/institution', Record<never, never>, Record<never, never>>,
+    '//tcmRecuperation/preserve': RouteRecordInfo<'//tcmRecuperation/preserve', '/tcmRecuperation/preserve', Record<never, never>, Record<never, never>>,
+    '//tcmRecuperation/system': RouteRecordInfo<'//tcmRecuperation/system', '/tcmRecuperation/system', Record<never, never>, Record<never, never>>,
     '/login': RouteRecordInfo<'/login', '/login', Record<never, never>, Record<never, never>>,
   }
 }

+ 1 - 0
package.json

@@ -24,6 +24,7 @@
     "echarts": "^5.5.1",
     "pinia": "^2.1.7",
     "pinia-plugin-persistedstate": "^3.2.1",
+    "swiper": "^8.4.7",
     "v-selectpage": "^3.0.1",
     "vue": "^3.4.29",
     "vue-echarts": "^7.0.2",

+ 41 - 0
pnpm-lock.yaml

@@ -41,6 +41,9 @@ importers:
       pinia-plugin-persistedstate:
         specifier: ^3.2.1
         version: 3.2.3(pinia@2.3.1(typescript@5.4.5)(vue@3.5.13(typescript@5.4.5)))
+      swiper:
+        specifier: ^8.4.7
+        version: 8.4.7
       v-selectpage:
         specifier: ^3.0.1
         version: 3.0.1(vue@3.5.13(typescript@5.4.5))
@@ -875,36 +878,42 @@ packages:
     engines: {node: '>= 10.0.0'}
     cpu: [arm]
     os: [linux]
+    libc: [glibc]
 
   '@parcel/watcher-linux-arm-musl@2.5.1':
     resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
     engines: {node: '>= 10.0.0'}
     cpu: [arm]
     os: [linux]
+    libc: [musl]
 
   '@parcel/watcher-linux-arm64-glibc@2.5.1':
     resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
     engines: {node: '>= 10.0.0'}
     cpu: [arm64]
     os: [linux]
+    libc: [glibc]
 
   '@parcel/watcher-linux-arm64-musl@2.5.1':
     resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
     engines: {node: '>= 10.0.0'}
     cpu: [arm64]
     os: [linux]
+    libc: [musl]
 
   '@parcel/watcher-linux-x64-glibc@2.5.1':
     resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
     engines: {node: '>= 10.0.0'}
     cpu: [x64]
     os: [linux]
+    libc: [glibc]
 
   '@parcel/watcher-linux-x64-musl@2.5.1':
     resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
     engines: {node: '>= 10.0.0'}
     cpu: [x64]
     os: [linux]
+    libc: [musl]
 
   '@parcel/watcher-win32-arm64@2.5.1':
     resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
@@ -978,56 +987,67 @@ packages:
     resolution: {integrity: sha512-mimPH43mHl4JdOTD7bUMFhBdrg6f9HzMTOEnzRmXbOZqjijCw8LA5z8uL6LCjxSa67H2xiLFvvO67PT05PRKGg==}
     cpu: [arm]
     os: [linux]
+    libc: [glibc]
 
   '@rollup/rollup-linux-arm-musleabihf@4.38.0':
     resolution: {integrity: sha512-tPiJtiOoNuIH8XGG8sWoMMkAMm98PUwlriOFCCbZGc9WCax+GLeVRhmaxjJtz6WxrPKACgrwoZ5ia/uapq3ZVg==}
     cpu: [arm]
     os: [linux]
+    libc: [musl]
 
   '@rollup/rollup-linux-arm64-gnu@4.38.0':
     resolution: {integrity: sha512-wZco59rIVuB0tjQS0CSHTTUcEde+pXQWugZVxWaQFdQQ1VYub/sTrNdY76D1MKdN2NB48JDuGABP6o6fqos8mA==}
     cpu: [arm64]
     os: [linux]
+    libc: [glibc]
 
   '@rollup/rollup-linux-arm64-musl@4.38.0':
     resolution: {integrity: sha512-fQgqwKmW0REM4LomQ+87PP8w8xvU9LZfeLBKybeli+0yHT7VKILINzFEuggvnV9M3x1Ed4gUBmGUzCo/ikmFbQ==}
     cpu: [arm64]
     os: [linux]
+    libc: [musl]
 
   '@rollup/rollup-linux-loongarch64-gnu@4.38.0':
     resolution: {integrity: sha512-hz5oqQLXTB3SbXpfkKHKXLdIp02/w3M+ajp8p4yWOWwQRtHWiEOCKtc9U+YXahrwdk+3qHdFMDWR5k+4dIlddg==}
     cpu: [loong64]
     os: [linux]
+    libc: [glibc]
 
   '@rollup/rollup-linux-powerpc64le-gnu@4.38.0':
     resolution: {integrity: sha512-NXqygK/dTSibQ+0pzxsL3r4Xl8oPqVoWbZV9niqOnIHV/J92fe65pOir0xjkUZDRSPyFRvu+4YOpJF9BZHQImw==}
     cpu: [ppc64]
     os: [linux]
+    libc: [glibc]
 
   '@rollup/rollup-linux-riscv64-gnu@4.38.0':
     resolution: {integrity: sha512-GEAIabR1uFyvf/jW/5jfu8gjM06/4kZ1W+j1nWTSSB3w6moZEBm7iBtzwQ3a1Pxos2F7Gz+58aVEnZHU295QTg==}
     cpu: [riscv64]
     os: [linux]
+    libc: [glibc]
 
   '@rollup/rollup-linux-riscv64-musl@4.38.0':
     resolution: {integrity: sha512-9EYTX+Gus2EGPbfs+fh7l95wVADtSQyYw4DfSBcYdUEAmP2lqSZY0Y17yX/3m5VKGGJ4UmIH5LHLkMJft3bYoA==}
     cpu: [riscv64]
     os: [linux]
+    libc: [musl]
 
   '@rollup/rollup-linux-s390x-gnu@4.38.0':
     resolution: {integrity: sha512-Mpp6+Z5VhB9VDk7RwZXoG2qMdERm3Jw07RNlXHE0bOnEeX+l7Fy4bg+NxfyN15ruuY3/7Vrbpm75J9QHFqj5+Q==}
     cpu: [s390x]
     os: [linux]
+    libc: [glibc]
 
   '@rollup/rollup-linux-x64-gnu@4.38.0':
     resolution: {integrity: sha512-vPvNgFlZRAgO7rwncMeE0+8c4Hmc+qixnp00/Uv3ht2x7KYrJ6ERVd3/R0nUtlE6/hu7/HiiNHJ/rP6knRFt1w==}
     cpu: [x64]
     os: [linux]
+    libc: [glibc]
 
   '@rollup/rollup-linux-x64-musl@4.38.0':
     resolution: {integrity: sha512-q5Zv+goWvQUGCaL7fU8NuTw8aydIL/C9abAVGCzRReuj5h30TPx4LumBtAidrVOtXnlB+RZkBtExMsfqkMfb8g==}
     cpu: [x64]
     os: [linux]
+    libc: [musl]
 
   '@rollup/rollup-win32-arm64-msvc@4.38.0':
     resolution: {integrity: sha512-u/Jbm1BU89Vftqyqbmxdq14nBaQjQX1HhmsdBWqSdGClNaKwhjsg5TpW+5Ibs1mb8Es9wJiMdl86BcmtUVXNZg==}
@@ -1601,6 +1621,9 @@ packages:
   dom-zindex@1.0.6:
     resolution: {integrity: sha512-FKWIhiU96bi3xpP9ewRMgANsoVmMUBnMnmpCT6dPMZOunVYJQmJhSRruoI0XSPoHeIif3kyEuiHbFrOJwEJaEA==}
 
+  dom7@4.0.6:
+    resolution: {integrity: sha512-emjdpPLhpNubapLFdjNL9tP06Sr+GZkrIHEXLWvOGsytACUrkbeIdjO5g77m00BrHTznnlcNqgmn7pCN192TBA==}
+
   duplexer@0.1.2:
     resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
 
@@ -2350,6 +2373,9 @@ packages:
     resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
     engines: {node: '>=0.10.0'}
 
+  ssr-window@4.0.2:
+    resolution: {integrity: sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ==}
+
   strip-ansi@6.0.1:
     resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
     engines: {node: '>=8'}
@@ -2376,6 +2402,10 @@ packages:
     resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
     engines: {node: '>=8'}
 
+  swiper@8.4.7:
+    resolution: {integrity: sha512-VwO/KU3i9IV2Sf+W2NqyzwWob4yX9Qdedq6vBtS0rFqJ6Fa5iLUJwxQkuD4I38w0WDJwmFl8ojkdcRFPHWD+2g==}
+    engines: {node: '>= 4.7.0'}
+
   synckit@0.10.3:
     resolution: {integrity: sha512-R1urvuyiTaWfeCggqEvpDJwAlDVdsT9NM+IP//Tk2x7qHCkSvBk/fwFgw/TLAHzZlrAnnazMcRw0ZD8HlYFTEQ==}
     engines: {node: ^14.18.0 || >=16.0.0}
@@ -4128,6 +4158,10 @@ snapshots:
 
   dom-zindex@1.0.6: {}
 
+  dom7@4.0.6:
+    dependencies:
+      ssr-window: 4.0.2
+
   duplexer@0.1.2: {}
 
   echarts@5.6.0:
@@ -4924,6 +4958,8 @@ snapshots:
 
   speakingurl@14.0.1: {}
 
+  ssr-window@4.0.2: {}
+
   strip-ansi@6.0.1:
     dependencies:
       ansi-regex: 5.0.1
@@ -4946,6 +4982,11 @@ snapshots:
     dependencies:
       has-flag: 4.0.0
 
+  swiper@8.4.7:
+    dependencies:
+      dom7: 4.0.6
+      ssr-window: 4.0.2
+
   synckit@0.10.3:
     dependencies:
       '@pkgr/core': 0.2.0

+ 505 - 0
src/components/Enabled.vue

@@ -0,0 +1,505 @@
+<script setup lang="ts">
+import type { PlanModel } from '@/model/system.model';
+import { tagEditMethod, tagMethod } from '@/request/api/system.api';
+import { message } from 'ant-design-vue';
+import {
+  plansSearchMethod,
+  planMethod,
+  planUpdateStatusMethod,
+  doctorMethod,
+  departmentsMethod,
+  planEditMethod,
+  tagsSearchMethod,
+  FollowupTaskMethod,
+} from '@/request/api/follow.api';
+import { useRequest } from 'alova/client';
+import {
+  type VxeFormListeners,
+  type VxeFormProps,
+  type VxeGridProps,
+  type VxeGridListeners,
+  VxeUI,
+  type VxeFormInstance,
+} from 'vxe-pc-ui';
+
+type FollowModel = Partial<PlanModel>;
+
+const defaultModel = {};
+
+const props = defineProps<{ data: FollowModel }>();
+
+const emits = defineEmits<{
+  submit: [data?: PlanModel];
+}>();
+
+const { loading, send: load } = useRequest(tagMethod, {
+  immediate: false,
+  initialData: props.data ?? defaultModel,
+}).onSuccess(({ data }) => {
+  formProps.data = { ...data };
+});
+const { loading: submitting, send: submit } = useRequest(planEditMethod, {
+  immediate: false,
+}).onSuccess(({ data }) => {
+  emits('submit');
+});
+// const { data: tags, loading: tagsLoading } = useRequest(planMethod, {
+//   initialData: { total: 0, data: [] },
+// });
+// 获取就诊医生
+const { data: doctorData, loading: doctorDataLoading } = useRequest(doctorMethod, {
+  initialData: { total: 0, data: [] },
+});
+// 获取就诊科室
+const { data: departmentsData, loading: depDataLoading } = useRequest(departmentsMethod, {
+  initialData: { total: 0, data: [] },
+});
+// 获取患者标签
+const { data: tagData, loading: tagDataLoading } = useRequest(tagsSearchMethod, {
+  initialData: { total: 0, data: [] },
+});
+
+// 第一步 填写随访计划
+const formProps = reactive<VxeFormProps<FollowModel>>({
+  titleWidth: 110,
+  titleAlign: 'right',
+  titleBold: true,
+  titleColon: true,
+  data: { ...props.data, frequency: props.data?.frequency ? props.data.frequency : 1 },
+  // data:{frequency:1},
+  items: [
+    {
+      field: 'name',
+      title: '计划名称',
+      span: 24,
+      itemRender: { name: 'VxeInput', props: { placeholder: '请输入随访计划名称' } },
+    },
+    {
+      field: 'startDate',
+      title: '开始日期',
+      span: 24,
+      itemRender: { name: 'VxeDatePicker', props: { placeholder: '请选择开始时间' } },
+    },
+    {
+      field: 'endDate',
+      title: '截止日期',
+      span: 24,
+      itemRender: { name: 'VxeDatePicker', props: { placeholder: '请选择截止时间' } },
+    },
+    {
+      field: 'purpose',
+      title: '随访目的',
+      span: 24,
+      itemRender: { name: 'VxeInput', props: { placeholder: '请编辑随访目的' } },
+    },
+    {
+      field: 'arrangeTime',
+      align: 'center',
+      title: '随访推送时间',
+      span: 24,
+      slots: { default: 'pushTime' },
+    },
+    { field: 'frequency', title: '随访次数', span: 24, slots: { default: 'followTimes' } },
+    { field: 'secondTimes', title: '', span: 24, slots: { default: 'secondTimes' } },
+    {
+      field: 'remindTime',
+      align: 'center',
+      title: '患者未填写,重复提醒时间',
+      span: 24,
+      slots: { default: 'remindTime' },
+    },
+    { align: 'center', span: 24, slots: { default: 'active' } },
+  ],
+  rules: {
+    name: [{ required: true, message: '请输入计划名称' }],
+    startDate: [{ required: true, message: '请选择开始日期' }],
+    endDate: [{ required: true, message: '请选择截止日期' }],
+    // arrangeTime: [{ required: true }],
+    // frequency: [{ required: true }],
+    // remindTime: [{ required: true }],
+  },
+});
+
+function formCheck(data) {
+  if (new Date(data?.endDate) < new Date(data?.startDate)) {
+    message.error('结束日期不能晚于开始日期');
+    return;
+  }
+  if (!data.arrangeTime) {
+    message.error('请选择随访推送时间!');
+    return;
+  }
+  if (!data.remindTime) {
+    message.error('请选择提醒时间!');
+    return;
+  }
+  if (compareTime(data.remindTime, data.arrangeTime)) {
+    message.error('推送时间不能晚于提醒时间');
+    return;
+  }
+  current.value++;
+}
+
+const arrangeTime = ref<string>('');
+const remindTime = ref<string>('');
+const follow = shallowRef<PlanModel>();
+const frequencyArr = ref<any[]>([]);
+const formEmits: VxeFormListeners<PlanModel> = {
+  submit({ data }) {
+    console.log(data, currentPushTime.value, '时间复制前', arrangeTime.value);
+    if (!arrangeTime.value) {
+      data.arrangeTime = currentPushTime.value;
+    } else {
+      data.arrangeTime = arrangeTime.value;
+    }
+    if (remindTime.value) {
+      data.remindTime = remindTime.value;
+    }
+
+    frequencyArr.value = [];
+    if (followTimesArr.value.length > 0) {
+      followTimesArr.value.forEach((item) => frequencyArr.value.push(item.data));
+      data.frequencyDays = frequencyArr.value?.join(',');
+    }
+    formCheck(data);
+    follow.value = { ...data };
+    console.log(follow.value, '时间复制');
+  },
+};
+// 第二步 填写筛选病人表单
+const patientsForm = reactive<VxeFormProps<PlanModel['filter']>>({
+  titleWidth: 110,
+  titleAlign: 'right',
+  titleBold: true,
+  titleColon: true,
+  data: { ...props.data?.filter },
+  items: [
+    {
+      field: 'tagIds',
+      title: '患者标签',
+      span: 24,
+      itemRender: {
+        name: 'VxeSelect',
+        props: {
+          placeholder: '患者标签',
+          loading: tagDataLoading,
+          options: computed(() => tagData.value.data),
+          optionProps: { value: 'id', label: 'name' },
+          clearable: true,
+          multiple: true,
+          filterable: true,
+        },
+      },
+    },
+    {
+      field: 'departments',
+      title: '就诊科室',
+      span: 24,
+      itemRender: {
+        name: 'VxeSelect',
+        props: {
+          loading: depDataLoading,
+          options: computed(() => departmentsData.value.departmentsData),
+          optionProps: { value: 'name', label: 'name' },
+          clearable: true,
+          multiple: true,
+          filterable: true,
+        },
+      },
+    },
+    {
+      field: 'doctors',
+      title: '就诊医生',
+      span: 24,
+      itemRender: {
+        name: 'VxeSelect',
+        props: {
+          loading: doctorDataLoading,
+          options: computed(() => doctorData.value.doctorData),
+          optionProps: { value: 'name', label: 'name' },
+          clearable: true,
+          multiple: true,
+          filterable: true,
+        },
+      },
+    },
+    { align: 'center', span: 24, slots: { default: 'patientsBtn' } },
+    // {
+    //   span: 20,
+    //   itemRender: {
+    //     name: 'VxeButtonGroup',
+    //     options: [
+    //       { type: 'submit', content: '完成', status: 'primary' },
+    //       { type: 'reset', content: '清空' },
+    //     ],
+    //   },
+    // },
+  ],
+});
+const tagNames = ref<any[]>([]);
+const submitData = ref({});
+const patientsEmits: VxeFormListeners<PlanModel['filter']> = {
+  async submit({ data }) {
+    if (data?.tagIds?.length > 0) {
+      tagData.value?.data?.filter((tag, i) => {
+        if (tag.id === data.tagIds[i]) {
+          tagNames.value.push(tag.name);
+        }
+      });
+    }
+    submitData.value = { filter: { ...data }, ...follow.value };
+    console.log(
+      submitData.value,
+      '提交1111',
+      { ...data },
+      { filter: { ...data }, ...follow.value }
+    );
+    await submit(submitData.value);
+    // 关闭弹窗
+    VxeUI.modal.close(`plan-modal`);
+  },
+};
+
+// 步骤条样式
+const stepStyle = {
+  width: '100%',
+  marginBottom: '20px',
+  boxShadow: '0px -1px 0 0 #e8e8e8 inset',
+};
+// 点击下一步
+const current = ref<number>(0);
+
+onBeforeMount(() => {
+  changeFrequency();
+});
+
+const timeArr = Array.from({ length: 16 }, (_, i) =>
+  i + 6 >= 10 ? `${6 + i}:00` : `0${6 + i}:00`
+);
+
+// 随访计划填写取消
+function cancel() {
+  VxeUI.modal.close(`plan-modal`);
+}
+
+const followTimesArr = ref<any[]>([]);
+
+function changeFrequency() {
+  const length = followTimesArr.value.length;
+  const diff = (formProps.data?.frequency ?? 0) - length;
+  console.log(props.data?.frequency, '随访次数');
+  if (diff > 0) {
+    for (var i = 0; i < diff; i++) {
+      followTimesArr.value.push({
+        field: 'secondTimes',
+        title: `第${length + i + 1}随访时间`,
+        span: 8,
+        slots: { default: 'secondTimes' },
+        data: formProps?.data?.frequencyDays?.split(',')[i]
+          ? formProps?.data?.frequencyDays?.split(',')[i]
+          : props.data?.frequency === undefined
+            ? length + i + 1
+            : 1,
+      });
+    }
+  } else {
+    followTimesArr.value = followTimesArr.value.slice(0, formProps.data?.frequency);
+  }
+}
+
+function compareTime(t1, t2) {
+  var date = new Date();
+  var a = t1.split(':');
+  var b = t2.split(':');
+  return date.setHours(a[0], a[1]) < date.setHours(b[0], b[1]);
+}
+
+const form1 = ref<VxeFormInstance>();
+
+// 点击下一步
+function changeStep(currentStep) {
+  // debugger
+  if (currentStep === 1) {
+    if (arrangeTime.value) {
+      formProps.data.arrangeTime = arrangeTime.value;
+    }
+    if (remindTime.value) {
+      formProps.data.remindTime = remindTime.value;
+    }
+    form1.value?.validate().then((haveValidate) => {
+      if (haveValidate) {
+        current.value = 0;
+      } else {
+        if (new Date(formProps?.data?.endDate) < new Date(formProps?.data?.startDate)) {
+          message.error('结束日期不能晚于开始日期');
+          current.value = 0;
+          return;
+        }
+        if (!formProps.data.arrangeTime) {
+          message.error('请选择随访推送时间!');
+          current.value = 0;
+          return;
+        }
+        if (!formProps.data.remindTime) {
+          message.error('请选择提醒时间!');
+          current.value = 0;
+          return;
+        }
+        if (compareTime(formProps.data.remindTime, formProps.data.arrangeTime)) {
+          message.error('推送时间不能晚于提醒时间');
+          current.value = 0;
+          return;
+        }
+      }
+    });
+  }
+}
+
+// 推送时间
+const currentPushTime = ref<string>(timeArr[0]);
+
+function selectPushTime(item, pushIndex) {
+  arrangeTime.value = item;
+  currentPushTime.value = item;
+}
+
+// 提醒时间
+const currentRemindTime = ref<string>();
+
+function selectRemindTime(item, remindIndex) {
+  remindTime.value = item;
+  currentRemindTime.value = item;
+}
+</script>
+<template>
+  <div>
+    <a-steps
+      @change="changeStep"
+      v-model:current="current"
+      size="small"
+      :style="stepStyle"
+      type="navigation"
+      :items="[
+        {
+          title: '填写随访计划',
+        },
+        {
+          title: '筛选病人',
+        },
+      ]"
+    >
+    </a-steps>
+  </div>
+  <vxe-form
+    v-bind="formProps"
+    v-on="formEmits"
+    :loading
+    v-show="current != 1"
+    class="flex flex-col"
+    ref="form1"
+  >
+    <template #active="{ data }">
+      <vxe-button
+        type="submit"
+        status="primary"
+        content="下一步"
+        :loading="submitting"
+      ></vxe-button>
+    </template>
+
+    <template #followTimes>
+      <div class="flex items-center">
+        <vxe-input
+          type="integer"
+          v-model="formProps.data!.frequency"
+          @change="changeFrequency"
+          min="1"
+        ></vxe-input>
+        <div class="ml-3">次</div>
+      </div>
+    </template>
+
+    <template #secondTimes>
+      <div class="flex items-center mb-3" v-for="item in followTimesArr" :key="item.title">
+        <div style="width: 110px" class="flex justify-end pr-1 font-bold">{{ item.title }}:</div>
+        <div class="mr-2">就诊后第</div>
+        <div>
+          <vxe-input type="integer" min="0" v-model="item.data"></vxe-input>
+        </div>
+        <div class="ml-3">天</div>
+      </div>
+    </template>
+    <!--随访推送时间-->
+    <template #pushTime="{ data }">
+      <div class="flex flex-wrap border border-solid border-gray-200">
+        <div
+          class="flex-none border border-solid border-gray-200 text-xs"
+          style="width: 25%; height: 50px; line-height: 50px"
+          v-for="(pushTime, pushIndex) in timeArr"
+          :key="pushTime"
+          @click="selectPushTime(pushTime, pushIndex)"
+          :class="
+            (currentPushTime ? currentPushTime : data.arrangeTime) === pushTime
+              ? 'bg-blue color-white'
+              : ''
+          "
+        >
+          {{ pushTime }}
+        </div>
+      </div>
+    </template>
+    <!--重复提醒时间-->
+    <template #remindTime="{ data }">
+      <div class="flex flex-wrap border border-solid border-gray-200">
+        <div
+          class="flex-none border border-solid border-gray-200 text-xs"
+          style="width: 25%; height: 50px; line-height: 50px"
+          v-for="(remindTime, remindIndex) in timeArr"
+          :key="remindTime"
+          @click="selectRemindTime(remindTime, remindIndex)"
+          :class="
+            (currentRemindTime ? currentRemindTime : data.remindTime) === remindTime
+              ? 'bg-blue color-white'
+              : ''
+          "
+        >
+          {{ remindTime }}
+        </div>
+      </div>
+    </template>
+  </vxe-form>
+
+  <!--筛选病人  -->
+  <vxe-form v-bind="patientsForm" v-on="patientsEmits" :loading v-show="current === 1">
+    <template #patientsBtn>
+      <div class="tips">请按照需求选择纳入随访的病人</div>
+      <vxe-button
+        content="取消"
+        :loading="submitting"
+        name="cancel"
+        class="mr-2"
+        @click="cancel"
+      ></vxe-button>
+      <vxe-button type="submit" content="完成" :disabled="submitting" status="primary"></vxe-button>
+    </template>
+  </vxe-form>
+</template>
+<style scoped lang="scss">
+.tips {
+  text-align: center;
+  margin: 40px auto 100px auto;
+  font-weight: bold;
+  font-size: 14px;
+}
+
+.mesh-grid {
+  border-collapse: collapse;
+}
+
+.mesh-grid td {
+  border: 1px solid black;
+  width: 100px;
+  padding: 20px 20px;
+  text-align: center;
+}
+</style>

+ 312 - 0
src/components/Evaluation.vue

@@ -0,0 +1,312 @@
+<script setup lang="ts">
+import SwiperImage from '@/components/Swiper.vue';
+import { symptomStatus, nextStatus } from '@/model/options';
+import { EvaluationModel } from '@/model/follow.model';
+import { tagEditMethod } from '@/request/api/system.api';
+import { EvaluateDetailMethod, FillEvaluateMethod } from '@/request/api/follow.api';
+import { useRequest } from 'alova/client';
+import { type VxeGridProps, VxeUI } from 'vxe-pc-ui';
+import { notification } from 'ant-design-vue';
+import { HealthReportAnalysisItemConfig } from '@/model/health-report-analysis.config';
+
+type FormModel = Partial<EvaluationModel>;
+
+const defaultModel = {};
+
+const props = defineProps<{ data: FormModel }>();
+const emits = defineEmits<{
+  submit: [data?: EvaluationModel];
+}>();
+// 获取表格数据
+const gridArr = ref<string[]>([]);
+const { loading, send: getDetail } = useRequest(EvaluateDetailMethod, {
+  immediate: false,
+  initialData: props.data ?? defaultModel,
+}).onSuccess(({ data }) => {
+  let medicalObj = {
+    arrangeTime: data?.medicalTime,
+    tonguefaceAnalysisReport: data?.tonguefaceAnalysisReport,
+  };
+  data.tasks.unshift(medicalObj);
+  console.log(data.tasks, '666');
+  const tasks = data.tasks.map((item) => {
+    let obj = {
+      date: item.fillinTime || item.arrangeTime,
+      k3: item?.analysis?.tongue.pictures[0],
+      k4: item?.analysis?.tongue.pictures[1],
+      k5: item?.analysis?.face.pictures[0],
+    };
+    item?.analysis?.tongue?.analysis.forEach((analysis) => {
+      obj[analysis?.subcategory] = analysis.resultValue;
+    });
+    item.analysis?.face?.analysis.forEach((analysis) => {
+      obj[analysis?.subcategory] = analysis?.resultValue;
+    });
+    return obj;
+  });
+
+  for (const item of HealthReportAnalysisItemConfig) {
+    gridOptions.data.push({
+      key: item.subcategory,
+      title: item.subcategory,
+    });
+  }
+  gridArr.value = tasks;
+  loadAssessDetail(tasks);
+});
+const { loading: submitting, send: submit } = useRequest(tagEditMethod, {
+  immediate: false,
+}).onSuccess(({ data }) => {
+  emits('submit');
+});
+// 渲染表格
+const gridOptions = reactive<VxeGridProps<FormModel>>({
+  border: true,
+  showOverflow: true,
+  height: 780,
+  showHeader: false,
+  columns: [{ field: 'title', title: '-', slots: { default: 'cell' } }],
+  // columnConfig:{
+  //   width:'auto'
+  // },
+  data: [
+    { title: '日期', key: 'date', col0: '-' },
+    { title: '好转', key: 'k1' },
+    { title: '恶化', key: 'k2' },
+    { title: '舌面', key: 'k3' },
+    { title: '舌下', key: 'k4' },
+    { title: '面部', key: 'k5' },
+  ],
+  cellStyle({ row, rowIndex, column, columnIndex }) {
+    // console.log('rowIndex:',rowIndex,'columnIndex',columnIndex);
+    const value = row[column.field];
+    const style = {};
+    if (columnIndex === 1) {
+      style.width = '100px';
+    }
+    if (value?.includes('(') && value?.includes(')')) {
+      style.color = 'red';
+    }
+
+    if (
+      columnIndex > 1 &&
+      value !== row.col1 &&
+      HealthReportAnalysisItemConfig.map((item) => item.subcategory).includes(row.key)
+    ) {
+      style.background = 'yellow';
+    }
+    if (value?.startsWith('http')) {
+      style.height = '100px';
+    }
+    return style;
+  },
+});
+
+//加载详情
+function loadAssessDetail(data = []) {
+  const columns = gridOptions.columns;
+  const rows = gridOptions.data;
+  let col = columns.length;
+  for (const item of data) {
+    const field = `col${col}`;
+    columns.push({
+      field,
+      title: `标题${col}`,
+      slots: { default: 'cell' },
+    });
+    col++;
+    for (let r = 0; r < rows.length; r++) {
+      rows[r][field] = item[rows[r].key];
+    }
+  }
+}
+
+onBeforeMount(() => {
+  if (props.data?.id) getDetail(props.data);
+});
+const radioStyle = reactive({
+  display: 'flex',
+  height: '30px',
+  lineHeight: '30px',
+});
+// 点击对比  查看
+const imgArr = ref<string[]>([]);
+
+function openSwiperImage(row, column) {
+  const getImages = Object.keys(row)
+    .map((key) => {
+      return key.startsWith('col') ? row[key] : '';
+    })
+    .filter(Boolean)
+    .map((item) => {
+      gridArr.value.forEach((date) => {
+        imgArr.value = [{ image: item, date: date.date }];
+      });
+      return imgArr.value;
+    });
+  const images = getImages.length > 0 ? getImages[0] : [];
+  // 判断当表头等于姓名字段时执行一个方法
+  VxeUI.modal.open({
+    showHeader: true,
+    showCancelButton: true,
+    escClosable: true,
+    destroyOnClose: true,
+    id: `swiper-modal`,
+    remember: true,
+    storage: true,
+    width: 800,
+    height: 400,
+    slots: {
+      default() {
+        return h(SwiperImage, <any>{
+          images,
+          onSubmit(data: EvaluationModel) {
+            VxeUI.modal.close(`swiper-modal`);
+          },
+        });
+      },
+    },
+  });
+}
+
+// 提交评估
+function subEvalation() {
+  FillEvaluateMethod(props.data).then(() => {
+    notification.success({
+      message: ` `,
+      description: '提交成功',
+    });
+  });
+}
+</script>
+<template>
+  <div>
+    <div
+      class="flex"
+      style="background: #eeeeee; padding: 10px; font-size: 13px; border: 0.5px solid lightgrey"
+    >
+      <div
+        class="border-r border-r-2 border-r-gray-300 border-r-solid pr-6 flex align--center justify-center"
+      >
+        <div class="mr-2 font-bold" style="font-size: 14px">基本信息</div>
+        <div class="mr-3">{{ props.data.medicalTime }}</div>
+        <span class="mr-3">{{ props.data.patientName }}</span>
+        <span class="mr-3"> {{ props.data.sex === '1' ? '男' : '女' }}</span>
+        <span> {{ props.data.age }}岁</span>
+      </div>
+      <div class="border-r border-r-2 border-r-gray-300 border-r-solid pr-6 pl-6">
+        诊断:{{ props.data.diagnosis }}
+      </div>
+      <div class="pl-6">症状:{{ props.data?.syndromeList.join(',') }}</div>
+    </div>
+
+    <vxe-grid class="reverse-table detail" v-bind="gridOptions">
+      <template #cell="{ row, rowIndex, column, columnIndex }">
+        <a-image
+          style="height: 100px"
+          v-if="row[column.field] && rowIndex > 2 && rowIndex < 6 && columnIndex !== 0"
+          :src="row[column.field]"
+        ></a-image>
+        <div
+          v-else-if="
+            row[column.field] === '舌面' ||
+            row[column.field] === '舌下' ||
+            row[column.field] === '面部'
+          "
+        >
+          <div>{{ row[column.field] }}</div>
+          <div @click="openSwiperImage(row, column)" style="margin-top: 10px; color: #1890ff">
+            对比
+          </div>
+        </div>
+        <span v-else-if="row[column.field] == props.data.medicalTime" style="color: red"
+          >{{ row[column.field] }}(就诊日) </span
+        >
+        <template v-else>{{ row[column.field] }}</template>
+      </template>
+      <!--      已经评估-->
+      <template #right v-if="props.data?.evaluateProgress === '1'">
+        <div class="pl-6 pr-4">
+          <div
+            style="width: 160px; font-size: 16px"
+            class="border-1 border-gray font-bold mt-2 mb-3"
+          >
+            随访结果评估
+          </div>
+          <div style="width: 160px" class="border-1 border-gray font-bold mt-2 mb-3">疾病转归</div>
+          <div class="mb-8 indent-6">
+            {{
+              props.data?.evaluateSituation === '1'
+                ? '痊愈'
+                : props.data?.evaluateSituation === '2'
+                  ? '好转'
+                  : props.data?.evaluateSituation === '3'
+                    ? '无变化'
+                    : '恶化'
+            }}
+          </div>
+          <div class="font-bold mt-2 mb-1">备注:</div>
+          <p class="indent-6 w-30 text-pretty">{{ props.data?.evaluateDesc }}</p>
+          <div class="font-bold mb-4 mt-15">下一步处置</div>
+          <div class="indent-6 font-bold">
+            {{ props.data?.evaluateDeal === '1' ? '复诊' : '中医调养' }}
+          </div>
+          <div class="mt-10 flex justify-end flex-col items-center">
+            <div>智医生</div>
+            <div>2024.10.05</div>
+          </div>
+        </div>
+      </template>
+      <!--未评估-->
+      <template #right v-else>
+        <div class="pl-6 pr-4">
+          <div style="font-size: 18px" class="border-1 border-gray font-bold mt-2 mb-3">
+            随访结果评估
+          </div>
+          <div class="flex items-center justify-center mb-2">
+            <div class="w-1 h-1 bg-blue-400"></div>
+            <div style="width: 200px" class="border-1 border-gray font-bold ml-2">疾病转归</div>
+          </div>
+          <div class="ml-2 mb-3">
+            <a-radio-group v-model:value="props.data.evaluateSituation">
+              <a-radio :style="radioStyle" :value="item.value" v-for="item in symptomStatus"
+                >{{ item.label }}
+              </a-radio>
+            </a-radio-group>
+          </div>
+          <div class="flex items-center justify-center mb-2">
+            <div class="w-1 h-1 bg-blue-400"></div>
+            <div style="width: 200px" class="border-1 border-gray font-bold ml-2">备注</div>
+          </div>
+          <a-textarea
+            v-model:value="props.data.evaluateDesc"
+            placeholder="请输入"
+            allow-clear
+            style="margin-bottom: 20px"
+          />
+          <div class="flex items-center justify-center mb-2">
+            <div class="w-1 h-1 bg-blue-400"></div>
+            <div style="width: 200px" class="border-1 border-gray font-bold ml-2">下一步处置</div>
+          </div>
+          <div>
+            <a-radio-group v-model:value="props.data.evaluateDeal">
+              <a-radio :style="radioStyle" :value="item.value" v-for="item in nextStatus"
+                >{{ item.label }}
+              </a-radio>
+            </a-radio-group>
+          </div>
+          <div class="mt-20 flex justify-center" v-show="props.data.evaluateProgress !== '1'">
+            <a-button type="primary" @click="subEvalation">完成</a-button>
+          </div>
+        </div>
+      </template>
+    </vxe-grid>
+  </div>
+</template>
+<style scoped lang="scss">
+.detail {
+  :deep(.vxe-cell) {
+    height: 100% !important;
+  }
+}
+</style>

+ 691 - 0
src/components/Follow.vue

@@ -0,0 +1,691 @@
+<script setup lang="ts">
+import type { TaskModel } from '@/model/follow.model';
+import {
+  FillFollowContentMethod,
+  UploadIFile,
+  FollowContentMethod,
+} from '@/request/api/follow.api';
+import { getDictionaryMethod } from '@/request/api/dictionary.api';
+import { useRequest } from 'alova/client';
+import { PlusOutlined } from '@ant-design/icons-vue';
+
+import { notification } from 'ant-design-vue';
+import type { UploadFile } from 'ant-design-vue/es/upload/interface';
+
+type FormModel = Partial<TaskModel>;
+
+const props = defineProps<{ data: FormModel }>();
+
+const emits = defineEmits<{
+  submit: [data?: TaskModel];
+}>();
+const {
+  data: contentArr,
+  loading,
+  send: load,
+} = useRequest(() => FollowContentMethod(props.data), {
+  initialData: [],
+}).onSuccess(({ data }) => {
+  const index = data.findIndex((item) => item.id === props.data.id);
+  if (index > -1) {
+    changeTab(data[index], index);
+  }
+});
+const statusList = ref<string[]>([]);
+onBeforeMount(() => {
+  getDictionaryMethod('followup_syndrome_change').then((res) => {
+    statusList.value = res;
+  });
+});
+const activeKey = ref<number>();
+const activeIndex = ref<number>();
+const activeObj = ref({ fillin: {}, symptomsData: [] });
+// 切换侧边栏任务
+const changeTab = (data: any, index: number) => {
+  activeKey.value = data.id;
+  activeObj.value = { ...data, fillin: { ...data.fillin } };
+  activeIndex.value = index;
+  activeObj.value.symptomsData = [];
+  downImageList.value = [];
+  const upImg = data.fillin?.upImg;
+  const downImg = data.fillin?.downImg;
+  const faceImg = data.fillin?.faceImg;
+  if (activeObj.value.syndromeList.length > 0) {
+    activeObj.value.syndromeList.forEach((syndrome) => {
+      activeObj.value.symptomsData.push({ name: syndrome });
+    });
+    activeObj.value.symptomsData.forEach((item) => {
+      item.child = statusList.value;
+      item.selectedValue = '';
+      item.selectedId = null;
+      item.id = data.id;
+    });
+  }
+  symptomsValue.value.parent = '';
+  upImgList.value = upImg
+    ? [
+        {
+          uid: '-1',
+          status: 'done',
+          url: upImg,
+          thumbUrl: upImg,
+          response: { url: upImg },
+        },
+      ]
+    : [];
+  downImageList.value = downImg
+    ? [
+        {
+          uid: '-1',
+          status: 'done',
+          url: downImg,
+          thumbUrl: downImg,
+          response: { url: downImg },
+        },
+      ]
+    : [];
+  faceImageList.value = faceImg
+    ? [
+        {
+          uid: '-1',
+          status: 'done',
+          url: faceImg,
+          thumbUrl: faceImg,
+          response: { url: faceImg },
+        },
+      ]
+    : [];
+  uploadProps.showRemoveIcon = data.progress === '1';
+};
+
+// 存储所有选择的症状
+const selectedSymptomsList = ref<{ name: string; value: string }[]>([]);
+// 存储症状
+const symptomsList = ref<{ name: string; type: string }[]>([]);
+// 症状选择的值
+const symptomsValue = ref({
+  parent: '',
+  child: '',
+});
+
+// 处理父级点击
+const handleParentClick = (name: string) => {
+  if (symptomsValue.value.parent === name) {
+    // 如果点击的是当前选中的父级,则清空选择
+    symptomsValue.value = {
+      parent: '',
+      child: '',
+    };
+  } else {
+    // 选择新的父级
+    symptomsValue.value = {
+      parent: name,
+      child: '',
+    };
+  }
+};
+
+// 处理子级选择变化
+const handleChildChange = (e: any) => {
+  const selectedValue = e.target.value;
+  const currentParent = symptomsValue.value.parent;
+  // 找到当前症状
+  const symptom = activeObj.value.symptomsData.find((item) => item.name === currentParent);
+
+  if (symptom) {
+    // 如果点击的是当前选中的值,则取消选择
+    if (symptom.selectedId === selectedValue) {
+      // 取消选择
+      symptom.selectedValue = '';
+      symptom.selectedId = null;
+
+      // 从已选择的症状列表中移除
+      const index = selectedSymptomsList.value.findIndex((item) => item.name === currentParent);
+      if (index > -1) {
+        selectedSymptomsList.value.splice(index, 1);
+      }
+    } else {
+      // 选择新的值
+      const child = symptom.child.find((item) => item.value === selectedValue);
+      if (child) {
+        symptom.selectedValue = selectedValue;
+        symptom.selectedId = selectedValue;
+
+        // 更新已选择的症状列表
+        const existingIndex = selectedSymptomsList.value.findIndex(
+          (item) => item.name === currentParent
+        );
+        if (existingIndex > -1) {
+          selectedSymptomsList.value[existingIndex].value = selectedValue;
+        } else {
+          selectedSymptomsList.value.push({
+            name: currentParent,
+            value: selectedValue,
+          });
+        }
+      }
+    }
+  }
+  symptomsValue.value.parent = '';
+};
+
+// 是否出现新症状
+const selectSymptomsData = reactive([
+  { name: '有', id: 'Y' },
+  { name: '没有', id: 'N' },
+]);
+
+const uploadProps = reactive({ showRemoveIcon: true });
+const changeTag = (item) => {
+  activeObj.value.fillin.isHaveNewSyndrome = item.id;
+};
+
+const upImgList = ref<UploadFile[]>([]);
+const downImageList = ref<UploadFile[]>([]);
+const faceImageList = ref<UploadFile[]>([]);
+// 预览图片
+const handlePreview = async (file: UploadFile) => {
+  previewImg.value = file.response?.url ?? file.thumbUrl;
+  visible.value = true;
+};
+
+// 填写随访内容
+function subFollowContent() {
+  activeObj.value.fillin.downImg = upImgList.value[0]?.response?.url;
+  activeObj.value.fillin.upImg = downImageList.value[0]?.response?.url;
+  activeObj.value.fillin.faceImg = faceImageList.value[0]?.response?.url;
+  symptomsList.value = [];
+  activeObj.value.symptomsData.forEach((item) => {
+    symptomsList.value.push({ name: item.name, type: item.selectedValue });
+  });
+  activeObj.value.fillin.symptomsList = symptomsList.value;
+  // console.log('填写的随访内容', activeObj.value.fillin);
+  // return;
+  FillFollowContentMethod(activeObj.value).then(() => {
+    notification.success({
+      message: '',
+      description: '提交成功!',
+    });
+    emits('submit');
+    load();
+  });
+}
+
+// 取消提交
+function cancelFollowContent() {
+  VxeUI.modal.close(`follow-modal`);
+}
+
+function customUpload(e) {
+  // uploadApi 你的二次封装上传接口
+  UploadIFile(e.file)
+    .then((res) => {
+      // 调用实例的成功方法通知组件该文件上传成功
+      e.onSuccess(res, e);
+    })
+    .catch((err) => {
+      // 调用实例的失败方法通知组件该文件上传失败
+      e.onError(err);
+    });
+}
+
+const visible = ref<boolean>(false);
+const setVisible = (value): void => {
+  visible.value = value;
+};
+const previewImg = ref<string>('');
+</script>
+
+<template>
+  <div>
+    <div class="flex font-bold">
+      <!--    左边-->
+      <div class="animated-vertical-tabs">
+        <div class="tab-list">
+          <div class="font-bold h-8 pt-3 mb-3 ml-2">失眠门诊随访</div>
+          <div
+            style="font-size: 14px"
+            v-for="(content, index) in contentArr"
+            :key="content.id"
+            class="tab-item mb-3"
+            :class="{ active: activeKey === content.id }"
+            @click="changeTab(content, index)"
+          >
+            <div>{{ content.followupTaskName }}</div>
+            <span class="tab-label">{{ content.arrangeTime }}</span>
+            <div
+              :class="
+                content.progress == 1
+                  ? 'text-red-600'
+                  : content.progress == 2
+                    ? 'text-green-900'
+                    : content.progress == 3
+                      ? 'text-blue-900'
+                      : ''
+              "
+            >
+              {{
+                content.progress === '1' ? '未完成' : content.progress === '2' ? '已完成' : '未开始'
+              }}
+            </div>
+          </div>
+        </div>
+      </div>
+      <!--    右边-->
+      <div :key="activeObj.id">
+        <div class="w-full flex flex-col items-center h-8 mb-3">
+          <div class="font-bold mb-2">
+            {{ activeObj?.followupTaskName }}
+          </div>
+          <div>预定随访时间:{{ activeObj?.arrangeTime }}</div>
+        </div>
+        <div class="mb-2 ml-2">
+          您好,您于<span class="text-blue-600">【{{ activeObj?.medicalTime }}】</span>在我院<span
+            class="text-blue-600"
+            >【{{ activeObj?.institutionName }}】</span
+          >因为<span class="text-blue-600">【{{ activeObj?.diagnosis }}】</span
+          >就诊。接下来我们将对您进行一个随访,请根据目前的实际情况回答。
+        </div>
+        <div class="border-1 border-solid border-gray:50 pl-2 pd-10 ml-2">
+          <div class="mb-3 border-b-0">
+            1、请问您的症状有没有<span class="text-red-600">好转</span>或者<span
+              class="text-red-600"
+              >恶化</span
+            >?请先点击症状,再选择好转还是恶化。(没有操作的症状默认没有变化)
+          </div>
+
+          <div class="ml-4" v-if="activeObj.progress === '1'">
+            <!-- 症状选择器 -->
+            <div class="symptom-container flex flex-wrap">
+              <div v-for="item in activeObj?.symptomsData" :key="item.name" class="symptom-item">
+                <div class="symptom-button" @click="handleParentClick(item.name)">
+                  <span>{{ item.name }}</span>
+                  <span v-if="item.selectedValue" class="selected-value"
+                    >: {{ item.selectedValue }}</span
+                  >
+                </div>
+                <div v-show="symptomsValue.parent === item.name" class="symptom-options">
+                  <a-radio-group
+                    :model-value="item.selectedId"
+                    @change="handleChildChange"
+                    class="flex flex-wrap"
+                  >
+                    <a-radio
+                      :value="tag.value"
+                      v-for="tag in item.child"
+                      :key="tag.value"
+                      class="mr-4"
+                    >
+                      {{ tag.label }}
+                    </a-radio>
+                  </a-radio-group>
+                </div>
+              </div>
+            </div>
+          </div>
+          <!-- 已经评估过 -->
+          <div v-else>
+            <div class="symptom-container flex flex-wrap">
+              <div
+                v-for="item in activeObj.value?.fillin?.symptomsList"
+                :key="item.name"
+                class="symptom-item"
+              >
+                <div class="symptom-button">
+                  <span>{{ item.name }}</span>
+                  <span class="selected-value">: {{ item.type }}</span>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <!--      第二个-->
+        <div class="border-1 border-solid border-gray:50 pl-2 pd-10 ml-2">
+          <div class="mb-3">2、请问有没有出现<span class="text-red-600">新</span>的症状?</div>
+          <div class="mb-8 ml-4 flex">
+            <div
+              v-for="symptoms in selectSymptomsData"
+              :key="symptoms.name"
+              class="mr-4"
+              @click="changeTag(symptoms)"
+            >
+              <div>
+                <div
+                  class="border-solid b-1 w-20 text-center"
+                  :class="
+                    activeObj.fillin.isHaveNewSyndrome === symptoms.id ? 'bg-blue text-#fff' : ''
+                  "
+                >
+                  {{ symptoms.name }}
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+        <!--      第三个-->
+        <div class="border-1 border-solid border-gray:50 pl-2 pd-10 ml-2">
+          <div class="mb-3">3、请描述新的症状</div>
+          <div class="mb-4 ml-4">
+            <a-input
+              v-model:value="activeObj.fillin.newSyndrome"
+              placeholder="请输入"
+              :auto-size="{ minRows: 2, maxRows: 5 }"
+            />
+          </div>
+        </div>
+        <!--      第四个-->
+        <div class="border-1 border-solid border-gray:50 pl-2 pd-10 ml-2">
+          <div class="mb-3">4、如果没有其他情况,请留言</div>
+          <div class="mb-4 ml-4">
+            <a-input
+              v-model:value="activeObj.fillin.otherDesc"
+              placeholder="请输入"
+              :auto-size="{ minRows: 2, maxRows: 5 }"
+            />
+          </div>
+        </div>
+        <!--      第五个-->
+        <div class="border-1 border-solid border-gray:50 pl-2 pd-10 ml-2">
+          <div class="mb-3">5、为了医生更好地了解您的恢复情况,需要您上传舌面象照片</div>
+          <div class="mb-4 ml-3">
+            <!--  上传图片-->
+            <div class="flex">
+              <!--            舌面-->
+              <div class="flex flex-col items-center mr-4">
+                <a-upload
+                  :showUploadList="uploadProps"
+                  v-model:file-list="upImgList"
+                  list-type="picture-card"
+                  @preview="handlePreview"
+                  :maxCount="1"
+                  :customRequest="customUpload"
+                >
+                  <div v-if="upImgList.length < 1">
+                    <plus-outlined />
+                  </div>
+                </a-upload>
+                <div class="font-bold">舌面</div>
+              </div>
+              <!--            舌下-->
+              <div class="flex flex-col items-center mr-4">
+                <a-upload
+                  :showUploadList="uploadProps"
+                  v-model:file-list="downImageList"
+                  list-type="picture-card"
+                  @preview="handlePreview"
+                  :maxCount="1"
+                  :customRequest="customUpload"
+                >
+                  <div v-if="downImageList.length < 1">
+                    <plus-outlined />
+                  </div>
+                </a-upload>
+                <div class="font-bold">舌下</div>
+              </div>
+              <!--            面部-->
+              <div class="flex flex-col items-center mr-4">
+                <a-upload
+                  :showUploadList="uploadProps"
+                  v-model:file-list="faceImageList"
+                  list-type="picture-card"
+                  @preview="handlePreview"
+                  :maxCount="1"
+                  :customRequest="customUpload"
+                >
+                  <div v-if="faceImageList.length < 1">
+                    <plus-outlined />
+                  </div>
+                </a-upload>
+                <div class="font-bold">面部</div>
+              </div>
+              <!--              --------end-->
+            </div>
+            <a-image
+              :width="200"
+              :style="{ display: 'none' }"
+              :preview="{
+                visible,
+                onVisibleChange: setVisible,
+              }"
+              :src="previewImg"
+            />
+          </div>
+        </div>
+
+        <!--      -->
+        <div class="ml-2 mt-1">
+          感谢您的配合,为了更好地了解您的回复情况,我们将会在<span class="text-blue-600">
+            {{ activeObj?.arrangeTime }} </span
+          >再次对您进行随访,届时请点击随访链接参与,再次感谢您!
+        </div>
+      </div>
+    </div>
+    <div class="flex items-center justify-center mt-6 mb-6">
+      <a-button size="small" class="mr-4" @click="cancelFollowContent">取消</a-button>
+      <!--      <a-button type="primary" size="small" @click="subFollowContent">提交</a-button>-->
+      <a-button
+        type="primary"
+        size="small"
+        @click="subFollowContent"
+        v-show="activeObj.progress === '1'"
+        >提交
+      </a-button>
+    </div>
+  </div>
+</template>
+<style scoped lang="scss">
+.ant-upload-select-picture-card i {
+  font-size: 32px;
+  color: #999;
+}
+
+.ant-upload-select-picture-card .ant-upload-text {
+  margin-top: 8px;
+  color: #666;
+}
+
+.mesh-grid {
+  border-collapse: collapse;
+}
+
+.mesh-grid td {
+  border: 1px solid black;
+  width: 100px;
+  padding: 20px 20px;
+  text-align: center;
+}
+
+.animated-vertical-tabs {
+  display: flex;
+  height: 730px;
+  width: 17%;
+  overflow: auto;
+}
+
+.tab-list {
+  border-right: 1px solid #f0f0f0;
+}
+
+.tab-item {
+  position: relative;
+  padding: 10px;
+  cursor: pointer;
+  transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
+  font-weight: bold;
+}
+
+.tab-item:hover {
+  background-color: rgba(24, 144, 255, 0.06);
+}
+
+.tab-item.active {
+  background: lightgray;
+}
+
+.tab-indicator {
+  position: absolute;
+  top: 0;
+  right: -1px;
+  width: 2px;
+  height: 100%;
+  background-color: #1890ff;
+  transform: scaleY(0);
+  transform-origin: center top;
+  transition: transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
+}
+
+.tab-item.active .tab-indicator {
+  transform: scaleY(1);
+}
+
+.tab-content {
+  flex: 1;
+  padding: 0 24px;
+  overflow: auto;
+}
+
+.fade-enter-active,
+.fade-leave-active {
+  transition: opacity 0.3s ease;
+}
+
+.fade-enter-from,
+.fade-leave-to {
+  opacity: 0;
+}
+
+// 症状选择器样式
+.symptom-container {
+  .symptom-item {
+    position: relative;
+    margin-right: 16px;
+    margin-bottom: 16px;
+
+    .symptom-button {
+      min-width: 100px;
+      padding: 8px 16px;
+      border: 1px solid #d9d9d9;
+      border-radius: 4px;
+      text-align: center;
+      cursor: pointer;
+      transition: all 0.3s;
+      background: #fff;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+
+      &:hover {
+        border-color: #40a9ff;
+        color: #40a9ff;
+      }
+
+      &.active {
+        border-color: #1890ff;
+        color: #1890ff;
+        background: rgba(24, 144, 255, 0.1);
+      }
+
+      &.has-value {
+        border-color: #52c41a;
+        color: #52c41a;
+      }
+
+      .selected-value {
+        margin-left: 4px;
+        font-weight: 500;
+      }
+    }
+
+    .symptom-options {
+      position: absolute;
+      top: 100%;
+      left: 0;
+      z-index: 1;
+      margin-top: 8px;
+      padding: 8px;
+      background: #fff;
+      border: 1px solid #d9d9d9;
+      border-radius: 4px;
+      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+      animation: fadeIn 0.3s;
+
+      .ant-radio-group {
+        display: flex;
+        flex-direction: column;
+        gap: 8px;
+      }
+
+      .ant-radio-wrapper {
+        padding: 4px 8px;
+        border-radius: 4px;
+        transition: all 0.3s;
+
+        &:hover {
+          background: rgba(24, 144, 255, 0.1);
+        }
+      }
+    }
+  }
+}
+
+// 已选择症状标签样式
+.selected-symptoms {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-bottom: 16px;
+
+  .ant-tag {
+    margin: 0;
+    padding: 4px 8px;
+    border-radius: 4px;
+    transition: all 0.3s;
+
+    &:hover {
+      background: rgba(24, 144, 255, 0.1);
+    }
+  }
+}
+
+// 动画
+@keyframes fadeIn {
+  from {
+    opacity: 0;
+    transform: translateY(-10px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+// 整体布局优化
+.border-1 {
+  margin-bottom: 16px;
+  padding: 16px;
+  border-radius: 8px;
+  background: #fff;
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+}
+
+.mb-8 {
+  margin-bottom: 32px;
+}
+
+.ml-4 {
+  margin-left: 16px;
+}
+
+.text-blue-600 {
+  color: #1890ff;
+}
+
+.text-red-600 {
+  color: #ff4d4f;
+}
+
+.font-bold {
+  font-weight: 600;
+}
+</style>

+ 159 - 0
src/components/Swiper.vue

@@ -0,0 +1,159 @@
+<script setup lang="ts">
+// 引入swiper组件
+import { Swiper, SwiperSlide } from 'swiper/vue';
+// 引入swiper样式(按需导入)
+import 'swiper/css';
+import 'swiper/css/pagination'; // 轮播图底面的小圆点
+import 'swiper/css/navigation'; // 轮播图两边的左右箭头
+import 'swiper/css/scrollbar'; // 轮播图的滚动条
+// 引入swiper核心和所需模块
+import { Autoplay, Pagination, Navigation, Scrollbar } from 'swiper';
+
+const modules = [Autoplay, Pagination, Navigation, Scrollbar];
+const props = defineProps<{ images: string[] }>();
+const slidesPerView = computed(() => Math.min(props.images?.length, 3));
+
+</script>
+<template>
+  <div class="home">
+    <swiper
+      :modules="modules"
+      :loop="true"
+      :slides-per-view="1"
+      :autoplay="{ delay: 4000, disableOnInteraction: false }"
+      navigation
+      :pagination="{ clickable: true }"
+      :scrollbar="{ draggable: true }"
+      :slidesPerView="slidesPerView"
+    >
+      <!-- loop可循环轮播,autoplay可自动播放 -->
+      <swiper-slide v-for="img in props.images" :key="img.date">
+        <div>
+          <span>{{ img?.date }}</span>
+          <div>
+            <a-image :src="img?.image" style="width: 200px; height: 290px" />
+          </div>
+        </div>
+      </swiper-slide>
+    </swiper>
+  </div>
+</template>
+<style scoped lang="scss">
+.home {
+  .swiper-slide {
+    //background-color: #bfc;
+    height: 200px;
+  }
+
+  .swiper-button-prev,
+  .swiper-button-next {
+    transform: translateY(-50%);
+  }
+}
+
+.pc-banner {
+  position: relative;
+
+  .swiper-container {
+    width: 90%;
+  }
+
+  .button {
+    width: 100%;
+    display: flex;
+    justify-content: space-between;
+    position: absolute;
+    top: 50%;
+
+    .swiper-button-next {
+      color: black;
+    }
+  }
+}
+
+@media only screen and (min-width: 1200px) {
+  .main {
+    //background: springgreen;
+  }
+}
+
+/*最大的宽度是1200px,小于1200px都适用于这里*/
+@media only screen and (max-width: 960px) {
+  .main {
+    width: 100%;
+  }
+}
+
+/*
+       大于min-width的宽度,小于max-width宽度
+       * */
+@media only screen and (min-width: 960px) and (max-width: 1200px) {
+  .main {
+    width: 100%;
+  }
+}
+
+.swiper-slide {
+  -webkit-transition: transform 1s;
+  -moz-transition: transform 1s;
+  -ms-transition: transform 1s;
+  -o-transition: transform 1s;
+  // -webkit-transform: scale(0.7);
+  // transform: scale(0.7);
+}
+
+.swiper-slide-active {
+  -webkit-transform: scale(1);
+  transform: scale(1);
+}
+
+.none-effect {
+  -webkit-transition: none;
+  -moz-transition: none;
+  -ms-transition: none;
+  -o-transition: none;
+}
+
+.swiper-slide {
+  img {
+    width: 100%;
+    display: block;
+  }
+}
+
+.button {
+  width: 100%;
+  margin: auto;
+  position: relative;
+  top: 25%;
+}
+
+@media screen and (max-width: 750px) {
+  .button {
+    width: 100%;
+  }
+}
+
+.swiper-button-prev {
+  width: 40px;
+  height: 40px;
+  border-radius: 50%;
+  background: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M0%2C22L22%2C0l4.2%2C4.2L8.4%2C22l17.8%2C17.8L22%2C44L0%2C22z'%20fill%3D'%23ffffff'%2F%3E%3C%2Fsvg%3E")
+    #c9c9ca center 50%/50% 50% no-repeat;
+}
+
+.swiper-button-next {
+  width: 40px;
+  height: 40px;
+  border-radius: 50%;
+  background: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M27%2C22L5%2C44l-4.2-4.2L18.6%2C22L0.8%2C4.2L5%2C0z'%20fill%3D'%23ffffff'%2F%3E%3C%2Fsvg%3E")
+    #c9c9ca center 50%/50% 50% no-repeat;
+}
+
+@media screen and (max-width: 750px) {
+  .button div {
+    width: 28px;
+    height: 28px;
+  }
+}
+</style>

+ 2 - 1
src/components/TagEdit.vue

@@ -19,7 +19,7 @@ const { loading, send: load } = useRequest(tagMethod, { immediate: false, initia
     formProps.data = { ...data };
   });
 const { loading: submitting, send: submit } = useRequest(tagEditMethod, { immediate: false }).onSuccess(({ data }) => {
-  emits('submit');
+    emits('submit');
 });
 
 const { data: tags, loading: tagsLoading } = useRequest(tagsSearchMethod, { initialData: { total: 0, data: [] } });
@@ -59,6 +59,7 @@ onBeforeMount(() => {
 });
 </script>
 <template>
+
   <vxe-form v-bind="formProps" v-on="formEmits" :loading>
     <template #active>
       <vxe-button type="submit" status="primary" content="提交" :loading="submitting"></vxe-button>

+ 154 - 0
src/components/ViewsEvaluation.vue

@@ -0,0 +1,154 @@
+<script setup lang="ts">
+import Swiper           from '@/components/Swiper.vue';
+import type { TagModel, PlainModel } from '@/model/system.model';
+import { tagEditMethod, tagMethod, tagsSearchMethod } from '@/request/api/system.api';
+import { useRequest } from 'alova/client';
+import { type VxeFormListeners, type VxeFormProps, type VxeGridProps, VxeUI } from 'vxe-pc-ui';
+import { statusLabel } from '@/model/options';
+
+type FormModel = Partial<TagModel>;
+type FollowModel = Partial<PlainModel>;
+
+const defaultModel = {};
+
+const props = defineProps<{ data: FormModel }>();
+// const plain = defineProps<{ data: FollowModel }>();
+const emits = defineEmits<{
+  submit: [data?: TagModel];
+}>();
+
+const { loading, send: load } = useRequest(tagMethod, {
+  immediate: false,
+  initialData: props.data ?? defaultModel,
+}).onSuccess(({ data }) => {
+  formProps.data = { ...data };
+});
+const { loading: submitting, send: submit } = useRequest(tagEditMethod, {
+  immediate: false,
+}).onSuccess(({ data }) => {
+  emits('submit');
+});
+
+
+
+const gridOptions = reactive<VxeGridProps<FormModel>>({
+  border: true,
+  showOverflow: true,
+  width: window.innerWidth - 256,
+  // height: 800,
+  showHeader: false,
+  columns: [
+    {field:'title', title: '-'}
+  ],
+  data: [
+    {title: '日期', key: 'date', col0: '-'},
+    {title: '好转', key: 'k1'},
+    {title: '恶化', key: 'k2'},
+    {title: '面', key: 'face'},
+  ],
+});
+
+function load2(data = []) {
+  const columns = gridOptions.columns;
+  const rows = gridOptions.data;
+  let col = columns.length;
+  for (const item of data) {
+    const field = `col${col}`;
+    columns.push({
+      field ,
+      title: `标题${col}`,
+      slots: { default: 'cell' }
+    });
+    col++;
+    for (let r = 0; r < rows.length; r++) {
+      rows[r][field] = item[rows[r].key]
+    }
+  }
+}
+
+load2(Array.from({ length: 4 }, (_,i)=>({
+  date: `2025/01/0${i}`,
+  k1: `第${i+1}项,col2字段`,
+  k2: `第${i+1}项,col3字段`,
+  face: `https://vxeui.com/resource/img/fj577.jpg`,
+})));
+
+
+
+
+
+onBeforeMount(() => {
+  if (props.data?.tagId) load(props.data);
+});
+const value = ref<number>(1);
+const radioStyle = reactive({
+  display: 'flex',
+  height: '30px',
+  lineHeight: '30px',
+});
+function cellClickEvent({row,column},model?: TagModel) {
+  console.log(row,column,row.title,row.title === '面',"row===");
+  if (row.title === '面') { // 判断当表头等于姓名字段时执行一个方法
+    VxeUI.modal.open({
+      escClosable: true,
+      destroyOnClose: true,
+      id: `swiper`,
+      remember: true,
+      storage: true,
+      width: window.innerWidth - 256,
+      slots: {
+        default() {
+          return h(Swiper, <any> {
+            data: model, onSubmit(data: TagModel) {
+              refresh(page.value);
+              VxeUI.modal.close(`swiper`);
+            },
+          });
+        },
+      },
+    });
+  }
+}
+</script>
+<template>
+  <div>
+    <div class="flex font-bold border-1 border-gray border-solid pt-1 pb-1">
+      <div class="border-r border-r-2 border-r-gray border-r-solid pr-3 flex"><div class="mr-2">基本信息</div> <div class="mr-2">2024.09.29</div> 孙明 男 28岁</div>
+      <div class="border-r border-r-2 border-r-gray border-r-solid pr-3 pl-3">诊断:心火旺盛</div>
+      <div class="pl-3">症状:难以入眠、头晕、心慌、麦轩、小便黄</div>
+    </div>
+
+    <vxe-grid class="reverse-table" v-bind="gridOptions"  @cell-click="cellClickEvent">
+      <template #cell="{ row, rowIndex, column }">
+<!--        {{row}}-{{rowIndex}}-->
+        <a-image v-if="rowIndex === 3" :src="row[column.field]"></a-image>
+        <template v-else>{{row[column.field]}}</template>
+      </template>
+
+      <template #right>
+        <div class="pl-6 pr-4">
+        <div style="width: 160px" class="border-1 border-gray font-bold mt-2 mb-3 text-sm">随访结果评估</div>
+        <div style="width: 160px" class="border-1 border-gray font-bold mt-2 mb-3">疾病转归</div>
+        <div class="mb-8 indent-6">
+        好转
+        </div>
+        <div class="font-bold mt-2 mb-1">备注:</div>
+        <p class="indent-6 w-30 text-pretty">可正常入眠,舌和耳边均有好转,但睡眠还易夜醒</p>
+        <div class="font-bold mb-4 mt-15">下一步处置</div>
+        <div class="indent-6 font-bold">
+         复诊
+        </div>
+        <div class="mt-10 flex justify-end flex-col items-center">
+        <div>智医生</div>
+          <div>2024.10.05</div>
+        </div>
+        </div>
+      </template>
+    </vxe-grid>
+  </div>
+</template>
+<style scoped lang="scss">
+
+
+
+</style>

+ 10 - 4
src/libs/vxe/plugin.ts

@@ -2,7 +2,7 @@ import type { App } from 'vue';
 
 import {
   VxeButton,
-  VxeButtonGroup,
+  VxeButtonGroup, VxeDatePicker,
   VxeDrawer,
   VxeForm,
   VxeFormDesign,
@@ -23,6 +23,9 @@ import {
   VxeTree,
   VxeTreeSelect,
   VxeUI,
+  VxeNumberInput,
+  VxeCheckboxGroup,
+  VxeCheckbox, VxeRow, VxeCol,
 } from 'vxe-pc-ui';
 
 import zhCN from 'vxe-pc-ui/lib/language/zh-CN';
@@ -43,16 +46,19 @@ function LazyVxeUIForForm(app: App) {
   app.use(VxeTreeSelect);
   app.use(VxeTree);
   app.use(VxeRadioGroup);
-
+  app.use(VxeDatePicker);
   app.use(VxeList);
-
-
+  app.use(VxeNumberInput);
   app.use(VxeButton);
   app.use(VxeButtonGroup);
   app.use(VxeIcon);
   app.use(VxeLoading);
   // 分页
   app.use(VxePager);
+  app.use(VxeCheckbox);
+  app.use(VxeCheckboxGroup);
+  app.use(VxeRow);
+  app.use(VxeCol);
 
   app.use(VxeModal);
   app.use(VxeDrawer);

+ 66 - 0
src/model/follow.model.ts

@@ -0,0 +1,66 @@
+export interface TaskQuery {
+  patientName: string; //患者姓名
+  progressArray: string[]; //随访任务进度
+  followupPlanId: number; //随访计划id
+  date: string; // 随访日期
+  medicalDoctor: string; // 随访医生
+}
+
+export interface TaskModel {
+  followupTaskGroupId: string; //随访任务组id
+  id: string; //随访任务id
+  followupPlanName: string; //随访计划名称
+  patientName: string; //姓名
+  sex: string; //性别
+  age: number; //年龄
+  phone: string; //手机号码
+  progress: '0' | '1' | '2'; //随访任务进度
+  medicalDoctor: string; // 就诊医生
+  tagNames: string; //标签
+  startDate: string; //开始日期
+  endDate: string; //截止日期
+}
+
+export interface TaskContentModel {
+  improveSyndrome: '';
+  worseSyndrome: '';
+  isHaveNewSyndrome: '';
+  newSyndrome: '';
+  otherDesc: '';
+  upImg: '';
+  downImg: '';
+  faceImg: '';
+}
+
+export interface EvaluateQuery {
+  status: '0' | '1'; // 评估状态
+  result: string; //评估结果
+  followupPlanName: string; //随访计划
+  followupPlanId: number; //随访计划id
+  patientName: string; //患者姓名
+  evaluateBy: string; //评估人
+  medicalDoctor: string; //就诊医生
+}
+
+export interface EvaluationModel {
+  id: string;
+  followupPlanName: string; //随访计划名字
+  patientName: string; //患者名字
+  sex: string; //性别
+  age: string; // 年龄
+  medicalTime: string; //就诊时间
+  medicalDoctor: string; //就诊医生
+  phone: string; //手机号码
+  tagNames: string; //标签
+  status: '0' | '1'; // 评估状态
+  result: string; //评估结果
+  evaluateBy: string; //评估人
+  evaluateProgress: string; //评估进度
+  evaluate: {
+    evaluateDeal: string; //评估结果
+  };
+
+  evaluateSituation: string; //	疾病转归
+  evaluateDesc: string; //评估备注
+  evaluateDeal: string; //疾病处置
+}

+ 1 - 0
src/model/index.ts

@@ -2,6 +2,7 @@ export * from './account.model';
 export * from './people.model';
 export * from './patient.model';
 export * from './report.model';
+export * from './follow.model';
 
 
 export type List<T> = { total: number; data: T[] };

+ 51 - 0
src/model/options.ts

@@ -3,3 +3,54 @@ export const statusOptions = [
   { label: '已启用', value: '0' },
   { label: '未启用', value: '1' },
 ];
+export const planStatus = [
+  { label: '状态', value: void 0 },
+  { label: '未开始', value: '0' },
+  { label: '进行中', value: '1' },
+  { label: '已结束', value: '2' },
+];
+export const enabledStatus = [
+  { label: '状态', value: void 0 },
+  { label: '启用', value: '0' },
+  { label: '停用', value: '1' },
+];
+export const progressStatus = [
+  { label: '未开始', value: '0' },
+  { label: '未完成', value: '1' },
+  { label: '已完成', value: '2' },
+];
+export const evaluateStatus = [
+  { label: '请选择', value: void 0 },
+  { label: '未完成', value: '0' },
+  { label: '已完成', value: '1' },
+];
+export const symptomStatus = [
+  { label: '痊愈', value: '1' },
+  { label: '好转', value: '2' },
+  { label: '无变化', value: '3' },
+  { label: '恶化', value: '4' },
+];
+export const nextStatus = [
+  // { label: '请选择', value: void 0 },
+  { label: '复诊', value: '1' },
+  { label: '中医调养', value: '2' },
+];
+export const patientLabel = [
+  { label: '患者标签', value: void 0 },
+  { label: '住院', value: '0' },
+  { label: '更年期', value: '1' },
+];
+export const statusLabel = [
+  { value: 1001, label: 'table' },
+  { value: 1002, label: 'grid' },
+  { value: 1003, label: 'button' },
+  { value: 1004, label: 'toolbar' },
+  { value: 1005, label: 'tooltip' },
+  { value: 1006, label: 'pager' },
+  { value: 1007, label: 'print' },
+  { value: 1008, label: 'export' },
+  { value: 1009, label: 'import' },
+  { value: 1010, label: 'select' },
+  { value: 1012, label: 'checkbox' },
+  { value: 1013, label: 'group' },
+];

+ 35 - 0
src/model/system.model.ts

@@ -46,3 +46,38 @@ export interface TagModel {
   parentId?: string;
   parentName?: string;
 }
+
+
+
+// export interface PlanQuery {
+//   id?: string;
+//   status: '0' | '1';
+//   name: string;
+//   label?: string;
+//   enabled: boolean;
+// }
+
+export interface PlanModel {
+  id: string;
+  name: string;
+  department: string; //科室
+  frequency?: number;
+  institutions: string; //机构
+  founder: string; //创建人
+  time: string; //创建时间
+  patient: string; //筛选病人
+  progress: '0' | '1' | '2'; //计划状态
+  status: '0' | '1'; //启用状态
+  label?: string;
+  enabled: boolean;
+  tagIds: string[] | string;
+  filter: {
+    tagId:string;
+    tagIds: string[] | string;
+    tagNames: string[] | string;
+    departments: string[] | string;
+    doctors: string[] | string;
+  };
+}
+
+export type PlanQuery = Partial<PlanModel>;

+ 278 - 0
src/pages/index/follow/assessment.vue

@@ -0,0 +1,278 @@
+<script setup lang="ts">
+import Evaluation from '@/components/Evaluation.vue';
+import {
+  nextStatus,
+  evaluateStatus,
+} from '@/model/options';
+
+import type { TagModel, TagQuery } from '@/model/system.model';
+import type { EvaluateQuery, EvaluationModel } from '@/model/follow.model';
+import {
+  tagDeleteMethod,
+  tagUpdateStatusMethod,
+} from '@/request/api/system.api';
+import { planMethod, EvaluateMethod } from '@/request/api/follow.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 { data: tags, loading: tagsLoading } = useRequest(EvaluateMethod, {
+  initialData: { total: 0, data: [] },
+});
+// 获取随访计划
+const { data: plans, loading: planLoading } = useRequest(planMethod, {
+  initialData: { total: 0, data: [] },
+});
+const model = shallowRef<EvaluateQuery>();
+const searchFormProps = reactive<VxeFormProps<EvaluateQuery>>({
+  titleWidth: 110,
+  titleAlign: 'right',
+  titleColon: true,
+  data: {},
+  items: [
+    {
+      field: 'evaluateProgress',
+      title: '评估状态',
+      span: 6,
+      itemRender: { name: 'VxeSelect', options: evaluateStatus },
+    },
+    {
+      field: 'evaluate.evaluateDeal',
+      title: '评估结果',
+      span: 6,
+      itemRender: { name: 'VxeSelect', options: nextStatus },
+    },
+    {
+      field: 'followupPlanId',
+      title: '随访计划',
+      span: 4,
+      itemRender: {
+        name: 'VxeSelect',
+        props: {
+          placeholder: '随访计划',
+          loading: planLoading,
+          options: computed(() => plans.value.data),
+          optionProps: { value: 'id', label: 'name' },
+          clearable: true,
+          multiple: false,
+          filterable: true,
+        },
+      },
+    },
+    { field: 'patientName', title: '患者姓名', span: 6, itemRender: { name: 'VxeInput' } },
+    { field: 'evaluateBy', title: '评估人', span: 6, itemRender: { name: 'VxeInput' } },
+    { field: 'medicalDoctor', title: '接诊医生', span: 6, itemRender: { name: 'VxeInput' } },
+    {
+      span: 6,
+      itemRender: {
+        name: 'VxeButtonGroup',
+        options: [
+          { type: 'submit', content: '查询', status: 'primary' },
+          { type: 'reset', content: '重置' },
+        ],
+      },
+    },
+  ],
+});
+const searchFormEmits: VxeFormListeners<TagQuery> = {
+  submit({ data }) {
+    model.value = { ...data };
+  },
+  reset({ data }) {
+    model.value = { ...data };
+  },
+};
+
+const gridRef = ref<VxeGridInstance<EvaluationModel>>();
+const gridOptions = reactive<VxeGridProps<EvaluationModel>>({
+  id: 'tag-list',
+  border: true,
+  showOverflow: true,
+  height: 'auto',
+  autoResize: false,
+  syncResize: true,
+  scrollY: { enabled: true, gt: 0 },
+  toolbarConfig: {
+    custom: true,
+    zoom: true,
+  },
+  columnConfig: {
+    resizable: true,
+  },
+  customConfig: {
+    storage: true,
+  },
+  columns: [
+    { field: 'followupPlanName', title: '随访计划' },
+    { field: 'patientName', title: '姓名' },
+    { field: 'sex', title: '性别', slots: { default: 'sex' } },
+    { field: 'age', title: '年龄' },
+    { field: 'medicalTime', title: '就诊时间' },
+    { field: 'medicalDoctor', title: '接诊医生' },
+    { field: 'phone', title: '手机号码' },
+    { field: 'tagNames', title: '标签' },
+    { field: 'evaluateProgress', title: '评估状态', slots: { default: 'status' } },
+    { field: 'result', title: '评估结果', slots: { default: 'result' } },
+    { field: 'evaluateBy', title: '评估人' },
+    {
+      title: '操作',
+      align: 'center',
+      width: 120,
+      slots: { default: 'btn' },
+    },
+  ],
+  data: [],
+});
+const gridEvents: VxeGridListeners = {};
+
+const { loading, page, pageSize, total, onSuccess, replace, refresh, remove } = usePagination(
+  (page, size) => EvaluateMethod(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 updateTagStatus(model: TagModel, index: number, status: TagModel['status']) {
+  const { id, name } = model;
+  const label = { '1': '停用', '0': '启用' }[status];
+  VxeUI.modal.confirm({
+    title: `启用状态`,
+    content: `确认要 ${label} ${name} 标签吗?`,
+    showClose: false,
+    onConfirm() {
+      tagUpdateStatusMethod({ id, status }).then(() => {
+        notification.success({
+          message: `${label}标签: ${name}`,
+          description: '操作成功',
+        });
+        model.status = status;
+        replace(model, index);
+      });
+    },
+  });
+}
+
+function deleteTag(model: TagModel, index: number) {
+  const { name } = model;
+  VxeUI.modal.confirm({
+    title: `删除标签`,
+    content: `确认要删除 ${name} 标签吗?`,
+    showClose: false,
+    onConfirm() {
+      tagDeleteMethod(model).then(() => {
+        notification.success({
+          message: `删除标签: ${name}`,
+          description: '操作成功',
+        });
+        refresh(page.value);
+      });
+    },
+  });
+}
+
+function assessment(row, rowIndex) {
+  VxeUI.modal.open({
+    title: '随访评估详情',
+    escClosable: true,
+    destroyOnClose: true,
+    id: `assess-modal`,
+    remember: true,
+    storage: true,
+    showClose: true,
+    showHeader: true,
+    width: 1100,
+    height:800,
+    slots: {
+      default() {
+        return h(Evaluation, <any>{
+          data: row,
+          onSubmit(data: EvaluationModel) {
+            refresh(page.value);
+            VxeUI.modal.close(`assess-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 #btn="{ row, rowIndex }">
+          <div v-if="row.evaluateProgress === '1'">
+            <vxe-button @click="assessment(row, rowIndex)">查看</vxe-button>
+          </div>
+          <div v-else>
+            <vxe-button @click="assessment(row, rowIndex)">评估</vxe-button>
+          </div>
+        </template>
+        <template #result="{ row }"
+          >{{ row.evaluate ? (row.evaluate?.evaluateDeal === '1' ? '复诊' : '中医调养') : '' }}
+        </template>
+        <template #sex="{ row }">{{ row.sex === '1' ? '女' : '男' }}</template>
+        <template #status="{ row }"
+          >{{ row.evaluateProgress === '1' ? '已完成' : '未完成' }}
+        </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>

+ 339 - 0
src/pages/index/follow/plan.vue

@@ -0,0 +1,339 @@
+<script setup lang="ts">
+import Enabled from '@/components/Enabled.vue';
+import { planStatus, enabledStatus } from '@/model/options';
+
+import type { PlanModel, PlanQuery } from '@/model/system.model';
+
+// 接口数据
+import {
+  planDeleteMethod,
+  planMethod,
+  planUpdateStatusMethod,
+  tagsSearchMethod,
+} from '@/request/api/follow.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 { data: tags, loading: tagsLoading } = useRequest(planMethod, {
+  initialData: { total: 0, data: [] },
+});
+// 获取患者标签
+const { data: tagData, loading: tagDataLoading } = useRequest(tagsSearchMethod, {
+  initialData: { total: 0, data: [] },
+});
+
+const model = shallowRef<PlanQuery>();
+const searchFormProps = reactive<VxeFormProps<PlanQuery>>({
+  titleWidth: 100,
+  titleAlign: 'right',
+  titleColon: true,
+  data: {},
+  items: [
+    {
+      field: 'progress',
+      title: '计划状态',
+      span: 4,
+      itemRender: {
+        name: 'VxeSelect',
+        options: planStatus,
+        props: { placeholder: '状态' },
+      },
+    },
+    {
+      field: 'name',
+      title: '计划名称',
+      span: 4,
+      itemRender: { name: 'VxeInput', props: { placeholder: '请输入计划名称' } },
+    },
+    {
+      field: 'tagIds',
+      title: '患者标签',
+      span: 4,
+      itemRender: {
+        name: 'VxeSelect',
+        props: {
+          placeholder: '患者标签',
+          loading: tagDataLoading,
+          options: computed(() => tagData.value.data),
+          optionProps: { value: 'id', label: 'name' },
+          clearable: true,
+          multiple: true,
+          filterable: true,
+        },
+      },
+    },
+    {
+      field: 'status',
+      title: '启用状态',
+      span: 4,
+      itemRender: {
+        name: 'VxeSelect',
+        options: enabledStatus,
+        props: { placeholder: '状态' },
+      },
+    },
+    {
+      span: 8,
+      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') {
+              // 新增
+              editPlan();
+            }
+          },
+        },
+      },
+    },
+  ],
+});
+const searchFormEmits: VxeFormListeners<PlanQuery> = {
+  // 查询随访计划
+  submit({ data }) {
+    model.value = { ...data };
+  },
+  // 重置
+  reset({ data }) {
+    model.value = { ...data };
+  },
+};
+const gridRef = ref<VxeGridInstance<PlanModel>>();
+const gridOptions = reactive<VxeGridProps<PlanModel>>({
+  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: 'name', title: '计划名称' },
+    { field: 'filter.tagNames', title: '患者标签' },
+    { field: 'filter.departments', title: '就诊科室' },
+    { field: 'frequency', title: '随访次数' },
+    { field: 'institutionName', title: '机构' },
+    { field: 'createBy', title: '创建人' },
+    { field: 'createTime', title: '创建时间' },
+    { field: 'patient', title: '筛选病人', slots: { default: 'patients' } },
+    {
+      field: 'progress',
+      title: '计划状态',
+      slots: { default: 'cell' },
+    },
+    {
+      field: 'status',
+      title: '启用状态',
+      align: 'center',
+      minWidth: 90,
+      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);
+          },
+        },
+      },
+    },
+    {
+      title: '操作',
+      align: 'center',
+      width: 120,
+      cellRender: {
+        name: 'VxeButtonGroup',
+        props: {
+          mode: 'text',
+        },
+        options: [
+          { content: '编辑', status: 'warning', name: 'editPlan' },
+          { content: '删除', status: 'error', name: 'deletePlan' },
+        ],
+        events: {
+          click({ row, rowIndex }, { name }) {
+            let method;
+            if (name === 'editPlan') {
+              method = editPlan;
+            } else if (name === 'deletePlan') {
+              method = deletePlan;
+            }
+            method?.(row, rowIndex);
+          },
+        },
+      },
+    },
+  ],
+  data: [],
+});
+const gridEvents: VxeGridListeners = {};
+
+const { loading, page, pageSize, total, onSuccess, replace, refresh, remove } = usePagination(
+  (page, size) => planMethod(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: PlanModel, index: number, status: PlanModel['status']) {
+  const { id, name } = model;
+  const label = { '1': '停用', '0': '启用' }[status];
+  VxeUI.modal.confirm({
+    title: `启用状态`,
+    content: `确认要 ${label} ${name} 计划吗?`,
+    showClose: false,
+    onConfirm() {
+      planUpdateStatusMethod({ id, status }).then(() => {
+        notification.success({
+          message: `${label}计划: ${name}`,
+          description: '操作成功',
+        });
+        model.status = status;
+        replace(model, index);
+      });
+    },
+  });
+}
+
+function deletePlan(model: PlanModel, index: number) {
+  const { name } = model;
+  VxeUI.modal.confirm({
+    title: `删除计划`,
+    content: `确认要删除 ${name} 计划吗?`,
+    showClose: false,
+    onConfirm() {
+      planDeleteMethod(model).then(() => {
+        notification.success({
+          message: `删除计划: ${name}`,
+          description: '操作成功',
+        });
+        refresh(page.value);
+      });
+    },
+  });
+}
+
+function editPlan(model?: PlanModel, index?: number) {
+  VxeUI.modal.open({
+    title: model?.id ? `修改随访计划` : `新增随访计划`,
+    height: 800,
+    width: 850,
+    position: {
+      top: Math.min(100, window.innerHeight * 0.1),
+    },
+    escClosable: true,
+    destroyOnClose: true,
+    id: `plan-modal`,
+    remember: true,
+    storage: true,
+    slots: {
+      default() {
+        return h(Enabled, <any>{
+          data: model,
+          onSubmit(data: PlanModel) {
+            refresh(page.value);
+            VxeUI.modal.close(`plan-modal`);
+          },
+        });
+      },
+    },
+  });
+}
+</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 ? '已结束' : '未开始' }}
+        </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>

+ 291 - 0
src/pages/index/follow/task.vue

@@ -0,0 +1,291 @@
+<script setup lang="ts">
+import Follow from '@/components/Follow.vue';
+import { progressStatus } from '@/model/options';
+
+import type { TaskQuery, TaskModel } from '@/model/follow.model';
+
+import { planMethod, taskMethod, taskRemindStatusMethod } from '@/request/api/follow.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 { data: tags, loading: tagsLoading } = useRequest(taskMethod, {
+  initialData: { total: 0, data: [] },
+});
+// 获取随访计划
+const { data: plans, loading: planLoading } = useRequest(planMethod, {
+  initialData: { total: 0, data: [] },
+});
+const model = shallowRef<TaskQuery>();
+const searchFormProps = reactive<VxeFormProps<TaskQuery>>({
+  titleWidth: 110,
+  titleAlign: 'right',
+  titleColon: true,
+  data: {},
+  items: [
+    {
+      field: 'progressArray',
+      title: '随访状态',
+      span: 6,
+      slots: { default: 'progress' },
+    },
+    {
+      field: 'followupPlanId',
+      title: '随访计划',
+      span: 4,
+      itemRender: {
+        name: 'VxeSelect',
+        props: {
+          placeholder: '随访计划',
+          loading: planLoading,
+          options: computed(() => plans.value.data),
+          optionProps: { value: 'id', label: 'name' },
+          clearable: true,
+          multiple: false,
+          filterable: true,
+        },
+      },
+    },
+    {
+      field: 'patientName',
+      title: '患者姓名',
+      span: 6,
+      itemRender: {
+        name: 'VxeInput',
+      },
+    },
+
+    {
+      span: 6,
+      itemRender: {
+        name: 'VxeButtonGroup',
+        options: [
+          { type: 'submit', content: '查询', status: 'primary' },
+          { type: 'reset', content: '重置' },
+        ],
+      },
+    },
+    {
+      field: 'date',
+      title: '预定随访日期',
+      span: 6,
+      itemRender: {
+        name: 'VxeDatePicker',
+        props: { placeholder: '请选择日期', type: 'datetime' },
+      },
+    },
+    {
+      field: 'medicalDoctor',
+      title: '接诊医生',
+      span: 6,
+      itemRender: {
+        name: 'VxeInput',
+      },
+    },
+  ],
+});
+const searchFormEmits: VxeFormListeners<TaskQuery> = {
+  submit({ data }) {
+    model.value = { ...data, progressArray: progressArray.value };
+    submit();
+  },
+  reset({ data }) {
+    progressArray.value = ['0', '1'];
+    model.value = { ...data };
+  },
+};
+
+const gridRef = ref<VxeGridInstance<TaskModel>>();
+const gridOptions = reactive<VxeGridProps<TaskModel>>({
+  id: 'tag-list',
+  border: true,
+  showOverflow: true,
+  height: 'auto',
+  autoResize: false,
+  syncResize: true,
+  scrollY: { enabled: true, gt: 0 },
+  toolbarConfig: {
+    custom: true,
+    zoom: true,
+  },
+  // data:
+  columnConfig: {
+    resizable: true,
+  },
+  customConfig: {
+    storage: true,
+  },
+  columns: [
+    { field: 'followupTaskName', title: '随访计划' },
+    { field: 'patientName', title: '姓名' },
+    { field: 'sex', title: '性别', slots: { default: 'sex' } },
+    { field: 'age', title: '年龄' },
+    { field: 'progress', title: '随访状态', slots: { default: 'cell' } },
+    { field: 'medicalDoctor', title: '接诊医生' },
+    { field: 'phone', title: '手机号码' },
+    { field: 'tagNames', title: '标签' },
+    {
+      title: '操作',
+      align: 'center',
+      width: 120,
+      slots: { default: 'btn' },
+    },
+  ],
+  data: [],
+});
+const gridEvents: VxeGridListeners = {};
+
+const {
+  data,
+  loading,
+  page,
+  pageSize,
+  total,
+  onSuccess,
+  replace,
+  refresh,
+  remove,
+  send: submit,
+} = usePagination((page, size) => taskMethod(page, size, model.value), {
+  initialData: { data: [], total: 0 },
+  initialPage: 1,
+  initialPageSize: 100,
+  immediate: true,
+});
+
+const { loadings, pages, pageSizes, totals } = usePagination(
+  (page, size) => planMethod(page, size),
+  {
+    initialData: { data: [], total: 0 },
+    initialPage: 1,
+    initialPageSize: 100,
+    immediate: false,
+  }
+);
+
+onSuccess(({ data: { data } }) => {
+  gridRef.value?.loadData(data);
+});
+
+onMounted(() => {
+  model.value = toRaw(searchFormProps.data);
+});
+
+function remind(row, rowIndex) {
+  VxeUI.modal.confirm({
+    title: `提醒`,
+    content: `是否要提醒 ${row.followupPlanName}?`,
+    showClose: false,
+    onConfirm() {
+      taskRemindStatusMethod({ id: row.id }).then(() => {
+        notification.success({
+          message: `提醒计划: ${row.followupPlanName} `,
+          description: '提醒成功',
+        });
+        replace(model, rowIndex);
+      });
+    },
+  });
+}
+
+function fillFollow(row, rowIndexr) {
+  VxeUI.modal.open({
+    title: row.progress === '1' ? '填写随访' : '查看',
+    width: 1100,
+    height: 800,
+    escClosable: true,
+    destroyOnClose: true,
+    id: `follow-modal`,
+    remember: true,
+    storage: true,
+    position: {
+      top: Math.min(100, window.innerHeight * 0.1),
+    },
+    slots: {
+      default() {
+        return h(Follow, <any>{
+          data: row,
+          onSubmit(data: TaskModel) {
+            refresh(page.value);
+          },
+        });
+      },
+    },
+  });
+}
+
+const progressArray = ref(['0', '1']);
+</script>
+<template>
+  <div class="page-container flex flex-col">
+    <header class="flex-none">
+      <vxe-form v-bind="searchFormProps" v-on="searchFormEmits">
+        <template #progress>
+          <vxe-checkbox-group
+            v-model="progressArray"
+            :options="progressStatus"
+          ></vxe-checkbox-group>
+        </template>
+      </vxe-form>
+    </header>
+    <main class="flex-auto overflow-hidden">
+      <vxe-grid ref="gridRef" v-bind="gridOptions" v-on="gridEvents" :loading="loading">
+        <template #btn="{ row, rowIndex }">
+          <div v-if="row.progress === '1'">
+            <vxe-button @click="remind(row, rowIndex)" v-show="row.isCanRemind==='Y'">提醒</vxe-button>
+            <vxe-button @click="fillFollow(row, rowIndex)">填写随访</vxe-button>
+          </div>
+          <div v-else>
+            <vxe-button @click="fillFollow(row, rowIndex)">查看</vxe-button>
+          </div>
+        </template>
+        <template #sex="{ row }">{{ row.sex === '1' ? '女' : '男' }}</template>
+        <template #cell="{ row }"
+          >{{ row.progress === '1' ? '未完成' : row.progress === '2' ? '已完成' : '未开始' }}
+        </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>

+ 342 - 0
src/pages/index/tcmRecuperation/institution.vue

@@ -0,0 +1,342 @@
+<script setup lang="ts">
+import Enabled from '@/components/Enabled.vue';
+import {  planStatus, enabledStatus } from '@/model/options';
+
+import type { PlanModel, PlanQuery } from '@/model/system.model';
+
+// 接口数据
+import {
+  planDeleteMethod,
+  planMethod,
+  planUpdateStatusMethod,
+  tagsSearchMethod,
+  DoctorMethod,
+  planEditMethod,
+} from '@/request/api/follow.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 { data: tags, loading: tagsLoading } = useRequest(planMethod, {
+  initialData: { total: 0, data: [] },
+});
+// 获取患者标签
+const { data: tagData, loading: tagDataLoading } = useRequest(tagsSearchMethod, {
+  initialData: { total: 0, data: [] },
+});
+
+const model = shallowRef<PlanQuery>();
+const searchFormProps = reactive<VxeFormProps<PlanQuery>>({
+  titleWidth: 100,
+  titleAlign: 'right',
+  titleColon: true,
+  data: {},
+  items: [
+    {
+      field: 'progress',
+      title: '计划状态',
+      span: 4,
+      itemRender: {
+        name: 'VxeSelect',
+        options: planStatus,
+        props: { placeholder: '状态' },
+      },
+    },
+    {
+      field: 'name',
+      title: '计划名称',
+      span: 4,
+      itemRender: { name: 'VxeInput', props: { placeholder: '请输入计划名称' } },
+    },
+    {
+      field: 'label',
+      title: '患者标签',
+      span: 4,
+      itemRender: {
+        name: 'VxeSelect',
+        props: {
+          placeholder: '患者标签',
+          loading: tagDataLoading,
+          options: computed(() => tagData.value.data),
+          optionProps: { value: 'id', label: 'name' },
+          clearable: true,
+          multiple: false,
+          filterable: true,
+        },
+      },
+    },
+    {
+      field: 'status',
+      title: '启用状态',
+      span: 4,
+      itemRender: {
+        name: 'VxeSelect',
+        options: enabledStatus,
+        props: { placeholder: '状态' },
+      },
+    },
+    {
+      span: 8,
+      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') {
+              console.log('新增');
+              // 新增
+              editPlan();
+            }
+          },
+        },
+      },
+    },
+  ],
+});
+const searchFormEmits: VxeFormListeners<PlanQuery> = {
+  // 查询随访计划
+  submit({ data }) {
+    model.value = { ...data };
+  },
+  // 重置
+  reset({ data }) {
+    model.value = { ...data };
+  },
+};
+const gridRef = ref<VxeGridInstance<PlanModel>>();
+const gridOptions = reactive<VxeGridProps<PlanModel>>({
+  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: 'name', title: '计划名称' },
+    { field: 'patientsLabel', title: '患者标签' },
+    { field: 'department', title: '就诊科室' },
+    { field: 'frequency', title: '随访次数' },
+    { field: 'institutionName', title: '机构' },
+    { field: 'createBy', title: '创建人' },
+    { field: 'createTime', title: '创建时间' },
+    { field: 'patient', title: '筛选病人', slots: { default: 'patients' } },
+    {
+      field: 'progress',
+      title: '计划状态',
+      slots: { default: 'cell' },
+    },
+    {
+      field: 'status',
+      title: '启用状态',
+      align: 'center',
+      minWidth: 90,
+      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);
+          },
+        },
+      },
+    },
+    {
+      title: '操作',
+      align: 'center',
+      width: 120,
+      cellRender: {
+        name: 'VxeButtonGroup',
+        props: {
+          mode: 'text',
+        },
+        options: [
+          { content: '编辑', status: 'warning', name: 'editPlan' },
+          { content: '删除', status: 'error', name: 'deletePlan' },
+        ],
+        events: {
+          click({ row, rowIndex }, { name }) {
+            let method;
+            if (name === 'editPlan') {
+              method = editPlan;
+            } else if (name === 'deletePlan') {
+              method = deletePlan;
+            }
+            method?.(row, rowIndex);
+          },
+        },
+      },
+    },
+  ],
+  data: [],
+});
+const gridEvents: VxeGridListeners = {};
+
+const { loading, page, pageSize, total, onSuccess, replace, refresh, remove } = usePagination(
+  (page, size) => planMethod(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: PlanModel, index: number, status: PlanModel['status']) {
+  const { id, name } = model;
+  const label = { '1': '停用', '0': '启用' }[status];
+  VxeUI.modal.confirm({
+    title: `启用状态`,
+    content: `确认要 ${label} ${name} 计划吗?`,
+    showClose: false,
+    onConfirm() {
+      planUpdateStatusMethod({ id, status }).then(() => {
+        notification.success({
+          message: `${label}计划: ${name}`,
+          description: '操作成功',
+        });
+        model.status = status;
+        replace(model, index);
+      });
+    },
+  });
+}
+
+function deletePlan(model: PlanModel, index: number) {
+  const { name } = model;
+  VxeUI.modal.confirm({
+    title: `删除计划`,
+    content: `确认要删除 ${name} 计划吗?`,
+    showClose: false,
+    onConfirm() {
+      planDeleteMethod(model).then(() => {
+        notification.success({
+          message: `删除计划: ${name}`,
+          description: '操作成功',
+        });
+        refresh(page.value);
+      });
+    },
+  });
+}
+
+function editPlan(model?: PlanModel, index?: number) {
+  VxeUI.modal.open({
+    title: model?.id ? `修改随访计划` : `新增随访计划`,
+    height: 800,
+    width: 800,
+    position: {
+      top: Math.min(100, window.innerHeight * 0.1),
+    },
+    escClosable: true,
+    destroyOnClose: true,
+    id: `plan-modal`,
+    remember: true,
+    storage: true,
+    slots: {
+      default() {
+        return h(Enabled, <any>{
+          data: model,
+          onSubmit(data: PlanModel) {
+            refresh(page.value);
+            VxeUI.modal.close(`plan-modal`);
+          },
+        });
+      },
+    },
+  });
+}
+</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 ? '已结束' : '未开始' }}
+        </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>

+ 342 - 0
src/pages/index/tcmRecuperation/preserve.vue

@@ -0,0 +1,342 @@
+<script setup lang="ts">
+import Enabled from '@/components/Enabled.vue';
+import {  planStatus, enabledStatus } from '@/model/options';
+
+import type { PlanModel, PlanQuery } from '@/model/system.model';
+
+// 接口数据
+import {
+  planDeleteMethod,
+  planMethod,
+  planUpdateStatusMethod,
+  tagsSearchMethod,
+  DoctorMethod,
+  planEditMethod,
+} from '@/request/api/follow.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 { data: tags, loading: tagsLoading } = useRequest(planMethod, {
+  initialData: { total: 0, data: [] },
+});
+// 获取患者标签
+const { data: tagData, loading: tagDataLoading } = useRequest(tagsSearchMethod, {
+  initialData: { total: 0, data: [] },
+});
+
+const model = shallowRef<PlanQuery>();
+const searchFormProps = reactive<VxeFormProps<PlanQuery>>({
+  titleWidth: 100,
+  titleAlign: 'right',
+  titleColon: true,
+  data: {},
+  items: [
+    {
+      field: 'progress',
+      title: '计划状态',
+      span: 4,
+      itemRender: {
+        name: 'VxeSelect',
+        options: planStatus,
+        props: { placeholder: '状态' },
+      },
+    },
+    {
+      field: 'name',
+      title: '计划名称',
+      span: 4,
+      itemRender: { name: 'VxeInput', props: { placeholder: '请输入计划名称' } },
+    },
+    {
+      field: 'label',
+      title: '患者标签',
+      span: 4,
+      itemRender: {
+        name: 'VxeSelect',
+        props: {
+          placeholder: '患者标签',
+          loading: tagDataLoading,
+          options: computed(() => tagData.value.data),
+          optionProps: { value: 'id', label: 'name' },
+          clearable: true,
+          multiple: false,
+          filterable: true,
+        },
+      },
+    },
+    {
+      field: 'status',
+      title: '启用状态',
+      span: 4,
+      itemRender: {
+        name: 'VxeSelect',
+        options: enabledStatus,
+        props: { placeholder: '状态' },
+      },
+    },
+    {
+      span: 8,
+      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') {
+              console.log('新增');
+              // 新增
+              editPlan();
+            }
+          },
+        },
+      },
+    },
+  ],
+});
+const searchFormEmits: VxeFormListeners<PlanQuery> = {
+  // 查询随访计划
+  submit({ data }) {
+    model.value = { ...data };
+  },
+  // 重置
+  reset({ data }) {
+    model.value = { ...data };
+  },
+};
+const gridRef = ref<VxeGridInstance<PlanModel>>();
+const gridOptions = reactive<VxeGridProps<PlanModel>>({
+  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: 'name', title: '计划名称' },
+    { field: 'patientsLabel', title: '患者标签' },
+    { field: 'department', title: '就诊科室' },
+    { field: 'frequency', title: '随访次数' },
+    { field: 'institutionName', title: '机构' },
+    { field: 'createBy', title: '创建人' },
+    { field: 'createTime', title: '创建时间' },
+    { field: 'patient', title: '筛选病人', slots: { default: 'patients' } },
+    {
+      field: 'progress',
+      title: '计划状态',
+      slots: { default: 'cell' },
+    },
+    {
+      field: 'status',
+      title: '启用状态',
+      align: 'center',
+      minWidth: 90,
+      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);
+          },
+        },
+      },
+    },
+    {
+      title: '操作',
+      align: 'center',
+      width: 120,
+      cellRender: {
+        name: 'VxeButtonGroup',
+        props: {
+          mode: 'text',
+        },
+        options: [
+          { content: '编辑', status: 'warning', name: 'editPlan' },
+          { content: '删除', status: 'error', name: 'deletePlan' },
+        ],
+        events: {
+          click({ row, rowIndex }, { name }) {
+            let method;
+            if (name === 'editPlan') {
+              method = editPlan;
+            } else if (name === 'deletePlan') {
+              method = deletePlan;
+            }
+            method?.(row, rowIndex);
+          },
+        },
+      },
+    },
+  ],
+  data: [],
+});
+const gridEvents: VxeGridListeners = {};
+
+const { loading, page, pageSize, total, onSuccess, replace, refresh, remove } = usePagination(
+  (page, size) => planMethod(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: PlanModel, index: number, status: PlanModel['status']) {
+  const { id, name } = model;
+  const label = { '1': '停用', '0': '启用' }[status];
+  VxeUI.modal.confirm({
+    title: `启用状态`,
+    content: `确认要 ${label} ${name} 计划吗?`,
+    showClose: false,
+    onConfirm() {
+      planUpdateStatusMethod({ id, status }).then(() => {
+        notification.success({
+          message: `${label}计划: ${name}`,
+          description: '操作成功',
+        });
+        model.status = status;
+        replace(model, index);
+      });
+    },
+  });
+}
+
+function deletePlan(model: PlanModel, index: number) {
+  const { name } = model;
+  VxeUI.modal.confirm({
+    title: `删除计划`,
+    content: `确认要删除 ${name} 计划吗?`,
+    showClose: false,
+    onConfirm() {
+      planDeleteMethod(model).then(() => {
+        notification.success({
+          message: `删除计划: ${name}`,
+          description: '操作成功',
+        });
+        refresh(page.value);
+      });
+    },
+  });
+}
+
+function editPlan(model?: PlanModel, index?: number) {
+  VxeUI.modal.open({
+    title: model?.id ? `修改随访计划` : `新增随访计划`,
+    height: 800,
+    width: 800,
+    position: {
+      top: Math.min(100, window.innerHeight * 0.1),
+    },
+    escClosable: true,
+    destroyOnClose: true,
+    id: `plan-modal`,
+    remember: true,
+    storage: true,
+    slots: {
+      default() {
+        return h(Enabled, <any>{
+          data: model,
+          onSubmit(data: PlanModel) {
+            refresh(page.value);
+            VxeUI.modal.close(`plan-modal`);
+          },
+        });
+      },
+    },
+  });
+}
+</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 ? '已结束' : '未开始' }}
+        </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>

+ 342 - 0
src/pages/index/tcmRecuperation/system.vue

@@ -0,0 +1,342 @@
+<script setup lang="ts">
+import Enabled from '@/components/Enabled.vue';
+import {  planStatus, enabledStatus } from '@/model/options';
+
+import type { PlanModel, PlanQuery } from '@/model/system.model';
+
+// 接口数据
+import {
+  planDeleteMethod,
+  planMethod,
+  planUpdateStatusMethod,
+  tagsSearchMethod,
+  DoctorMethod,
+  planEditMethod,
+} from '@/request/api/follow.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 { data: tags, loading: tagsLoading } = useRequest(planMethod, {
+  initialData: { total: 0, data: [] },
+});
+// 获取患者标签
+const { data: tagData, loading: tagDataLoading } = useRequest(tagsSearchMethod, {
+  initialData: { total: 0, data: [] },
+});
+
+const model = shallowRef<PlanQuery>();
+const searchFormProps = reactive<VxeFormProps<PlanQuery>>({
+  titleWidth: 100,
+  titleAlign: 'right',
+  titleColon: true,
+  data: {},
+  items: [
+    {
+      field: 'progress',
+      title: '计划状态',
+      span: 4,
+      itemRender: {
+        name: 'VxeSelect',
+        options: planStatus,
+        props: { placeholder: '状态' },
+      },
+    },
+    {
+      field: 'name',
+      title: '计划名称',
+      span: 4,
+      itemRender: { name: 'VxeInput', props: { placeholder: '请输入计划名称' } },
+    },
+    {
+      field: 'label',
+      title: '患者标签',
+      span: 4,
+      itemRender: {
+        name: 'VxeSelect',
+        props: {
+          placeholder: '患者标签',
+          loading: tagDataLoading,
+          options: computed(() => tagData.value.data),
+          optionProps: { value: 'id', label: 'name' },
+          clearable: true,
+          multiple: false,
+          filterable: true,
+        },
+      },
+    },
+    {
+      field: 'status',
+      title: '启用状态',
+      span: 4,
+      itemRender: {
+        name: 'VxeSelect',
+        options: enabledStatus,
+        props: { placeholder: '状态' },
+      },
+    },
+    {
+      span: 8,
+      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') {
+              console.log('新增');
+              // 新增
+              editPlan();
+            }
+          },
+        },
+      },
+    },
+  ],
+});
+const searchFormEmits: VxeFormListeners<PlanQuery> = {
+  // 查询随访计划
+  submit({ data }) {
+    model.value = { ...data };
+  },
+  // 重置
+  reset({ data }) {
+    model.value = { ...data };
+  },
+};
+const gridRef = ref<VxeGridInstance<PlanModel>>();
+const gridOptions = reactive<VxeGridProps<PlanModel>>({
+  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: 'name', title: '计划名称' },
+    { field: 'patientsLabel', title: '患者标签' },
+    { field: 'department', title: '就诊科室' },
+    { field: 'frequency', title: '随访次数' },
+    { field: 'institutionName', title: '机构' },
+    { field: 'createBy', title: '创建人' },
+    { field: 'createTime', title: '创建时间' },
+    { field: 'patient', title: '筛选病人', slots: { default: 'patients' } },
+    {
+      field: 'progress',
+      title: '计划状态',
+      slots: { default: 'cell' },
+    },
+    {
+      field: 'status',
+      title: '启用状态',
+      align: 'center',
+      minWidth: 90,
+      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);
+          },
+        },
+      },
+    },
+    {
+      title: '操作',
+      align: 'center',
+      width: 120,
+      cellRender: {
+        name: 'VxeButtonGroup',
+        props: {
+          mode: 'text',
+        },
+        options: [
+          { content: '编辑', status: 'warning', name: 'editPlan' },
+          { content: '删除', status: 'error', name: 'deletePlan' },
+        ],
+        events: {
+          click({ row, rowIndex }, { name }) {
+            let method;
+            if (name === 'editPlan') {
+              method = editPlan;
+            } else if (name === 'deletePlan') {
+              method = deletePlan;
+            }
+            method?.(row, rowIndex);
+          },
+        },
+      },
+    },
+  ],
+  data: [],
+});
+const gridEvents: VxeGridListeners = {};
+
+const { loading, page, pageSize, total, onSuccess, replace, refresh, remove } = usePagination(
+  (page, size) => planMethod(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: PlanModel, index: number, status: PlanModel['status']) {
+  const { id, name } = model;
+  const label = { '1': '停用', '0': '启用' }[status];
+  VxeUI.modal.confirm({
+    title: `启用状态`,
+    content: `确认要 ${label} ${name} 计划吗?`,
+    showClose: false,
+    onConfirm() {
+      planUpdateStatusMethod({ id, status }).then(() => {
+        notification.success({
+          message: `${label}计划: ${name}`,
+          description: '操作成功',
+        });
+        model.status = status;
+        replace(model, index);
+      });
+    },
+  });
+}
+
+function deletePlan(model: PlanModel, index: number) {
+  const { name } = model;
+  VxeUI.modal.confirm({
+    title: `删除计划`,
+    content: `确认要删除 ${name} 计划吗?`,
+    showClose: false,
+    onConfirm() {
+      planDeleteMethod(model).then(() => {
+        notification.success({
+          message: `删除计划: ${name}`,
+          description: '操作成功',
+        });
+        refresh(page.value);
+      });
+    },
+  });
+}
+
+function editPlan(model?: PlanModel, index?: number) {
+  VxeUI.modal.open({
+    title: model?.id ? `修改随访计划` : `新增随访计划`,
+    height: 800,
+    width: 800,
+    position: {
+      top: Math.min(100, window.innerHeight * 0.1),
+    },
+    escClosable: true,
+    destroyOnClose: true,
+    id: `plan-modal`,
+    remember: true,
+    storage: true,
+    slots: {
+      default() {
+        return h(Enabled, <any>{
+          data: model,
+          onSubmit(data: PlanModel) {
+            refresh(page.value);
+            VxeUI.modal.close(`plan-modal`);
+          },
+        });
+      },
+    },
+  });
+}
+</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 ? '已结束' : '未开始' }}
+        </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>

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

@@ -1,8 +1,7 @@
 import { type AccountFormState, type PeopleModel, transformAccount } from '@/model';
-import type { UserModel }                                            from '@/model/system.model';
-import request                                                       from '@/request/alova';
-import router                                                        from '@/router';
-
+import type { UserModel } from '@/model/system.model';
+import request from '@/request/alova';
+import router from '@/router';
 
 interface Menu {
   label: string;
@@ -19,11 +18,10 @@ export interface AccountModel {
   user?: UserModel;
 }
 
-
 export function loginMethod(data: AccountFormState) {
   return request.Post<string, { access_token: string }>(`/auth/token`, data, {
     transform(data, headers) {
-      return `Bearer ${ data.access_token }`;
+      return `Bearer ${data.access_token}`;
     },
   });
 }
@@ -42,33 +40,87 @@ export function accountMethod(token: string) {
   });
 }
 
-
 export function getMenusMethod(account: AccountModel) {
-  const routes = new Set(router.getRoutes().map(route => route.path));
+  const routes = new Set(router.getRoutes().map((route) => route.path));
 
   const transformMenus = (data: any[], prefix?: string) => {
     const menus: Menu[] = [];
-    if ( !Array.isArray(data) ) return menus;
-    for ( const menu of data ) {
-      const path = [ prefix, menu?.path ].filter(v => v != null).join('/');
+    if (!Array.isArray(data)) return menus;
+    for (const menu of data) {
+      const path = [prefix, menu?.path].filter((v) => v != null).join('/');
       const title = menu?.meta?.title ?? path;
       const children = transformMenus(menu.children, path === '/' ? '' : path);
-      if ( children.length > 1 ) {
+      if (children.length > 1) {
         menus.push({ key: path, title, label: title, children });
-      } else if ( children.length === 1 ) {
-        menus.push(children[ 0 ]);
-      } else if ( routes.has(path) ) {
+      } else if (children.length === 1) {
+        menus.push(children[0]);
+      } else if (routes.has(path)) {
         menus.push({ key: path, title, label: title });
       }
     }
+
     return menus;
   };
 
   return request.Get<AccountModel, any[]>(`/system/menu/getRouters`, {
     headers: { Authorization: account.token },
     transform(data) {
+      data.push(
+        {
+          path: '/follow',
+          meta: {
+            title: '随访管理',
+          },
+          children: [
+            {
+              path: 'plan',
+              meta: {
+                title: '随访计划',
+              },
+            },
+            {
+              path: 'task',
+              meta: {
+                title: '随访任务',
+              },
+            },
+            {
+              path: 'assessment',
+              meta: {
+                title: '随访评估',
+              },
+            },
+          ],
+        },
+        // {
+        //   path: '/tcmRecuperation',
+        //   meta: {
+        //     title: '中医调养',
+        //   },
+        //   children: [
+        //     {
+        //       path: 'preserve',
+        //       meta: {
+        //         title: '服务项目维护',
+        //       },
+        //     },
+        //     {
+        //       path: 'system',
+        //       meta: {
+        //         title: '系统服务包',
+        //       },
+        //     },
+        //     {
+        //       path: 'institution',
+        //       meta: {
+        //         title: '机构服务包',
+        //       },
+        //     },
+        //   ],
+        // }
+      );
+      console.log(data);
       return { ...account, menus: transformMenus(data) };
     },
   });
 }
-

+ 2 - 0
src/request/api/dictionary.api.ts

@@ -115,6 +115,7 @@ export type Dictionaries = Record<DictionaryKeys, Dictionary[] & { __label__: st
 
 
 export function getDictionaryMethod(key: string) {
+  console.log('key',key);
   return request.Get(`/system/dict/data/type/${ key }`, {
     cacheFor: {
       mode: 'restore',
@@ -128,6 +129,7 @@ export function getDictionaryMethod(key: string) {
           value: item.dictValue,
         }
       )) ?? [];
+
     },
   });
 }

+ 184 - 0
src/request/api/follow.api.ts

@@ -0,0 +1,184 @@
+import type { List, Tree } from '@/model';
+import type { TagModel, TagQuery, PlanQuery, PlanModel } from '@/model/system.model';
+import type { TaskQuery, TaskModel, EvaluateQuery, EvaluationModel } from '@/model/follow.model';
+import request from '@/request/alova';
+import { fromHealthReportAnalysis } from '@/model/health-report.model';
+
+// 计划搜索列表
+export function planMethod(page: number, size: number, query?: PlanQuery) {
+  return request.Post<List<PlanModel>>(
+    '/fdhb-pc/followupPlanManage/pageFollowupPlan',
+    query ?? {},
+    {
+      hitSource: /plan$/, // 匹配失效源
+      params: { pageNum: page, pageSize: size },
+    }
+  );
+}
+
+// 随访计划状态改变
+export function planUpdateStatusMethod(data: Partial<PlanModel>) {
+  return request.Get(`/fdhb-pc/followupPlanManage/updateStatus`, {
+    name: 'update-plan',
+    params: { id: data.id, status: data.status },
+    cacheFor: null,
+  });
+}
+
+// 删除随访计划
+export function planDeleteMethod(data: Partial<PlanModel>) {
+  return request.Get(`/fdhb-pc/followupPlanManage/delete`, {
+    name: 'update-PLAN',
+    params: { id: data.id },
+    cacheFor: null,
+  });
+}
+
+// 搜索随访计划
+export function plansSearchMethod(query?: PlanQuery) {
+  return request.Post<List<TagModel>, TagModel[]>(`/fdhb-pc/tagManage/selectTag`, query ?? {}, {
+    hitSource: /plan$/,
+    transform(data) {
+      return { total: data.length, data };
+    },
+  });
+}
+
+// 患者标签
+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 };
+    },
+  });
+}
+
+// 就诊科室
+export function departmentsMethod() {
+  const departmentsData = reactive<any[]>([]);
+  return request.Post<List<PlanModel>, PlanModel[]>(
+    `/fdhb-pc/patientMedicalManage/getDeptOrDoctor`,
+    { type: 1 },
+    {
+      transform(data) {
+        data?.map((item, i) => {
+          departmentsData.push({ id: `${i + 1}`, name: item });
+        });
+        return { total: departmentsData.length, departmentsData };
+      },
+    }
+  );
+}
+
+// 就诊医生
+export function doctorMethod() {
+  const doctorData = reactive<any[]>([]);
+  return request.Post<List<PlanModel>, PlanModel[]>(
+    `/fdhb-pc/patientMedicalManage/getDeptOrDoctor`,
+    { type: 2 },
+    {
+      transform(data) {
+        if (data.length > 0) {
+          data?.map((item, i) => {
+            doctorData.push({ id: `${i + 1}`, name: item });
+          });
+        }
+        return { total: doctorData.length, doctorData };
+      },
+    }
+  );
+}
+
+// 新增和编辑随访计划
+export function planEditMethod(data: Partial<PlanModel>) {
+  return data.id
+    ? request.Post(
+        `/fdhb-pc/followupPlanManage/updateFollowupPlan`,
+        { ...data, id: data.id },
+        { name: 'edit-plan' }
+      )
+    : request.Post(`/fdhb-pc/followupPlanManage/addFollowupPlan`, data, { name: 'edit-plan' });
+}
+
+// 任务搜索列表
+export function taskMethod(page: number, size: number, query?: TaskQuery) {
+  return request.Post<List<TaskModel>>(
+    '/fdhb-pc/followupTaskManage/pageFollowupTask',
+    query ?? {},
+    {
+      hitSource: /task$/, // 匹配失效源
+      params: { pageNum: page, pageSize: size },
+    }
+  );
+}
+
+// 随访任务提醒
+export function taskRemindStatusMethod(data: Partial<TaskModel>) {
+  return request.Post(`/fdhb-pc/followupTaskManage/taskRemind/${data.id}`);
+}
+
+export function FollowContentMethod(data: Partial<TaskModel>) {
+  return request.Get<any[], any>(`/fdhb-pc/followupTaskManage/getFollowupTasksByGroupId`, {
+    name: 'follow-modal',
+    params: { id: data.followupTaskGroupId },
+    cacheFor: null,
+    transform(data) {
+      return data;
+    },
+  });
+}
+
+//填写随访内容
+export function FillFollowContentMethod(query) {
+  return request.Post(`/fdhb-pc/followupTaskManage/updateFollowupTaskFillin/${query.id}`, {
+    ...query.fillin,
+  });
+}
+
+// 通用上传图片
+export function UploadIFile(file) {
+  const formData = new FormData();
+  formData.append('file', file);
+  return request.Post(`/file/upload`, formData, {
+    // headers: { 'Content-Type': 'multipart/form-data', image: 'true' },
+  });
+}
+
+// 获取随访评估列表
+export function EvaluateMethod(page: number, size: number, query?: EvaluateQuery) {
+  return request.Post<List<EvaluationModel>>(
+    '/fdhb-pc/followupTaskManage/pageFollowupTaskGroup',
+    query ?? {},
+    {
+      hitSource: /eva$/, // 匹配失效源
+      params: { pageNum: page, pageSize: size },
+    }
+  );
+}
+
+// 填写随访结果评估
+export function FillEvaluateMethod(data: Partial<EvaluationModel>) {
+  return request.Post(`/fdhb-pc/followupTaskManage/followupEvaluate/${data.id}`, {
+    ...data,
+  });
+}
+
+// 查看评估详情
+export function EvaluateDetailMethod(data: Partial<EvaluationModel>) {
+  return request.Get(`/fdhb-pc/followupTaskManage/getFollowupTaskGroupDetailById`, {
+    name: 'view-evaluation',
+    params: { id: data.id },
+    cacheFor: null,
+    transform(data: any) {
+      // if ()
+      for (const task of data.tasks)  {
+
+        task.analysis = fromHealthReportAnalysis(task.tonguefaceAnalysisReport ?? {})
+      }
+      console.log('log-->', data);
+
+      return data;
+    },
+  });
+}

+ 14 - 0
src/router/index.ts

@@ -25,6 +25,20 @@ const router = createRouter({
           ],
         },
         { path: '', redirect: '/patient/history' },
+        {
+          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: '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`) },
+          ],
+        },
       ],
       beforeEnter(to, from, next) {
         if ( useAccountStore(pinia).token ) {

+ 1 - 0
src/swiper-vars.less

@@ -0,0 +1 @@
+@themeColor: #007aff;

+ 1 - 0
src/swiper-vars.scss

@@ -0,0 +1 @@
+$themeColor: #007aff !default;

File diff ditekan karena terlalu besar
+ 12 - 0
src/swiper.min.css


File diff ditekan karena terlalu besar
+ 14 - 0
src/swiper.scss


Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini