Bläddra i källkod

标签 & 患者标签

cc12458 1 år sedan
förälder
incheckning
e545b43b65

+ 1 - 0
.gitignore

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

+ 1 - 1
.prettierrc.json

@@ -3,6 +3,6 @@
   "semi": true,
   "tabWidth": 2,
   "singleQuote": true,
-  "printWidth": 100,
+  "printWidth": 180,
   "trailingComma": "es5"
 }

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

@@ -27,15 +27,23 @@ declare module 'vue' {
     AInputSearch: typeof import('ant-design-vue/es')['InputSearch']
     AMenu: typeof import('ant-design-vue/es')['Menu']
     APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
+    ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
+    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']
+    ATabPane: typeof import('ant-design-vue/es')['TabPane']
+    ATabs: typeof import('ant-design-vue/es')['Tabs']
     ATag: typeof import('ant-design-vue/es')['Tag']
     ATextarea: typeof import('ant-design-vue/es')['Textarea']
     ATooltip: typeof import('ant-design-vue/es')['Tooltip']
+    PatientEdit: typeof import('./../src/components/PatientEdit.vue')['default']
+    PatientHealthRecordPreview: typeof import('./../src/components/PatientHealthRecordPreview.vue')['default']
+    PatientMedicalHistoryPreview: typeof import('./../src/components/PatientMedicalHistoryPreview.vue')['default']
+    PatientTagEdit: typeof import('./../src/components/PatientTagEdit.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']

+ 175 - 0
src/components/PatientHealthRecordPreview.vue

@@ -0,0 +1,175 @@
+<script setup lang="ts">
+import { VxeUI } from 'vxe-pc-ui';
+import { EditOutlined } from '@ant-design/icons-vue';
+
+import { useWatcher } from 'alova/client';
+import type { PatientModel, PatientTagVO, ReportModel } from '@/model';
+import { getPatientTagsMethod, patientMethod } from '@/request/api/patient.api';
+
+const props = defineProps<{
+  patient: Partial<PatientModel>;
+  report: Partial<ReportModel>;
+}>();
+
+const emits = defineEmits<{
+  refresh: [];
+  destroy: [];
+}>();
+
+const { data: patient, loading: loadPatientPending } = useWatcher(() => patientMethod(props.patient?.id!), [() => props.patient.id], {
+  initialData: { ...props.patient },
+  immediate: true,
+  middleware: (_, next) => {
+    if (props.patient.id) next();
+  },
+});
+
+const { data: patientTags, loading: loadPatientTagsPending } = useWatcher(() => getPatientTagsMethod(props.patient?.id!), [() => props.patient.id], {
+  initialData: [],
+  immediate: true,
+  middleware: (_, next) => {
+    if (props.patient.id) next();
+  },
+});
+
+function openPatientTagEdit(event: MouseEvent) {
+  const width = 500;
+  const offset = 32;
+  const component = defineAsyncComponent(() => import('@/components/PatientTagEdit.vue'));
+  const id = `PatientTagEdit`;
+  VxeUI.modal.open({
+    id,
+    title: '标签',
+    type: 'modal',
+    position: {
+      top: event.pageY + offset,
+      left: event.pageX - width,
+    },
+    escClosable: true,
+    resize: true,
+    width,
+    minWidth: width,
+    mask: false,
+    slots: {
+      default() {
+        return h(component, {
+          id: patient.value.id,
+          tags: patientTags.value,
+          onDestroy(values?: PatientTagVO[]) {
+            if (values) {
+              patientTags.value = values;
+              emits('refresh');
+            }
+            VxeUI.modal.close(id);
+          },
+        });
+      },
+    },
+  });
+}
+</script>
+
+<template>
+  <div class="p-6">
+    <div class="flex">
+      <section class="flex-none min-w-100px max-w-400px">
+        <label>标签:</label>
+        <a-spin v-if="loadPatientTagsPending"></a-spin>
+        <template v-else>
+          <a-tag v-for="tag in patientTags" :key="tag.id">{{ tag.name }}</a-tag>
+          <a-button type="link" @click="openPatientTagEdit($event)">
+            <template #icon>
+              <EditOutlined />
+            </template>
+          </a-button>
+        </template>
+      </section>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+section {
+  color: rgba(0, 0, 0, 0.85);
+
+  > header {
+    font-size: 18px;
+    font-weight: 700;
+
+    :deep(.ant-btn-link) {
+      padding-block: 0;
+      font-size: 18px;
+      border: none;
+    }
+  }
+
+  > main {
+    margin-left: 18px * 4;
+    padding: 0 15px;
+    font-size: 16px;
+    color: rgba(0, 0, 0, 0.85);
+
+    > .row > .ant-space {
+      font-size: 16px;
+    }
+
+    .row {
+      padding: 12px 0;
+
+      span > label {
+        color: rgba(0, 0, 0, 0.45);
+      }
+
+      label::after {
+        margin-left: 2px;
+        margin-right: 8px;
+        content: ':';
+      }
+
+      > header::before {
+        $size: 10px;
+        content: '';
+        display: inline-block;
+        margin-right: 12px;
+        width: $size;
+        height: $size;
+        border: 2px solid #1d6ff6;
+        border-radius: 50%;
+      }
+
+      > main {
+        margin-left: 18px * 2;
+      }
+    }
+  }
+
+  .ant-tag {
+    margin-top: 6px;
+  }
+}
+
+.separate {
+  :deep(.ant-space-item) {
+    & + .ant-space-item::before {
+      content: ',';
+      margin-right: 2px;
+    }
+  }
+
+  span + span::before {
+    content: ',';
+    margin-right: 2px;
+  }
+}
+
+.panel-wrapper {
+  :deep(.ant-tabs-content-holder) {
+    padding-top: 12px;
+    height: calc(100vh - 60px - 24px - 32px);
+
+    .ant-tabs-content {
+      height: 100%;
+    }
+  }
+}
+</style>

+ 135 - 0
src/components/PatientTagEdit.vue

@@ -0,0 +1,135 @@
+<script setup lang="ts">
+import { useForm, useRequest } from 'alova/client';
+import type { VxeFormListeners, VxeFormProps } from 'vxe-pc-ui';
+import { getPatientTagsMethod, searchTagsFromSelectableMethod, updatePatientTagMethod } from '@/request/api/patient.api';
+import type { PatientTagVO } from '@/model';
+import { list2Groups } from '@/tools/data';
+import { message as Message } from 'ant-design-vue';
+
+interface FormModel {
+  tags: string[];
+}
+
+const props = defineProps<{
+  id: string;
+  tags?: PatientTagVO[];
+}>();
+
+const emits = defineEmits<{
+  destroy: [refreshValue?: PatientTagVO[]];
+}>();
+
+const {
+  form,
+  send: submit,
+  loading: submitting,
+} = useForm((model) => updatePatientTagMethod(props.id, model.tags), {
+  initialForm: {
+    selected: [],
+    tags: props.tags ? [...props.tags] : [],
+  },
+}).onSuccess(({ data }) => {
+  Message.success(`标签更新成功`);
+  emits('destroy', data);
+});
+
+const {
+  data: patientTags,
+  loading,
+  send: loadPatientTags,
+} = useRequest(() => getPatientTagsMethod(props.id), {
+  initialData: props.tags ? [...props.tags] : [],
+  immediate: false,
+}).onSuccess(({ data: tags }) => {
+  form.value.tags = [...tags];
+});
+
+onMounted(() => {
+  if (!patientTags.value.length) loadPatientTags();
+});
+
+const { data: selectable, loading: tagsLoading } = useRequest(searchTagsFromSelectableMethod, {
+  initialData: [],
+});
+
+const selected = computed(() => form.value.tags.map((item) => item.id));
+const options = computed(() =>
+  list2Groups(
+    selectable.value.filter((tag) => !(tag.disabled || selected.value.includes(tag.id))),
+    'category',
+    (key) => ({ 1: '系统标签', 2: '个人标签' })[key]!
+  )
+);
+
+const formProps = reactive<VxeFormProps<FormModel>>({
+  titleWidth: 0,
+  titleAlign: 'right',
+  titleColon: true,
+  items: [
+    {
+      field: 'selected',
+      title: '',
+      span: 24,
+      itemRender: {
+        name: 'VxeSelect',
+        props: {
+          loading: tagsLoading,
+          optionGroups: options,
+          optionProps: { value: 'id', label: 'name' },
+          optionGroupProps: { options: 'groups' },
+          multiple: true,
+          clearable: true,
+          filterable: true,
+        },
+        events: {
+          visibleChange(ref, { visible }) {
+            if (!visible) ref.data.selected?.forEach(appendTag);
+          },
+        },
+      },
+    },
+    { align: 'center', span: 24, slots: { default: 'active' } },
+  ],
+  rules: {},
+});
+const formEmits: VxeFormListeners<FormModel> = {
+  submit({ data }) {
+    submit(data);
+  },
+  reset() {
+    form.value = { tags: [...patientTags.value], selected: [] };
+  },
+};
+
+function appendTag(id: PatientTagVO['id']) {
+  const tag = selectable.value.find((tag) => tag.id === id);
+  if (tag) form.value.tags.push(tag);
+  form.value.selected = [];
+}
+
+function remove(tag: PatientTagVO, index: number) {
+  form.value.tags.splice(index, 1);
+}
+
+const getTagColor = (tag: PatientTagVO) => {
+  return { 1: 'pink', 2: 'blue' }[tag.category];
+};
+</script>
+
+<template>
+  <a-spin :spinning="loading">
+    <div class="flex flex-wrap mt--2">
+      <a-tag class="mt-2" v-for="(item, index) in form.tags" :key="item.id" :color="getTagColor(item)" :closable="!submitting" @close="remove(item, index)">
+        {{ item.name }}
+      </a-tag>
+    </div>
+    <vxe-form :data="form" v-bind="formProps" v-on="formEmits">
+      <template #active>
+        <vxe-button type="submit" status="primary" content="提交" :loading="submitting"></vxe-button>
+        <vxe-button type="reset" content="重置" :disabled="submitting"></vxe-button>
+      </template>
+    </vxe-form>
+  </a-spin>
+</template>
+
+<style scoped lang="scss"></style>

+ 6 - 0
src/libs/vxe/plugin.ts

@@ -11,12 +11,15 @@ import {
   VxeFormView,
   VxeIcon,
   VxeInput,
+  VxeNumberInput,
+  VxeDatePicker,
   VxeList,
   VxeLoading,
   VxeModal,
   VxePager,
   VxeRadio,
   VxeRadioGroup,
+  VxeCheckboxGroup,
   VxeSelect,
   VxeSwitch,
   VxeTooltip,
@@ -37,12 +40,15 @@ function LazyVxeUIForForm(app: App) {
   app.use(VxeFormItem);
   app.use(VxeFormView);
   app.use(VxeInput);
+  app.use(VxeNumberInput);
+  app.use(VxeDatePicker);
   app.use(VxeRadio);
   app.use(VxeSwitch);
   app.use(VxeSelect);
   app.use(VxeTreeSelect);
   app.use(VxeTree);
   app.use(VxeRadioGroup);
+  app.use(VxeCheckboxGroup);
 
   app.use(VxeList);
 

+ 28 - 3
src/model/patient.model.ts

@@ -1,7 +1,6 @@
 import type { PeopleModel } from '@/model/people.model';
 import type { ReportModel } from '@/model/report.model';
-import dayjs                from 'dayjs';
-
+import dayjs from 'dayjs';
 
 export interface PatientQuery {
   patientName?: string;
@@ -13,7 +12,7 @@ export interface PatientQuery {
 
 export interface PatientModel extends PeopleModel {
   tags?: string[];
-  [ key: string ]: any;
+  [key: string]: any;
 }
 
 export interface PatientReportModel extends PatientModel {
@@ -33,6 +32,8 @@ export function transformPatient(data: any): PatientModel {
     gender: data?.sex,
     age: data?.age,
     tags: data?.tags ?? [],
+
+    foodAllergy2: data?.foodAllergy?.replace?.('8:', '') ?? '',
   };
 }
 
@@ -42,3 +43,27 @@ export function transformPatientRecord(data: any): PatientRecordModel {
     recordDate: data?.analysisEndTime ?? dayjs(data.createTime).format('YYYY/MM/DD'),
   };
 }
+
+export interface PatientTagDTO {
+  id: string;
+  name: string;
+  type: PatientTagVO['category'];
+  status: string;
+}
+
+export interface PatientTagVO {
+  id: string;
+  name: string;
+  category: '1' | '2';
+
+  disabled?: boolean;
+}
+
+export function fromPatientTag(data: PatientTagDTO): PatientTagVO {
+  return {
+    id: data.id,
+    name: data.name,
+    category: data.type,
+    disabled: data.status === '1',
+  }
+}

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

@@ -33,6 +33,7 @@ export interface TagQuery {
   tagId?: string;
   name?: string;
   status?: '0' | '1';
+  types?: ('1' | '2')[];
 
   parentId?: string;
   parentIds?: string[] | string;
@@ -42,7 +43,23 @@ export interface TagModel {
   id: string;
   name: string;
   status: '0' | '1';
+  type: '1' | '2';
+  category: string;
 
   parentId?: string;
   parentName?: string;
+
+  editable?: boolean;
+  deletable?: boolean;
 }
+
+export function fromTag(data: Record<string, any>): TagModel {
+  return Object.assign(data, {
+    updateTime: data.updateTime ?? data.createTime,
+    category: {1: '系统标签', 2: '个人标签'}[data.type as number],
+    editable: data.isEdit?.toUpperCase() === 'Y',
+    deletable: (data.isDelete ?? data.isEdit)?.toUpperCase() === 'Y',
+    parentId: data.parentId === 0 ? void 0 : data.parentId,
+    disabled: data.status === '1',
+  }) as unknown as TagModel;
+}

+ 62 - 26
src/pages/index/patient/history.vue

@@ -1,24 +1,19 @@
 <script setup lang="ts">
-import ReportAnalysisCountEdit                   from '@/components/ReportAnalysisCountEdit.vue';
-import ReportHistoryPreview                      from '@/components/ReportHistoryPreview.vue';
-import type { PatientQuery, PatientReportModel } from '@/model';
-import { patientsHistoryMethod }                 from '@/request/api/patient.api';
-import { tagsSearchMethod }                      from '@/request/api/system.api';
-import { usePagination, useRequest }             from 'alova/client';
-import { h }                                     from 'vue';
+import { h } from 'vue';
 
-import {
-  VxeButton,
-  type VxeFormListeners,
-  type VxeFormProps,
-  type VxeGridInstance,
-  type VxeGridListeners,
-  type VxeGridProps,
-  VxeUI,
-} from 'vxe-pc-ui';
+import { usePagination, useRequest } from 'alova/client';
+import { patientsHistoryMethod, patientsHistoryPullMethod, searchTagsFromSelectableMethod } from '@/request/api/patient.api';
+import type { PatientQuery, PatientReportModel, PatientTagVO } from '@/model';
+import { list2Groups } from '@/tools/data';
 
+import { VxeButton, type VxeFormListeners, type VxeFormProps, VxeUI } from 'vxe-pc-ui';
+import type { VxeGridInstance, VxeGridListeners, VxeGridProps } from 'vxe-table';
 
-const { data: tags, loading: tagsLoading } = useRequest(tagsSearchMethod, { initialData: { total: 0, data: [] } });
+import ReportAnalysisCountEdit from '@/components/ReportAnalysisCountEdit.vue';
+import PatientTagEdit from '@/components/PatientTagEdit.vue';
+import PatientHealthRecordPreview from '@/components/PatientHealthRecordPreview.vue';
+
+const { data: selectable, loading: tagsLoading } = useRequest(searchTagsFromSelectableMethod, { initialData: [] });
 
 const model = shallowRef<PatientQuery>();
 const searchFormProps = reactive<VxeFormProps<PatientQuery>>({
@@ -43,13 +38,25 @@ const searchFormProps = reactive<VxeFormProps<PatientQuery>>({
       },
     },
     {
-      field: 'tagIds', title: '标签', span: 8, itemRender: {
+      field: 'tagIds',
+      title: '标签',
+      span: 6,
+      itemRender: {
         name: 'VxeSelect',
         props: {
           loading: tagsLoading,
-          options: computed(() => tags.value.data),
+          options: computed(() =>
+            list2Groups(
+              selectable.value.filter((tag) => !tag.disabled),
+              'category',
+              (key) => ({ 1: '系统标签', 2: '个人标签' })[key]!
+            )
+          ),
           optionProps: { value: 'id', label: 'name' },
-          clearable: true, multiple: true, filterable: true,
+          optionGroupProps: { options: 'groups' },
+          clearable: true,
+          multiple: true,
+          filterable: true,
         },
       },
     },
@@ -107,14 +114,20 @@ const gridOptions = reactive<VxeGridProps<PatientReportModel>>({
           mode: 'text',
         },
         options: [
-          { content: '健康档案', status: 'primary', name: 'previewPatientHistoryRecord' },
-          { content: '充值', status: 'warning', name: 'resetPatientAnalysisCount' },
+          { content: '查看', status: 'primary', name: 'previewPatientHistoryRecord' },
+          { content: '充值', status: 'primary', name: 'resetPatientAnalysisCount' },
+          { content: '标签', status: 'primary', name: 'editPatientTags' },
         ],
         events: {
           click({ row, rowIndex }, { name }) {
             let method;
-            if ( name === 'previewPatientHistoryRecord' ) { method = openHistoryPreviewHandle; }
-            else if ( name === 'resetPatientAnalysisCount' ) { method = openPatientAnalysisCountResetHandle; }
+            if (name === 'previewPatientHistoryRecord') {
+              method = openHistoryPreviewHandle;
+            } else if (name === 'resetPatientAnalysisCount') {
+              method = openPatientAnalysisCountResetHandle;
+            } else if (name === 'editPatientTags') {
+              method = openPatientTagsEditHandle;
+            }
             method?.(row, rowIndex);
           },
         },
@@ -155,8 +168,8 @@ function openHistoryPreviewHandle({ report, ...patient }: PatientReportModel, in
     width: window.innerWidth - 256,
     slots: {
       default() {
-        return h(ReportHistoryPreview, {
-          patient, report, onDestroy,
+        return h(PatientHealthRecordPreview, {
+          patient, report, onDestroy, onRefresh() { refresh(page.value); }
         });
       },
     },
@@ -182,6 +195,29 @@ function openPatientAnalysisCountResetHandle({ report, ...patient }: PatientRepo
         });
       },
     },
+  });
+}
+
+function openPatientTagsEditHandle({ report, ...patient }: PatientReportModel) {
+  const id = `modal:patient-tags:edit`;
+  const onDestroy = (tags?: PatientTagVO[]) => {
+    if (tags) refresh(page.value)
+    VxeUI.modal.close(id);
+  };
+  onDestroy();
+  VxeUI.modal.open({
+    id,
+    title: `${patient.name} 标签`,
+    maskClosable: true,
+    escClosable: true,
+    slots: {
+      default() {
+        return h(PatientTagEdit, {
+          id: patient.id,
+          onDestroy,
+        });
+      },
+    },
   })
 }
 </script>

+ 48 - 42
src/pages/index/system/tag.vue

@@ -25,7 +25,9 @@ const searchFormProps = reactive<VxeFormProps<TagQuery>>({
   titleWidth: 100,
   titleAlign: 'right',
   titleColon: true,
-  data: {},
+  data: {
+    types: ['1', '2'],
+  },
   items: [
     { field: 'name', title: '标签名称', span: 8, itemRender: { name: 'VxeInput' } },
     {
@@ -46,6 +48,14 @@ const searchFormProps = reactive<VxeFormProps<TagQuery>>({
         },
       },
     },
+    { field: 'types', title: '标签分类', span: 8, itemRender: {
+        name: 'VxeCheckboxGroup',
+        options: [
+          { label: '系统', value: '1' },
+          { label: '个人', value: '2' },
+        ]
+      }
+    },
     {
       span: 8, itemRender: {
         name: 'VxeButtonGroup',
@@ -59,7 +69,7 @@ const searchFormProps = reactive<VxeFormProps<TagQuery>>({
 });
 const searchFormEmits: VxeFormListeners<TagQuery> = {
   submit({ data }) { model.value = { ...data }; },
-  reset({ data }) { model.value = { ...data }; },
+  reset({ data }) { model.value = { ...data, }; },
 };
 
 const gridRef = ref<VxeGridInstance<TagModel>>();
@@ -88,44 +98,10 @@ const gridOptions = reactive<VxeGridProps<TagModel>>({
     { field: 'name', title: '标签名称' },
     { field: 'parentName', title: '上级标签' },
     { field: 'updateTime', title: '最近一次修改时间' },
-    { field: 'nickName', title: '创建者' },
-    {
-      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;
-            updateTagStatus(row, rowIndex, value);
-          },
-        },
-      },
-    },
-    {
-      title: '操作',
-      align: 'center',
-      width: 120,
-      cellRender: {
-        name: 'VxeButtonGroup',
-        props: {
-          mode: 'text',
-        },
-        options: [
-          { content: '修改', status: 'warning', name: 'editTag' },
-          { content: '删除', status: 'error', name: 'deleteTag' },
-        ],
-        events: {
-          click({ row, rowIndex }, { name }) {
-            let method;
-            if ( name === 'editTag' ) { method = editTag; } else if ( name === 'deleteTag' ) { method = deleteTag; }
-            method?.(row, rowIndex);
-          },
-        },
-      },
-    },
+    { field: 'createBy', title: '创建者' },
+    { field: 'status', title: '启用状态', align: 'center', minWidth: 90, slots: { default: 'cell-status' }, },
+    { field: 'category', title: '标签分类' },
+    { title: '操作', align: 'center', width: 120, slots: { default: 'cell-operation' }, },
   ],
   data: [],
 });
@@ -148,7 +124,7 @@ onMounted(() => {
   model.value = toRaw(searchFormProps.data);
 });
 
-function updateTagStatus(model: TagModel, index: number, status: TagModel['status']) {
+function updateTagStatus(status: TagModel['status'], model: TagModel, index?: number) {
   const { id, name } = model;
   const label = { '1': '停用', '0': '启用' }[ status ];
   VxeUI.modal.confirm({
@@ -168,7 +144,7 @@ function updateTagStatus(model: TagModel, index: number, status: TagModel['statu
   });
 }
 
-function deleteTag(model: TagModel, index: number) {
+function deleteTag(model: TagModel, index?: number) {
   const { name } = model;
   VxeUI.modal.confirm({
     title: `删除标签`,
@@ -220,6 +196,20 @@ function editTag(model?: TagModel, index?: number) {
         <template #toolbar-extra>
           <vxe-button style="margin-right: 12px;" icon="vxe-icon-repeat" circle @click="refresh(page)"></vxe-button>
         </template>
+
+        <template #cell-status="{ row }">
+          <vxe-switch :modelValue="row.status" :disabled="!row.editable"
+            openLabel="启用" openValue="0"
+            closeLabel="停用" closeValue="1"
+            @change="updateTagStatus($event.value, row)"
+          ></vxe-switch>
+        </template>
+        <template #cell-operation="{ row }">
+          <vxe-button-group mode="text">
+            <vxe-button content="修改" :disabled="!row.editable" status="warning" @click="editTag(row)"></vxe-button>
+            <vxe-button content="删除" :disabled="!row.deletable" status="error" @click="deleteTag(row)"></vxe-button>
+          </vxe-button-group>
+        </template>
       </vxe-grid>
     </main>
     <footer class="flex-none">
@@ -236,5 +226,21 @@ function editTag(model?: TagModel, index?: number) {
 .page-container {
   padding: 0 24px;
   max-height: var(--page-main-container);
+
+  :deep(.vxe-checkbox-group) {
+    .vxe-checkbox {
+      .vxe-checkbox--input {
+        margin-right: 4px;
+        transform: translateY(2px);
+      }
+      & + .vxe-checkbox {
+        margin-left: 8px;
+      }
+    }
+  }
+
+  :deep(.vxe-checkbox--icon) {
+    display: none;
+  }
 }
 </style>

+ 60 - 0
src/request/api/patient.api.ts

@@ -1,6 +1,9 @@
 import { type List, type PatientModel, type PatientQuery, type PatientReportModel, type PatientTagModel, transformPatient } from '@/model';
 import request                                                                                                              from '@/request/alova';
 
+import type { PatientTagVO, PatientTagDTO } from '@/model/patient.model';
+import { fromPatientTag } from '@/model/patient.model';
+
 
 export function patientsHistoryMethod(page: number, size: number, query?: PatientQuery) {
   return request.Post<List<PatientReportModel>, List<any>>(`/fdhb-pc/patientInfoManage/pageMyPatient`, query ?? {}, {
@@ -48,6 +51,10 @@ export function patientMethod(id: string) {
   });
 }
 
+/**
+ * @deprecated
+ * @param id
+ */
 export function patientTags(id: string) {
   return request.Get<PatientTagModel, any[]>(`/fdhb-pc/patientInfoManage/getPatientTag`, {
     hitSource: 'update-tags',
@@ -63,10 +70,63 @@ export function patientTags(id: string) {
   });
 }
 
+/**
+ * @deprecated
+ * @param id
+ */
+export function patientTagsMethod(id: string) {
+  return request.Get<PatientTagModel['tags'], any[]>(`/fdhb-pc/patientInfoManage/getPatientTag`, {
+    hitSource: 'update-tags',
+    params: { patientId: id },
+  });
+}
+
+/**
+ * @deprecated
+ * @param id
+ * @param data
+ */
 export function updatePatientTagsMethod(id: string, data: string[]) {
   return request.Post(`/fdhb-pc/patientInfoManage/updatePatientTag`, { patientId: id, tagIds: data }, { name: 'update-tags' });
 }
 
+export function getPatientTagsMethod(id: string) {
+  return request.Get<PatientTagVO[], PatientTagDTO[]>(`/fdhb-pc/patientInfoManage/getPatientTag`, {
+    hitSource: 'update-tags',
+    params: { patientId: id },
+    transform(data) {
+      return data.map(fromPatientTag);
+    },
+  });
+}
+
+export function updatePatientTagMethod(id: string, tags: PatientTagVO[]) {
+  const tagIds = tags.map(({ id }) => id);
+  return request.Post(
+    `/fdhb-pc/patientInfoManage/updatePatientTag`,
+    { patientId: id, tagIds },
+    {
+      name: 'update-tags',
+      transform() {
+        return tags;
+      },
+    }
+  );
+}
+
+export function searchTagsFromSelectableMethod() {
+  return request.Post<PatientTagVO[], PatientTagDTO[]>(
+    `/fdhb-pc/tagManage/selectTag`,
+    {},
+    {
+      hitSource: /tag$/,
+      transform(data) {
+        return data.map(fromPatientTag);
+      },
+    }
+  );
+}
+
 export function patientAnalysisCountMethod(id: string) {
   return request.Get(`/fdhb-pc/patientInfoManage/rechargeUseDetail`, {
     hitSource: 'update-analysis-count',

+ 10 - 6
src/request/api/system.api.ts

@@ -1,5 +1,6 @@
 import type { List, Tree }                                                     from '@/model';
 import type { RoleModel, RoleQuery, TagModel, TagQuery, UserModel, UserQuery } from '@/model/system.model';
+import { fromTag } from '@/model/system.model';
 
 import request from '@/request/alova';
 
@@ -107,13 +108,16 @@ export function getRoleMenusMethod(data?: Partial<RoleModel>) {
 
 }
 
-export function tagsMethod(page: number, size: number, query?: TagQuery) {
-  if ( Array.isArray(query?.parentIds) && query.parentIds.length === 1 ) {
-    query.parentId = query.parentIds[ 0 ];
-  } else if ( query?.parentIds ) query.parentId = query?.parentIds as string;
-  return request.Post<List<TagModel>>(`/fdhb-pc/tagManage/pageTag`, query ?? {}, {
+export function tagsMethod(page: number, size: number, query?: TagQuery & { type?: Required<TagQuery>['types'][number] }) {
+  if ( Array.isArray(query?.parentIds)) query.parentId = query.parentIds.join(',');
+  else if ( query?.parentIds ) query.parentId = query?.parentIds as string;
+  if (query?.types?.length === 1) query.type = query.types[0];
+  return request.Post<List<TagModel>, List<any>>(`/fdhb-pc/tagManage/pageTag`, query ?? {}, {
     hitSource: /tag$/,
     params: { pageNum: page, pageSize: size },
+    transform({ total, data }) {
+      return { total, data: data.map(fromTag) }
+    },
   });
 }
 
@@ -121,7 +125,7 @@ export function tagsSearchMethod(query?: TagQuery) {
   return request.Post<List<TagModel>, TagModel[]>(`/fdhb-pc/tagManage/selectTag`, query ?? {}, {
     hitSource: /tag$/,
     transform(data) {
-      return { total: data.length, data };
+      return { total: data.length, data: data.map(fromTag) }
     },
   });
 }

+ 15 - 0
src/tools/data.ts

@@ -0,0 +1,15 @@
+export function list2Group<G, K extends keyof G>(list: G[], key: K): Record<K, G[]> {
+  return list.reduce(
+    (group, item) => {
+      const k = item[key] as K;
+      const g = group[k] ?? (group[k] = []);
+      g.push(item);
+      return group;
+    },
+    {} as Record<K, G[]>
+  );
+}
+
+export function list2Groups<G, K extends keyof G>(list: G[], key: K, _label?: (key: string) => string): { key: K; groups: G[] }[] {
+  return Object.entries<G[]>(list2Group(list, key)).map(([key, groups]) => ({ groups, key: key as K, label: _label?.(key) ?? key }));
+}