Просмотр исходного кода

Merge branch 'feature/患者管理' into develop

# Conflicts:
#	@types/components.d.ts
#	src/libs/vxe/plugin.ts
#	src/model/health-report-analysis.config.ts
#	src/model/health-report.model.ts
#	src/model/system.model.ts
cc12458 1 год назад
Родитель
Сommit
e9b19a6685

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

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

@@ -1,68 +0,0 @@
-/* eslint-disable */
-// @ts-nocheck
-// Generated by unplugin-vue-components
-// Read more: https://github.com/vuejs/core/pull/3399
-export {}
-
-/* prettier-ignore */
-declare module 'vue' {
-  export interface GlobalComponents {
-    AAvatar: typeof import('ant-design-vue/es')['Avatar']
-    AButton: typeof import('ant-design-vue/es')['Button']
-    ACard: typeof import('ant-design-vue/es')['Card']
-    ACarousel: typeof import('ant-design-vue/es')['Carousel']
-    ACol: typeof import('ant-design-vue/es')['Col']
-    ACollapse: typeof import('ant-design-vue/es')['Collapse']
-    ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel']
-    AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
-    ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
-    ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem']
-    AEmpty: typeof import('ant-design-vue/es')['Empty']
-    AForm: typeof import('ant-design-vue/es')['Form']
-    AFormItem: typeof import('ant-design-vue/es')['FormItem']
-    AImage: typeof import('ant-design-vue/es')['Image']
-    AInput: typeof import('ant-design-vue/es')['Input']
-    AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
-    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']
-    ReportAnalysisCountEdit: typeof import('./../src/components/ReportAnalysisCountEdit.vue')['default']
-    ReportAnalysisEdit: typeof import('./../src/components/ReportAnalysisEdit.vue')['default']
-    ReportHistoryPreview: typeof import('./../src/components/ReportHistoryPreview.vue')['default']
-    ReportPreview: typeof import('./../src/components/ReportPreview.vue')['default']
-    ReportSchemeEdit: typeof import('./../src/components/ReportSchemeEdit.vue')['default']
-    ReportSchemePreview: typeof import('./../src/components/ReportSchemePreview.vue')['default']
-    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']
-  }
-}

+ 88 - 0
src/components/PatientEdit.vue

@@ -0,0 +1,88 @@
+<script setup lang="ts">
+import type { VxeFormListeners, VxeFormProps } from 'vxe-pc-ui';
+import { useForm } from 'alova/client';
+import { appendPatientMethod } from '@/request/api/patient.api';
+import { message as Message } from 'ant-design-vue/es/components';
+
+interface FormModel {}
+
+const emits = defineEmits<{
+  destroy: [refresh?: boolean];
+}>();
+
+const {
+  form,
+  loading: submitting,
+  send: submit,
+  reset,
+} = useForm(appendPatientMethod, {
+  initialForm: {},
+}).onSuccess(() => {
+  Message.success(`新增患者成功`);
+  emits('destroy', true);
+});
+
+const formProps = reactive<VxeFormProps<FormModel>>({
+  titleWidth: 100,
+  titleAlign: 'right',
+  titleColon: true,
+  items: [
+    { field: 'name', title: '姓名', span: 24, itemRender: { name: 'VxeInput' } },
+    { field: 'phone', title: '手机号码', span: 24, itemRender: { name: 'VxeInput', props: { maxLength: 11 } } },
+    {
+      field: 'cardno',
+      title: '身份证号',
+      span: 24,
+      itemRender: { name: 'VxeInput', props: { minLength: 18, maxLength: 18 } },
+    },
+    /*{
+      field: 'gender', title: '性别', span: 24, itemRender: {
+        name: 'VxeRadioGroup',
+        options: [
+          { label: '男', value: '0' },
+          { label: '女', value: '1' },
+        ],
+      },
+    },*/
+    // { field: 'age', title: '年龄', span: 24, itemRender: { name: 'VxeNumberInput', props: { min: 0, controls: false }, } },
+    { field: 'disease', title: '疾病', span: 24, itemRender: { name: 'VxeInput' } },
+    { field: 'symptom', title: '证型', span: 24, itemRender: { name: 'VxeInput' } },
+    { field: 'date', title: '就诊日期', span: 24, itemRender: { name: 'VxeDatePicker', props: { type: 'datetime' } } },
+    { field: 'department', title: '就诊科室', span: 24, itemRender: { name: 'VxeInput' } },
+    { field: 'doctor', title: '就诊医生', span: 24, itemRender: { name: 'VxeInput' } },
+    { align: 'center', span: 24, slots: { default: 'active' } },
+  ],
+  rules: {
+    name: [{ required: true, message: '请输入姓名' }],
+    phone: [{ required: true, message: '请输入手机号码' }],
+    cardno: [{ required: true, message: '请输入身份证号' }],
+    /*gender: [
+      { required: true, message: '请选择性别' },
+    ],
+    age: [
+      { required: true, message: '请输入年龄' },
+    ],*/
+    disease: [{ required: true, message: '请输入疾病' }],
+    date: [{ required: true, message: '请选择就诊日期' }],
+  },
+});
+const formEmits: VxeFormListeners<FormModel> = {
+  submit({ data }) {
+    submit(data);
+  },
+  reset() {
+    reset();
+  },
+};
+</script>
+
+<template>
+  <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>
+</template>
+
+<style scoped lang="scss"></style>

+ 407 - 0
src/components/PatientHealthRecordPreview.vue

@@ -0,0 +1,407 @@
+<script setup lang="ts">
+import { h, ref } from 'vue';
+
+import { VxeUI } from 'vxe-pc-ui';
+import { ArrowDownOutlined, ArrowUpOutlined, EditOutlined } from '@ant-design/icons-vue';
+
+import { useWatcher } from 'alova/client';
+import { getPatientTagsMethod, patientMethod } from '@/request/api/patient.api';
+import { getPatientDiagnosisReportMethod, getPatientHealthIndicatorMethod, getPatientHealthRecordMethod } from '@/request/api/report.api';
+import type { PatientModel, PatientTagVO, ReportModel } from '@/model';
+
+import UseDict from '@/core/dictionary/component';
+import HealthReportAnalysisWidget from '@/widgets/HealthReportAnalysisWidget.vue';
+
+const props = defineProps<{
+  patient: Partial<PatientModel>;
+  report: Partial<ReportModel>;
+}>();
+
+const emits = defineEmits<{
+  refresh: [];
+  destroy: [];
+}>();
+
+// 患者基本信息
+const { data: patient } = 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();
+  },
+});
+
+// 患者最后一次就诊记录
+const { data: diagnosisRecord } = useWatcher(() => getPatientDiagnosisReportMethod(0, props.patient?.id!), [() => props.patient.id], {
+  initialData: { disease: {}, symptom: {} },
+  immediate: true,
+  middleware: (_, next) => {
+    if (props.patient.id) next();
+  },
+});
+
+// 患者最后一次健康分析报告
+const { data: healthRecord } = useWatcher(() => getPatientHealthRecordMethod(props.report?.id!), [() => props.report.id], {
+  initialData: {},
+  immediate: true,
+  middleware: (_, next) => {
+    if (props.report.id) next();
+  },
+});
+
+const { data: indicator } = useWatcher(() => getPatientHealthIndicatorMethod(props.patient?.id!), [() => props.patient.id], {
+  initialData: [],
+  immediate: true,
+  middleware: (_, next) => {
+    if (props.patient.id) next();
+  },
+});
+
+const panels = shallowReactive([
+  {
+    id: 'patient-diagnosis-records',
+    title: '就诊记录',
+    component: defineAsyncComponent(() => import('@/widgets/PatientDiagnosisRecordsWidget.vue')),
+  },
+  {
+    id: 'patient-health-records',
+    title: '健康分析记录',
+    component: defineAsyncComponent(() => import('@/widgets/PatientHealthRecordsWidget.vue')),
+  },
+  {
+    id: 'patient-physicalSign-records',
+    title: '生理指标',
+    component: defineAsyncComponent(() => import('@/widgets/PatientPhysicalSignRecordsWidget.vue')),
+  },
+]);
+
+const activePanel = ref(panels[0].id);
+const activePanelChange = (event: Event) => {
+  (event.target as HTMLElement)?.scrollIntoView({ block: 'start', behavior: 'smooth' });
+};
+
+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);
+          },
+        });
+      },
+    },
+  });
+}
+
+function openPatientRecordsPreview() {
+  const component = defineAsyncComponent(() => import('@/components/RecordsPatientPreview.vue'));
+  const id = `modal:record-patient:preview`;
+  const onDestroy = () => { VxeUI.modal.close(id); };
+  onDestroy();
+  VxeUI.modal.open({
+    id, remember: true,
+    showMaximize: true, mask: false, lockView: false, padding: false,
+    resize: true, width: Math.floor(window.innerWidth * 0.5), height: Math.floor(window.innerHeight * 0.5),
+    escClosable: true, maskClosable: true,
+    title: `基础信息更新记录`,
+    slots: {
+      default() {
+        return h(component, {
+          patient: patient.value,
+          onDestroy,
+        });
+      },
+    },
+  });
+}
+
+function openIndicatorRecordsPreview() {
+  const component = defineAsyncComponent(() => import('@/components/RecordsIndicatorPreview.vue'));
+  const id = `modal:record-indicator:preview`;
+  const onDestroy = () => { VxeUI.modal.close(id); };
+  onDestroy();
+  VxeUI.modal.open({
+    id, remember: true,
+    showMaximize: true, mask: false, lockView: false, padding: false,
+    resize: true, width: Math.floor(window.innerWidth * 0.5), height: Math.floor(window.innerHeight * 0.5),
+    escClosable: true, maskClosable: true,
+    title: `指标信息更新记录`,
+    slots: {
+      default() {
+        return h(component, {
+          patient: patient.value,
+          onDestroy,
+        });
+      },
+    },
+  });
+}
+</script>
+
+<template>
+  <div class="p-6">
+    <div class="flex">
+      <section class="flex-auto">
+        <header class="flex items-center">
+          <div class="title">基本信息</div>
+          <a-button type="link" @click="openPatientRecordsPreview">更新记录</a-button>
+        </header>
+        <main>
+          <div class="row">
+            <a-space class="separate" :size="0">
+              <span>{{ patient.name }}</span>
+              <UseDict v-slot="{ value }" sign="sys_user_sex" :raw="patient.gender">
+                <span v-if="value">{{ value }}</span>
+              </UseDict>
+              <span v-if="patient.age">{{ patient.age }} 岁</span>
+              <UseDict v-slot="{ value }" sign="women_special_period" :raw="patient.womenSpecialPeriod">
+                <span v-if="value && value !== '无'">{{ value }}</span>
+              </UseDict>
+              <UseDict v-slot="{ value }" sign="job" :raw="patient.job">
+                <span v-if="value && value !== '无'">{{ value }}</span>
+              </UseDict>
+              <span v-if="patient.phone"><label>手机号</label> {{ patient.phone }}</span>
+              <span v-if="patient.cardno"><label>身份证号</label> {{ patient.cardno }}</span>
+            </a-space>
+          </div>
+          <div class="row">
+            <a-space :size="40">
+              <span v-if="patient.height"><label>身高</label> {{ patient.height }} cm</span>
+              <span v-if="patient.weight"><label>体重</label> {{ patient.weight }} kg</span>
+              <UseDict v-slot="{ value }" sign="sys_yes_no" :raw="patient.drinkState">
+                <span v-if="value"><label>饮酒</label> {{ value }}</span>
+              </UseDict>
+              <UseDict v-slot="{ value }" sign="sys_yes_no" :raw="patient.smokeState">
+                <span v-if="value"><label>抽烟</label> {{ value }}</span>
+              </UseDict>
+              <UseDict v-slot="{ value }" sign="food_allergy" :raw="patient.foodAllergy2" :multiple="true" :separator="[',', '、']">
+                <span v-if="value"><label>食物过敏</label> {{ value }}</span>
+              </UseDict>
+              <UseDict v-slot="{ value }" sign="hobby_flavor" :raw="patient.hobbyFlavor" :multiple="true" :separator="[',', '、']">
+                <span v-if="value"><label>喜好口味</label> {{ value }}</span>
+              </UseDict>
+            </a-space>
+          </div>
+        </main>
+      </section>
+      <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>
+    <section class="mt-4">
+      <header class="flex items-center">
+        <div class="title">健康状况</div>
+      </header>
+      <main>
+        <div class="row" v-if="diagnosisRecord">
+          <header>
+            <label>诊断</label>
+            <span>{{ diagnosisRecord.disease?.name }} - {{ diagnosisRecord.symptom?.name }}</span>
+            <span v-if="diagnosisRecord.date">({{ diagnosisRecord.date }})</span>
+          </header>
+        </div>
+        <div class="row">
+          <header>
+            <label>健康状态</label>
+            <span>{{ healthRecord.result?.status }}</span>
+            <span v-if="healthRecord.date">({{ healthRecord.date }})</span>
+          </header>
+          <main>
+            <div class="row">
+              <a-space :size="24">
+                <a-space direction="vertical" :size="12">
+                  <span><label>程度</label>{{ healthRecord.result?.level }}</span>
+                  <span><label>表现</label>{{ healthRecord.result?.description }}</span>
+                  <span><label>症素</label>{{ healthRecord.syndromeElement?.label }}</span>
+                </a-space>
+                <a-space direction="vertical" :size="12">
+                  <span><label>类型</label>{{ healthRecord.result?.category }}</span>
+                  <span><label>体质</label>{{ healthRecord.physique?.label }}</span>
+                  <span><label>证型</label>{{ healthRecord.syndrome?.label }}</span>
+                </a-space>
+              </a-space>
+            </div>
+          </main>
+        </div>
+        <div class="row">
+          <header>
+            <label>症状</label>
+            <span>{{ healthRecord.symptom?.value || ' - ' }}</span>
+            <span v-if="healthRecord.symptom?.duration">,{{ healthRecord.symptom?.duration }}</span>
+            <span v-if="healthRecord.symptom?.influence">,{{ healthRecord.symptom?.influence }}</span>
+          </header>
+        </div>
+        <HealthReportAnalysisWidget class="row" category="tongue" :analysis="healthRecord.analysis"></HealthReportAnalysisWidget>
+        <HealthReportAnalysisWidget class="row" category="face" :analysis="healthRecord.analysis"></HealthReportAnalysisWidget>
+        <div class="row" v-if="indicator.length">
+          <header>
+            <label>生理指标</label>
+            <a-button type="link" @click="openIndicatorRecordsPreview">更新记录</a-button>
+          </header>
+          <main>
+            <div class="flex flex-wrap">
+              <div class="text-center w-260px row" v-for="item in indicator" :key="item.id">
+                <div class="flex justify-center">
+                  <span><label>{{ item.name }}</label>{{ item.value }}{{ item.unit }}</span>
+                  <div class="inline-block ml-2 size-24px">
+                    <a-button v-if="item.trend > 0" :icon="h(ArrowUpOutlined)" shape="circle" size="small" class="trend-up" />
+                    <a-button v-else-if="item.trend < 0" :icon="h(ArrowDownOutlined)" shape="circle" size="small" class="trend-down" />
+                  </div>
+                </div>
+                <div class="text-center mt-1" style="font-size: 14px; color: rgba(0, 0, 0, 0.45)">{{ item.date }}</div>
+              </div>
+            </div>
+          </main>
+        </div>
+      </main>
+    </section>
+    <a-tabs class="panel-wrapper" v-model:activeKey="activePanel">
+      <a-tab-pane v-for="panel in panels" :key="panel.id">
+        <component :is="panel.component" :patient="patient"></component>
+      </a-tab-pane>
+      <template #renderTabBar>
+        <a-radio-group v-model:value="activePanel" @change="activePanelChange($event.nativeEvent)">
+          <a-radio-button v-for="panel in panels" :key="panel.id" :value="panel.id" :disabled="panel.disabled">
+            {{ panel.title }}
+          </a-radio-button>
+        </a-radio-group>
+      </template>
+    </a-tabs>
+  </div>
+</template>
+
+<style scoped lang="scss">
+section {
+  color: rgba(0, 0, 0, 0.85);
+
+  > header {
+    font-size: 18px;
+    font-weight: 700;
+
+    :deep(.ant-btn-link) {
+      padding-block: 0;
+      font-size: 18px;
+      border: none;
+    }
+  }
+
+  > main {
+    margin-left: 18px * 4;
+    padding: 0 15px;
+    font-size: 16px;
+    color: rgba(0, 0, 0, 0.85);
+
+    > .row > .ant-space {
+      font-size: 16px;
+    }
+
+    .row {
+      padding: 12px 0;
+
+      span > label {
+        color: rgba(0, 0, 0, 0.45);
+      }
+
+      label::after {
+        margin-left: 2px;
+        margin-right: 8px;
+        content: ':';
+      }
+
+      > header::before {
+        $size: 10px;
+        content: '';
+        display: inline-block;
+        margin-right: 12px;
+        width: $size;
+        height: $size;
+        border: 2px solid #1d6ff6;
+        border-radius: 50%;
+      }
+
+      > main {
+        margin-left: 18px * 2;
+      }
+    }
+  }
+
+  .ant-tag {
+    margin-top: 6px;
+  }
+}
+
+.separate {
+  :deep(.ant-space-item) {
+    & + .ant-space-item::before {
+      content: ',';
+      margin-right: 2px;
+    }
+  }
+
+  span + span::before {
+    content: ',';
+    margin-right: 2px;
+  }
+}
+
+.panel-wrapper {
+  :deep(.ant-tabs-content-holder) {
+    padding-top: 12px;
+    height: calc(100vh - 60px - 24px - 32px);
+
+    .ant-tabs-content {
+      height: 100%;
+    }
+  }
+}
+
+.trend-up {
+  color: #ff4d4f;
+  border-color: #ff4d4f;
+}
+
+.trend-down {
+  color: #87d068;
+  border-color: #87d068;
+}
+</style>

+ 124 - 0
src/components/PatientMedicalHistoryPreview.vue

@@ -0,0 +1,124 @@
+<script setup lang="ts">
+import type { MedicalHistoryVO } from '@/model/diagnosis-report.model';
+
+const props = defineProps<Partial<MedicalHistoryVO>>();
+</script>
+
+<template>
+  <div class="patient-medical-history-wrapper">
+    <header class="text-center text-4.5 font-600">门诊病历</header>
+    <main class="mt-4">
+      <a-descriptions :column="10">
+        <a-descriptions-item label="就诊日期" :span="3">{{props.date}}</a-descriptions-item>
+        <a-descriptions-item label="姓名" :span="2">{{props.patient?.name}}</a-descriptions-item>
+        <a-descriptions-item label="性别" :span="1">{{props.patient?.gender}}</a-descriptions-item>
+        <a-descriptions-item label="年龄" :span="1">{{props.patient?.age}}</a-descriptions-item>
+        <a-descriptions-item label="电话" :span="3">{{props.patient?.phone}}</a-descriptions-item>
+        <a-descriptions-item label="就诊科室" :span="3">{{props.diagnosis?.department.name}}</a-descriptions-item>
+        <a-descriptions-item label="身份证" :span="2">{{props.patient?.identityCard}}</a-descriptions-item>
+        <a-descriptions-item label="地址" :span="5">{{props.patient?.address}}</a-descriptions-item>
+      </a-descriptions>
+      <div class="row" v-for="item in props.diagnosis?.descriptions" :key="item.label">
+        <header>
+          <label>{{item.label}}</label>
+          <span>{{item.value}}</span>
+        </header>
+      </div>
+      <div class="row">
+        <header>
+          <label>诊断</label>
+        </header>
+        <main>
+          <div class="row">
+            <a-space direction="vertical" :size="12">
+              <span><label>中医诊断</label>{{props.diagnosis?.TCM}}</span>
+              <span><label>西医诊断</label>{{props.diagnosis?.ICD}}</span>
+            </a-space>
+          </div>
+        </main>
+      </div>
+      <div class="row">
+        <header>
+          <label>处置方式</label>
+        </header>
+        <main>
+          <div class="row">
+            <a-space direction="vertical" :size="12">
+              <div v-for="prescription in props.prescriptions" :key="prescription.title">
+                <span>{{prescription.title}}</span>
+                <a-row style="padding-right: 36px;">
+                  <a-col v-for="medicine in prescription.medicines" :key="medicine.id" class="flex items-center justify-evenly min-h-10" :span="6">
+                    <span style="width: 50%;" class="text-center">{{medicine.name}}</span>
+                    <span>{{medicine.dosage}}{{medicine.unit}}</span>
+                    <span style="color: rgba(0, 0, 0, 0.45);">{{medicine.usage}}</span>
+                  </a-col>
+                </a-row>
+                <a-space direction="vertical" :size="12">
+                  <span>
+                    <label>剂数</label>
+                    {{prescription.count}}
+                    <template v-if="prescription.decoction?.label">({{prescription.decoction?.label}})</template>
+                    <span style="margin-right: 12px;">{{prescription.dosageType?.label}}</span>
+                    <span style="margin-right: 12px;">{{prescription.method?.label}}</span>
+                    <span style="margin-right: 12px;">{{prescription.dosage?.label}}</span>
+                    <span style="margin-right: 12px;">{{prescription.frequency?.label}}</span>
+                    <span style="margin-right: 12px;">{{prescription.frequencyTime?.label}}</span>
+                  </span>
+                  <span><label>嘱托</label>{{prescription.remark}}</span>
+                </a-space>
+              </div>
+            </a-space>
+          </div>
+        </main>
+      </div>
+
+    </main>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.ant-descriptions {
+  margin-bottom: 12px;
+  border-bottom: 1px solid rgba(5, 5, 5, 0.2);
+
+  :deep(.ant-descriptions-item-label),
+  :deep(.ant-descriptions-item-content) {
+    font-size: 16px;
+    color: rgba(0, 0, 0, 0.85);
+  }
+}
+
+main {
+  font-size: 16px;
+  color: rgba(0, 0, 0, 0.85);
+}
+
+.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;
+  }
+}
+</style>

+ 134 - 0
src/components/PatientTagEdit.vue

@@ -0,0 +1,134 @@
+<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: searchTags, loading: tagsLoading } = useRequest(searchTagsFromSelectableMethod, {
+  initialData: [],
+});
+
+const selected = computed(() => form.value.tags.map((item) => item.id));
+
+const formProps = reactive<VxeFormProps<FormModel>>({
+  titleWidth: 0,
+  titleAlign: 'right',
+  titleColon: true,
+  items: [
+    {
+      field: 'selected',
+      title: '',
+      span: 24,
+      itemRender: {
+        name: 'VxeSelect',
+        props: {
+          loading: tagsLoading,
+          optionGroups: computed(() =>
+            list2Groups(
+              searchTags.value.filter((tag) => !(tag.disabled || selected.value.includes(tag.id))),
+              'category',
+              (key) => ({ 1: '系统标签', 2: '个人标签' })[key]!
+            )
+          ),
+          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 = searchTags.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>

+ 2 - 2
src/components/RecordsPatientPreview.vue

@@ -5,7 +5,7 @@ import { patientDictionariesMethod }                                  from '@/re
 import { patientUpdateRecordsMethod }                                 from '@/request/api/report.api';
 import { usePagination, useRequest }                                  from 'alova/client';
 
-import { type VxeColumnPropTypes, type VxeTableEvents, type VxeTableInstance } from 'vxe-pc-ui';
+import type { VxeColumnPropTypes, VxeTableEvents, VxeTableInstance } from 'vxe-table';
 
 
 const props = defineProps<{
@@ -59,7 +59,7 @@ const scrollEvent: VxeTableEvents.Scroll<AnalysisRecordModel> = (params) => {
 <template>
   <vxe-table
     ref="tableRef" :data="records" :loading
-    max-height="100%" auto-resize align="center"
+    height="100%" auto-resize align="center"
     :column-config="{resizable:true,isCurrent: true}"
     :row-config="{isCurrent: true, isHover: true}"
     @cell-click="cellClickEvent"

+ 1 - 1
src/components/ReportHistoryPreview.vue

@@ -39,7 +39,7 @@ const { data: patient, loading: patientLoading } = useWatcher(
 );
 
 const { data: tags, loading: tagsLoading, send: loadTags } = useWatcher(
-  () => patientTags(patientId.value),
+  () => patientTags(patientId.value!),
   [ patientId ],
   {
     initialData: [], immediate: true,

+ 11 - 5
src/components/ReportPreview.vue

@@ -10,15 +10,15 @@ import { useWatcher }
 
 
 const props = defineProps<{
-  patient: Partial<PatientModel>;
-  report: Partial<ReportModel>;
+  patient?: Partial<PatientModel>;
+  report?: Partial<ReportModel>;
   scheme?: Partial<ReportSchemeModel>;
 }>();
 const emits = defineEmits<{
   destroy: [];
 }>();
 
-const patientId = computed(() => props.patient.id);
+const patientId = defineModel<string>('patientId', { default: void 0 });;
 const reportId = defineModel<string>('reportId', { default: void 0 });
 const showType = defineModel<'analysis' | 'scheme'>('type', { default: 'analysis' });
 
@@ -29,7 +29,10 @@ const { data: report, loading: reportLoading } = useWatcher(
     initialData: { ...props.report }, immediate: true,
     middleware: (_, next) => { if ( reportId.value && showType.value === 'analysis' ) next(); },
   },
-).onSuccess(() => scrollIntoView());
+).onSuccess(({data}) => {
+  scrollIntoView();
+  patientId.value ??= data.patientId;
+});
 const { data: indicator, loading: indicatorLoading } = useWatcher(
   () => indicatorByReportIdMethod(reportId.value!),
   [ reportId, showType ],
@@ -57,7 +60,10 @@ const { data: scheme, loading: schemeLoading } = useWatcher(
   },
 ).onSuccess(() => scrollIntoView());
 
-onBeforeMount(() => { reportId.value ??= props.report.id!; });
+onBeforeMount(() => {
+  reportId.value ??= props.report?.id!;
+  patientId.value ??= props.patient?.id!;
+});
 
 const schemeKeys = ref([]);
 const collapsed = ref(false);

+ 21 - 0
src/core/dictionary/component.ts

@@ -0,0 +1,21 @@
+import { useDict, type UseDict } from '@/core/dictionary/use';
+import type { MaybeDictValue } from '@/core/dictionary/index';
+import type { Reactive, SlotsType } from 'vue';
+
+export default /*#__PURE__*/ defineComponent({
+  name: 'UseDict',
+  props: {
+    sign: { type: String as PropType<string>, required: true },
+    raw: String as PropType<MaybeDictValue>,
+  },
+  slots: Object as SlotsType<{
+    default: Reactive<UseDict>;
+  }>,
+  setup(props, { slots, attrs }) {
+    const data = reactive(useDict(props.sign, toRef(props, 'raw'), attrs));
+
+    return () => {
+      if (slots.default) return slots.default(data);
+    };
+  },
+});

+ 27 - 0
src/core/dictionary/data.ts

@@ -0,0 +1,27 @@
+import request from '@/request/alova';
+import type { Dictionaries } from '@/core/dictionary/index';
+
+
+
+function getRemoteDictionaries(sign: string) {
+  return request.Get<Dictionaries, any[]>(`/system/dict/data/type/${sign}`, {
+    cacheFor: {
+      mode: 'restore',
+      expire: 1000 * 60 * 60 * 24,
+      tag: __APP_VERSION__,
+    },
+    transform(data: any[]) {
+      return (
+        data?.map((item) => ({
+          label: item.dictLabel,
+          value: item.dictValue,
+          default: item.isDefault === 'Y',
+        })) ?? []
+      );
+    },
+  });
+}
+
+export async function getDictionaries(sign: string) {
+  return getRemoteDictionaries(sign);
+}

+ 9 - 0
src/core/dictionary/index.ts

@@ -0,0 +1,9 @@
+import type { MaybeRefOrGetter } from 'vue';
+
+export type Dictionaries = {
+  readonly label: string;
+  readonly value: MaybeDictValue;
+}[];
+
+export type MaybeDictValue = string | number | boolean | null | undefined | object;
+export type MaybeComputedDictValueRef<T extends MaybeDictValue = MaybeDictValue> = MaybeRefOrGetter<T>;

+ 78 - 0
src/core/dictionary/use.ts

@@ -0,0 +1,78 @@
+import { tryOnBeforeMount, tryOnUnmounted } from '@vueuse/core';
+import { getDictionaries } from '@/core/dictionary/data';
+import type { Dictionaries, MaybeComputedDictValueRef, MaybeDictValue } from '@/core/dictionary/index';
+
+type Dictionary = Dictionaries[number];
+
+interface UseDictOptions {
+  multiple?: boolean;
+  separator?: string | [string, string?];
+  default?: MaybeDictValue;
+  /**
+   * 从原值中取出的键
+   * @default value
+   */
+  rawKey?: string;
+  /**
+   * 从字典中取出的键
+   * @default label
+   */
+  dictKey?: 'label' | 'value';
+  /**
+   * 匹配函数
+   * @param {Dictionary} dict 字典项
+   * @param plain 原始值
+   */
+  predicate?: (dict: Dictionary, plain: MaybeDictValue) => boolean;
+
+  ignore?: boolean;
+}
+
+export function useDict(sign: string, raw: MaybeComputedDictValueRef, options?: UseDictOptions) {
+  const rawKey = options?.rawKey ?? 'value';
+  const dictKey = options?.dictKey ?? 'label';
+  const predicate = options?.predicate ?? ((dict, plain) => dict.value === plain);
+  const ignore = options?.ignore ?? false;
+
+  const loading = ref(false);
+  const value = shallowRef<MaybeDictValue>();
+  const values = shallowRef<MaybeDictValue[]>([]);
+  const dictionaries = shallowRef<Dictionaries>([]);
+
+  const get = async () => {
+    loading.value = true;
+    dictionaries.value = await getDictionaries(sign);
+    loading.value = false;
+  };
+
+  const { stop } = watchEffect(async () => {
+    const plain = toValue(raw);
+
+    const multiple = options?.multiple ?? false;
+    const separator = Array.isArray(options?.separator) ? options.separator : [options?.separator ?? ','];
+    const plains = multiple && typeof plain === 'string' ? plain.split(separator[0]) : Array.isArray(plain) ? plain : [plain];
+
+    const results: MaybeDictValue[] = [];
+
+    for (const p of plains) {
+      const r = p && typeof p === 'object' ? (p as any)[rawKey] : p;
+      const v = dictionaries.value.find((dict) => predicate(dict, r))?.[dictKey] ?? options?.default ?? r;
+      if (v || !ignore) results.push(v);
+    }
+
+    value.value = multiple ? results.join(separator[1] ?? separator[0]) : results[0];
+    values.value = results;
+  });
+
+  tryOnBeforeMount(get);
+  tryOnUnmounted(stop);
+
+  return {
+    loading,
+    value,
+    values,
+    dictionaries,
+  };
+}
+
+export type UseDict = ReturnType<typeof useDict>;

+ 14 - 8
src/libs/vxe/plugin.ts

@@ -2,7 +2,7 @@ import type { App } from 'vue';
 
 import {
   VxeButton,
-  VxeButtonGroup, VxeDatePicker,
+  VxeButtonGroup,
   VxeDrawer,
   VxeForm,
   VxeFormDesign,
@@ -11,21 +11,24 @@ import {
   VxeFormView,
   VxeIcon,
   VxeInput,
+  VxeNumberInput,
+  VxeDatePicker,
   VxeList,
   VxeLoading,
   VxeModal,
   VxePager,
   VxeRadio,
   VxeRadioGroup,
+  VxeCheckbox,
+  VxeCheckboxGroup,
   VxeSelect,
   VxeSwitch,
   VxeTooltip,
   VxeTree,
   VxeTreeSelect,
   VxeUI,
-  VxeNumberInput,
-  VxeCheckboxGroup,
-  VxeCheckbox, VxeRow, VxeCol,
+  VxeRow,
+  VxeCol,
 } from 'vxe-pc-ui';
 
 import zhCN from 'vxe-pc-ui/lib/language/zh-CN';
@@ -40,23 +43,26 @@ 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(VxeDatePicker);
+  app.use(VxeCheckbox);
+  app.use(VxeCheckboxGroup);
+
   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);
 

+ 186 - 0
src/model/diagnosis-report.model.ts

@@ -0,0 +1,186 @@
+export interface DiagnosisReportDTO {
+  healthAnalysisReportId: string;
+  medicalTime: string;
+  medicalDepartment: string;
+  medicalDoctor: string;
+  diagnosis: string;
+  symptom: string;
+  syndrome: string;
+  medicalContentJson?: MedicalHistoryDTO;
+}
+
+export interface DiagnosisReportVO {
+  id: string;
+  date: string;
+  department: {
+    id?: string;
+    name: string;
+  };
+  doctor: {
+    id?: string;
+    name: string;
+  };
+  disease: {
+    id?: string;
+    name: string;
+  };
+  symptom: {
+    id?: string;
+    name: string;
+  };
+
+  medicalHistory?: MedicalHistoryVO;
+}
+
+export function fromDiagnosisReport(data: DiagnosisReportDTO): DiagnosisReportVO {
+  const vo = {
+    id: '',
+    date: data.medicalTime,
+    department: { name: data.medicalDepartment },
+    doctor: { name: data.medicalDoctor },
+    disease: { name: data.diagnosis },
+    symptom: { name: data.symptom },
+
+    medicalHistory: data.medicalContentJson ? fromMedicalHistory(data.medicalContentJson) : void 0,
+  } as DiagnosisReportVO;
+  if (vo.medicalHistory) {
+    vo.medicalHistory.diagnosis.department.id ??= vo.department.id;
+    vo.medicalHistory.diagnosis.department.name ??= vo.department.name;
+  }
+  return vo;
+}
+
+interface PatientVO {
+  id: string;
+  name: string;
+  avatar?: string;
+  gender?: '0' | '1';
+  age?: string | number;
+
+  phone?: string;
+  identityCard?: string;
+
+  address?: string;
+}
+
+interface PatientDTO {
+  name: string;
+  sex: string;
+  age: string;
+  phone: string;
+  idCard: string;
+  address: string;
+}
+
+export interface MedicalHistoryDTO extends PatientDTO {
+  pid: string;
+  // 就诊日期
+  treatmentTime: string;
+
+  tcmDiagnosis: string;
+  westernMedicineDiagnosis: string;
+
+  treatment: string;
+}
+
+export interface MedicalHistoryVO {
+  id: string;
+  date: string;
+  patient: PatientVO;
+
+  diagnosis: {
+    TCM: string;
+    ICD: string;
+    therapies: string;
+    descriptions: { label: string; value: string }[];
+
+    department: { id?: string; name: string };
+  };
+
+  prescriptions: HerbalMedicinePrescriptionVO[];
+}
+
+interface HerbalMedicinePrescriptionVO {
+  title: string;
+  count: number;
+  method: { label: string; value?: string };
+  dosage: { label: string; value?: string };
+  dosageType: { label: string; value?: string };
+  frequency: { label: string; value?: string };
+  frequencyTime: { label: string; value?: string };
+  decoction: { label: string;  };
+  remark: string;
+  medicines: HerbalMedicineVO[];
+}
+
+interface HerbalMedicineVO {
+  id: string;
+  name: string;
+  dosage: number;
+  unit: string;
+  usage: string;
+}
+
+export function fromMedicalHistory(data: MedicalHistoryDTO & Record<string, any>): MedicalHistoryVO {
+  return {
+    id: data.pid,
+    date: data.treatmentTime,
+    patient: {
+      id: '',
+      name: data.name,
+      gender: data.sex as PatientVO['gender'],
+      age: data.age,
+      phone: data.phone,
+      identityCard: data.idCard,
+      address: data.address,
+    },
+    diagnosis: {
+      TCM: data.tcmDiagnosis,
+      ICD: data.westernMedicineDiagnosis,
+      therapies: data.treatment,
+      department: { name: data.clinic },
+
+      descriptions: [
+        { label: '主诉', value: data['chiefcomplaint'] },
+        { label: '现病史', value: data['historypresent'] },
+        { label: '既往史', value: data['pasthistory'] },
+        { label: '中医四诊', value: data['fourmedicine'] },
+        { label: '体格检查', value: data['physicalexamination'] },
+        { label: '辅助检查', value: data['supplementaryexamination'] },
+      ],
+    },
+    prescriptions: [
+      ...(data.zhongyaochufangVos?.map?.(fromHerbalMedicinePrescription) ?? []),
+      ...(data.zhongyaozhijiVos?.map?.(fromPreparationPrescription) ?? [])
+    ],
+  };
+}
+
+function fromHerbalMedicinePrescription(data: Record<string, any>, index?: number): HerbalMedicinePrescriptionVO {
+  return {
+    title: '中药处方' + (index != null ? index + 1 : ''),
+    count: data.num,
+    method: { label: data['usestr'] },
+    dosage: { label: data['strongFried'] ? `每次${data['strongFried']}` : '', value: data['strongFried'] },
+    dosageType: { label: data['dosageForm'] },
+    frequency: { label: data['frequency'] },
+    frequencyTime: { label: data['eatMedicineTime'] },
+    remark: data.command,
+    decoction: { label: data['agency'] === '1' ? '代煎' : '' },
+    medicines: data.prescriptiondetailVos?.map?.(fromHerbalMedicine) ?? [],
+  };
+}
+
+function fromPreparationPrescription(data: Record<string, any>): {} {
+  return {};
+}
+
+function fromHerbalMedicine(data: Record<string, any>): HerbalMedicineVO {
+  return {
+    id: data.drugid,
+    name: data.drugName,
+    dosage: data.dose,
+    unit: data.unit,
+    usage: data.usagestr,
+  };
+}

+ 15 - 0
src/model/health-report-analysis.config.ts

@@ -50,3 +50,18 @@ export const analysisPictureStrategy: Record<
     });
   },
 };
+
+export const HealthReportSymptomItemConfig = {
+  有一点: 2.0,
+  偶尔: 2.0,
+  轻: 2.0,
+  有些: 3.0,
+  有时: 3.0,
+  中: 3.0,
+  相当: 4.0,
+  经常: 4.0,
+  重: 4.0,
+  非常: 5.0,
+  总是: 5.0,
+  非常重: 5.0,
+} as const;

+ 162 - 2
src/model/health-report.model.ts

@@ -4,6 +4,7 @@ import {
   HealthReportAnalysisItemConfig,
   type HealthReportAnalysisKey,
   type HealthReportAnalysisSubcategory,
+  HealthReportSymptomItemConfig,
 } from '@/model/health-report-analysis.config';
 
 export interface HealthReportAnalysisItemDTO {
@@ -29,6 +30,17 @@ export interface HealthReportDTO extends Record<HealthReportAnalysisKey, HealthR
   pickedSymptom?: string;
   duration?: string;
   influenceDegree?: string;
+
+  willillStateName: string;
+  willillDegreeName: string;
+  willillFunctionName: string;
+  willillSocialName: string;
+
+  constitutionGroupName: string;
+  constitutionGroupDefinition: string;
+
+  factorItemSummary: string;
+  diagnoseSyndromeSummary: string;
 }
 
 export interface HealthReportAnalysisItemVO {
@@ -42,6 +54,9 @@ export interface HealthReportAnalysisItemVO {
     actualValue: string;
     resultValue: string;
     tags: string[];
+    /**
+     * 特征
+     */
     feature: string;
     /**
      * 意义
@@ -69,6 +84,85 @@ export interface HealthReportAnalysisVO
   id?: string;
 }
 
+export interface HealthReportSymptomItemVo {
+  /**
+   * 症状ID
+   */
+  id: string;
+  /**
+   * 症状名称
+   */
+  name: string;
+  /**
+   * 症状描述
+   */
+  label: string;
+  /**
+   * 症状得分
+   */
+  value: number;
+}
+
+export interface HealthReportSymptomVo {
+  items: HealthReportSymptomItemVo[];
+  value?: string;
+  duration?: string;
+  influence?: string;
+}
+
+export interface HealthReportVO {
+  id: string;
+  date: string;
+  analysis: HealthReportAnalysisVO;
+  symptom: HealthReportSymptomVo;
+
+  result: {
+    status?: string;
+    level?: string;
+    description?: string;
+    category: string;
+  };
+
+  physique: {
+    label: string;
+    description: string;
+  };
+
+  syndrome: {
+    label: string;
+  };
+  syndromeElement: {
+    label: string;
+  };
+}
+
+export function fromHealthReport(data: HealthReportDTO): HealthReportVO {
+  return {
+    id: data.healthAnalysisReportId,
+    date: data.reportTime,
+    analysis: fromHealthReportAnalysis(data),
+    symptom: fromHealthReportSymptom(data),
+
+    result: {
+      status: data.willillStateName,
+      level: data.willillDegreeName,
+      description: data.willillFunctionName,
+      category: data.willillSocialName,
+    },
+
+    physique: {
+      label: data.constitutionGroupName,
+      description: data.constitutionGroupDefinition,
+    },
+    syndromeElement: {
+      label: data.factorItemSummary,
+    },
+    syndrome: {
+      label: data.diagnoseSyndromeSummary,
+    },
+  };
+}
+
 export function fromHealthReportAnalysis(data: Partial<HealthReportDTO>, config = HealthReportAnalysisItemConfig): HealthReportAnalysisVO {
   const result = { id: data.tonguefaceAnalysisReportId } as HealthReportAnalysisVO;
   for (const { key, category, subcategory } of config) {
@@ -86,7 +180,9 @@ export function fromHealthReportAnalysis(data: Partial<HealthReportDTO>, config
       const contrast = item.contrast === 's' ? '' : (item.contrast ?? '');
       const [_, features = _, significance = item.clinicalSignificance] = item.features?.match?.(
         new RegExp(`【(?:${actualValue}|正常${subcategory}|正常面色)】([^<]*)<?[\\s\\S]*?【病理意义】([^<]*)`)
-      ) ?? [item.features];
+      ) ?? item.features?.match?.(
+        new RegExp(`([^<]*)<?[\\s\\S]*?【病理意义】([^<]*)`)
+      ) ??  [item.features];
       values.push({
         cover: contrast ? cover : void 0,
         actualValue,
@@ -107,4 +203,68 @@ export function fromHealthReportAnalysis(data: Partial<HealthReportDTO>, config
     });
   }
   return result;
-}
+}
+
+export function fromHealthReportSymptom(data: Partial<HealthReportDTO>, config = HealthReportSymptomItemConfig): HealthReportSymptomVo {
+  const result: HealthReportSymptomVo = {
+    value: data.pickedSymptom,
+    items:
+      data.pickedSymptomList?.map(({ id, name, ...item }) => ({
+        id,
+        name,
+        label: item.value,
+        value: +item.score,
+      })) ?? [],
+    duration: data.duration,
+    influence: data.influenceDegree,
+  };
+  if (!result.items?.length && result.value) {
+    const matches = result.value?.matchAll(/([^,(]+)(([^)]+))/g) ?? [];
+    for (const [_, name, label] of matches) {
+      // @ts-ignore
+      const value = config[label] ?? 0;
+      result.items.push({ id: name, name, label, value });
+    }
+  }
+  return result;
+}
+
+export interface HealthIndicatorVO {
+  items: HealthIndicatorItemVO[];
+  id: string;
+  name: string;
+}
+
+export interface HealthIndicatorItemVO {
+  id: string;
+  name: string;
+  value: number;
+  unit: string;
+  trend: 0 | -1 | 1;
+
+  date: string;
+}
+
+export function fromHealthIndicator(data: Record<string, any>): HealthIndicatorVO {
+  return {
+    id: data.quotaId,
+    name: data.name,
+    items: data.patientQuotaRecordDTOS?.map?.((item: any) => fromHealthIndicatorItem(Object.assign(item, data))) ?? [],
+  };
+}
+
+export function fromHealthIndicatorItem(data: Record<string, any>): HealthIndicatorItemVO {
+  const getTrend = (value: string) => {
+    if (value?.includes('高')) return 1 as const;
+    if (value?.includes('低')) return -1 as const;
+    return 0 as const;
+  };
+  return {
+    id: data.quotaId,
+    name: data.name,
+    unit: data.unit,
+    value: data.quotaVal,
+    trend: data.abnormal ? getTrend(data.abnormalDesc) : 0,
+    date: data.time2,
+  };
+}

+ 30 - 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;
@@ -9,11 +8,13 @@ export interface PatientQuery {
   cardno?: string;
   isHaveHealthAnalysisReport?: boolean;
   tags?: string[];
+
+  count?: number;
 }
 
 export interface PatientModel extends PeopleModel {
   tags?: string[];
-  [ key: string ]: any;
+  [key: string]: any;
 }
 
 export interface PatientReportModel extends PatientModel {
@@ -33,6 +34,8 @@ export function transformPatient(data: any): PatientModel {
     gender: data?.sex,
     age: data?.age,
     tags: data?.tags ?? [],
+
+    foodAllergy2: data?.foodAllergy?.replace?.('8:', '') ?? '',
   };
 }
 
@@ -42,3 +45,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',
+  }
+}

+ 16 - 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,11 +43,26 @@ 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;
+}
 
 
 // export interface PlanQuery {

+ 103 - 36
src/pages/index/patient/history.vue

@@ -1,24 +1,20 @@
 <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 PatientEdit from '@/components/PatientEdit.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>>({
@@ -27,11 +23,14 @@ const searchFormProps = reactive<VxeFormProps<PatientQuery>>({
   titleColon: true,
   data: {},
   items: [
-    { field: 'patientName', title: '患者姓名', span: 8, itemRender: { name: 'VxeInput' } },
-    // { field: 'phone', title: '手机号码', span: 8, itemRender: { name: 'VxeInput', props: { maxLength: 11 } } },
-    { field: 'cardno', title: '身份证号', span: 8, itemRender: { name: 'VxeInput', props: { maxLength: 18 } } },
+    { field: 'patientName', title: '患者姓名', span: 6, itemRender: { name: 'VxeInput' } },
+    { field: 'phone', title: '手机号码', span: 6, itemRender: { name: 'VxeInput', props: { maxLength: 11 } } },
+    { field: 'cardno', title: '身份证号', span: 6, itemRender: { name: 'VxeInput', props: { maxLength: 18 } } },
     {
-      field: 'isHaveHealthAnalysisReport', title: '健康分析报告', span: 8, itemRender: {
+      field: 'isHaveHealthAnalysisReport',
+      title: '健康分析报告',
+      span: 6,
+      itemRender: {
         name: 'VxeRadioGroup',
         options: [
           { label: '有', value: true },
@@ -43,18 +42,31 @@ 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,
         },
       },
     },
     {
-      span: 8, itemRender: {
+      span: 6,
+      itemRender: {
         name: 'VxeButtonGroup',
         options: [
           { type: 'submit', content: '查询', status: 'primary' },
@@ -72,14 +84,17 @@ const searchFormEmits: VxeFormListeners<PatientQuery> = {
 const gridRef = ref<VxeGridInstance<PatientReportModel>>();
 const gridOptions = reactive<VxeGridProps<PatientReportModel>>({
   id: 'patient-history-list',
-  border: true,
+  border: false,
   showOverflow: true,
-  height: 'auto', autoResize: false, syncResize: true,
+  height: 'auto',
+  autoResize: false,
+  syncResize: true,
   scrollY: { enabled: true, gt: 0 },
   toolbarConfig: {
     custom: true,
     zoom: true,
     slots: {
+      buttons: 'toolbar-buttons',
       tools: 'toolbar-extra',
     },
   },
@@ -93,9 +108,9 @@ const gridOptions = reactive<VxeGridProps<PatientReportModel>>({
     { type: 'seq', width: 70, fixed: 'left' },
     { field: 'name', title: '姓名', minWidth: 80 },
     { field: 'gender', title: '性别', minWidth: 40, formatter: 'gender' },
-    { field: 'age', title: '年龄', minWidth: 40, formatter: ({ cellValue }) => cellValue ? `${ cellValue }岁` : '' },
+    { field: 'age', title: '年龄', minWidth: 40, formatter: ({ cellValue }) => (cellValue ? `${cellValue}岁` : '') },
+    { field: 'diagnosis', title: '诊断', minWidth: 40 },
     { field: 'tags', title: '标签' },
-    { field: 'report.time', title: '最近一次健康分析时间' },
     { field: 'createTime', title: '创建时间' },
     {
       title: '操作',
@@ -107,14 +122,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 +176,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,7 +203,50 @@ 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,
+        });
+      },
+    },
+  });
+}
+
+function openPatientEditHandle() {
+  const id = `modal:patient-info-handle:edit`;
+  const onDestroy = (_refresh?: boolean) => {
+    VxeUI.modal.close(id);
+    if (_refresh) refresh(1);
+  };
+  onDestroy();
+  VxeUI.modal.open({
+    id,
+    title: `新增患者`,
+    maskClosable: true,
+    escClosable: true,
+    slots: {
+      default() {
+        return h(PatientEdit, { onDestroy });
+      },
+    },
+  });
 }
 </script>
 <template>
@@ -192,6 +256,9 @@ function openPatientAnalysisCountResetHandle({ report, ...patient }: PatientRepo
     </header>
     <main class="flex-auto overflow-hidden">
       <vxe-grid ref="gridRef" v-bind="gridOptions" v-on="gridEvents" :loading="loading">
+        <template #toolbar-buttons>
+          <vxe-button status="primary" @click="openPatientEditHandle()">新增</vxe-button>
+        </template>
         <template #toolbar-extra>
           <vxe-button style="margin-right: 12px;" icon="vxe-icon-repeat" circle @click="refresh(page)"></vxe-button>
         </template>

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

+ 69 - 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 ?? {}, {
@@ -20,6 +23,15 @@ export function patientsHistoryMethod(page: number, size: number, query?: Patien
   });
 }
 
+export function appendPatientMethod(data: Record<string, any>) {
+  const { disease: diagnosis, date: medicalTime, department: medicalDepartment, doctor: medicalDoctor, tags, ...patient } = data;
+  return request.Post(
+    `/fdhb-pc/patientMedicalManage/addPatientMedical`,
+    { ...patient, diagnosis, medicalTime, medicalDepartment, medicalDoctor, },
+    {}
+  );
+}
+
 export function patientsRoomMethod(query?: PatientQuery) {
   return request.Post<PatientReportModel[], any[]>(`/fdhb-pc/patientInfoManage/pendPatient`, query ?? {}, {
     params: {},
@@ -48,6 +60,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 +79,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',

+ 99 - 1
src/request/api/report.api.ts

@@ -13,7 +13,11 @@ import {
 } from '@/model';
 
 import request from '@/request/alova';
-import dayjs   from 'dayjs';
+import dayjs from 'dayjs';
+
+import { fromHealthIndicator, fromHealthReport, type HealthIndicatorItemVO, type HealthIndicatorVO, type HealthReportDTO, type HealthReportVO } from '@/model/health-report.model';
+import { type DiagnosisReportDTO, type DiagnosisReportVO, fromDiagnosisReport } from '@/model/diagnosis-report.model';
+import { type FollowUpReportDTO, type FollowUpReportVO, fromFollowUpReport } from '@/model/follow-up-report.model';
 
 
 export function reportsMethod(patientId: string) {
@@ -52,6 +56,15 @@ export function reportMethod(id: string) {
   });
 }
 
+export function patientHealthReportMethod(id: string) {
+  return request.Get<HealthReportVO, HealthReportDTO>(`/fdhb-pc/analysisManage/getHealRepDetailById`, {
+    hitSource: 'confirm-scheme',
+    name: 'get-report',
+    params: { healthAnalysisReportId: id },
+    transform: fromHealthReport,
+  });
+}
+
 export function reportSchemeMethod(reportId: string) {
   return request.Get(`/fdhb-pc/analysisManage/getCondProgDetailById`, {
     hitSource: /-scheme$/,
@@ -211,3 +224,88 @@ export function analysisUpdateRecordsMethod(page: number, size: number, query: {
     },
   });
 }
+
+/**
+ * 获取患者健康分析记录列表
+ * @param page
+ * @param size
+ * @param query
+ */
+export function getPatientHealthRecordsMethod(page: number, size: number, query: { patientId: string }) {
+  return request.Post<{ total: number; data: HealthReportVO[] }, { total: number; data: HealthReportDTO[] }>(`/fdhb-pc/analysisManage/pageHarStatu`, query, {
+    params: { pageNum: page, pageSize: size, ...query },
+    transform({ data, total }) {
+      return { total, data: data.map(fromHealthReport) };
+    },
+  });
+}
+
+export function getPatientHealthRecordMethod(id: string) {
+  return request.Get<HealthReportVO, HealthReportDTO>(`/fdhb-pc/analysisManage/getHealRepDetailById`, {
+    params: { healthAnalysisReportId: id },
+    transform(data) {
+      return fromHealthReport(data);
+    },
+  });
+}
+
+export function getPatientHealthIndicatorsMethod(patientId: string) {
+  return request.Get<HealthIndicatorVO[], any[]>(`/fdhb-pc/patientQuota/getQuovalRecord`, {
+    hitSource: 'update-indicator',
+    name: 'patient-indicator',
+    params: { patientId },
+    transform(data) {
+      return data.map(fromHealthIndicator).filter(indicator => indicator.items.length);
+    },
+  });
+}
+
+export function getPatientHealthIndicatorMethod(patientId: string) {
+  return request.Get<HealthIndicatorItemVO[], any[]>(`/fdhb-pc/patientQuota/getCurQuovalByPatId`, {
+    hitSource: 'update-indicator',
+    name: 'patient-indicator',
+    params: { patientId },
+    transform(data) {
+      return data.map((indicator) => {
+        const { items, ...collection } = fromHealthIndicator(indicator);
+        return { ...collection, ...items[0] };
+      }).filter(item => !!item.value);
+    },
+  });
+}
+
+/**
+ * 获取患者就诊记录列表
+ * @param page
+ * @param size
+ * @param query
+ */
+export function getPatientDiagnosisRecordsMethod(page: number, size: number, query: { patientId: string }) {
+  const start = (page - 1) * size;
+  return request.Post<{ total: number; data: DiagnosisReportVO[] }, { total: number; data: DiagnosisReportDTO[] }>(`/fdhb-pc/patientMedicalManage/pagePatientMedical`, query, {
+    params: { pageNum: page, pageSize: size, ...query },
+    transform({ data, total }) {
+      return { total, data: data.map((item, index) => Object.assign(fromDiagnosisReport(item), { id: start + index })) };
+    },
+  });
+}
+
+export function getPatientDiagnosisReportMethod(id: string | number, patientId?: string) {
+  if (typeof id === 'string') return request.Post<DiagnosisReportVO, any>('/');
+
+  const size = 100;
+  const page = ~~(id / size) + 1;
+  const index = id % size;
+
+  return request.Post<DiagnosisReportVO, { total: number; data: DiagnosisReportDTO[] }>(
+    `/fdhb-pc/patientMedicalManage/pagePatientMedical`,
+    { patientId },
+    {
+      params: { pageNum: page, pageSize: size, patientId },
+      transform({ data }) {
+        return fromDiagnosisReport(data[index]);
+      },
+    },
+  );
+}
+}

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

+ 130 - 0
src/widgets/HealthReportAnalysisWidget.vue

@@ -0,0 +1,130 @@
+<script setup lang="ts">
+import type { HealthReportAnalysisCategory } from '@/model/health-report-analysis.config';
+import type { HealthReportAnalysisItemVO, HealthReportAnalysisVO } from '@/model/health-report.model';
+import type { VxeGridProps } from 'vxe-table';
+import type { VxeModalProps } from 'vxe-pc-ui';
+import { h } from 'vue';
+import { FileImageOutlined } from '@ant-design/icons-vue';
+
+const props = defineProps<{
+  category: HealthReportAnalysisCategory;
+  analysis: HealthReportAnalysisVO;
+}>();
+type HealthReportAnalysisItemData = Pick<HealthReportAnalysisItemVO, 'category' | 'subcategory' | 'standardValue'> & HealthReportAnalysisItemVO['values'][number];
+const title = computed(() => ({ tongue: '舌象', face: '面象' })[props.category]);
+const analysis = computed(() => props.analysis?.[props.category]);
+
+const gridOptions = reactive<VxeGridProps<HealthReportAnalysisItemData>>({
+  headerAlign: 'center',
+  columns: [
+    { field: 'subcategory', title: '维度', align: 'center', width: 120 },
+    { field: 'standardValue', title: '标准值', align: 'center', width: 180 },
+    { field: 'resultValue', title: '检测结果', align: 'center', width: 180 },
+    {
+      field: '',
+      title: '图片分析',
+      width: 180,
+      minWidth: 78,
+      resizable: true,
+      showOverflow: 'ellipsis',
+      slots: { default: 'picture-cell' },
+    },
+    { field: 'feature', title: '特征', resizable: true },
+  ],
+  mergeCells: [],
+  data: [],
+  cellStyle({ row, column }) {
+    if (column.field === 'resultValue' && row.contrast)
+      return {
+        color: '#f50',
+      };
+  },
+});
+
+const transform = (data: HealthReportAnalysisItemVO[] = []) => {
+  gridOptions.data = [];
+  gridOptions.mergeCells = [];
+  for (const { category, subcategory, standardValue, values } of data) {
+    const length = values.length;
+    if (length > 1) {
+      const row = gridOptions.data?.length;
+      gridOptions.mergeCells?.push(
+        { row, col: 0, rowspan: length, colspan: 1 },
+        {
+          row,
+          col: 1,
+          rowspan: length,
+          colspan: 1,
+        }
+      );
+    }
+    for (const item of values) {
+      gridOptions.data?.push({ category, subcategory, standardValue, ...item });
+    }
+  }
+};
+watchEffect(() => {
+  gridOptions.columns![0].title = `${title.value}维度`;
+  transform(analysis.value?.analysis);
+});
+
+const show = ref(false);
+const modalOptions = reactive<VxeModalProps & { data?: HealthReportAnalysisItemData }>({
+  position: {},
+  mask: false,
+  width: 500,
+  resize: true,
+  minWidth: 300,
+});
+
+function open(event: MouseEvent, data: HealthReportAnalysisItemData) {
+  console.log(event, data);
+  const offset = 32;
+  modalOptions.title = data.resultValue;
+  modalOptions.position = {
+    top: event.pageY - offset,
+    left: event.pageX + offset,
+  };
+  modalOptions.data = data;
+  show.value = true;
+}
+</script>
+
+<template>
+  <div v-if="analysis?.result">
+    <div class="flex gap-6">
+      <a-space direction="vertical" :size="24">
+        <div v-for="(item, index) in analysis?.pictures" :key="index" class="size-126px">
+          <img class="w-full h-full object-scale-down" :src="item" alt="舌象" />
+        </div>
+      </a-space>
+      <vxe-grid class="flex-auto" v-bind="gridOptions">
+        <template #picture-cell="{ row }">
+          <a-button v-if="row.cover" :icon="h(FileImageOutlined)" @click="open($event, row)" />
+          <a-tag v-for="tag in row.tags" :key="tag" color="#f50" style="margin: 8px 0 0 8px">{{ tag }}</a-tag>
+        </template>
+      </vxe-grid>
+    </div>
+
+    <vxe-modal v-model="show" v-bind="modalOptions">
+      <a-descriptions :column="1" bordered size="small" :label-style="{ width: '120px', textAlign: 'center' }">
+        <a-descriptions-item>
+          <div class="flex flex-row">
+            <a-image v-if="modalOptions.data?.cover" :src="modalOptions.data.cover" :width="126" :preview="true" />
+            <a-space v-if="modalOptions.data?.tags?.length" direction="vertical">
+              <a-tag v-for="v in modalOptions.data?.tags" :key="v" color="#f50" style="margin-left: 8px; margin-right: 0">{{ v }} </a-tag>
+            </a-space>
+          </div>
+        </a-descriptions-item>
+        <a-descriptions-item label="特征" v-if="modalOptions.data?.feature">{{ modalOptions.data?.feature }} </a-descriptions-item>
+        <template v-if="modalOptions.data?.significance">
+          <a-descriptions-item v-if="category === 'tongue'" label="临床意义">{{ modalOptions.data?.significance }} </a-descriptions-item>
+          <a-descriptions-item v-if="category === 'face'" label="病理意义">{{ modalOptions.data?.significance }} </a-descriptions-item>
+        </template>
+        <a-descriptions-item label="机理分析" v-if="modalOptions.data?.analysis">{{ modalOptions.data?.analysis }} </a-descriptions-item>
+      </a-descriptions>
+    </vxe-modal>
+  </div>
+</template>
+
+<style scoped lang="scss"></style>

+ 76 - 0
src/widgets/PatientDiagnosisRecordsWidget.vue

@@ -0,0 +1,76 @@
+<script setup lang="ts">
+import { usePagination } from 'alova/client';
+import { getPatientDiagnosisRecordsMethod } from '@/request/api/report.api';
+import type { VxeGridInstance, VxeGridListeners, VxeGridProps } from 'vxe-table';
+import type { DiagnosisReportVO } from '@/model/diagnosis-report.model';
+import { VxeUI } from 'vxe-pc-ui';
+
+const props = defineProps<{ patient: { id: string } }>();
+
+const grid = ref<VxeGridInstance>();
+const { data, loading, page, isLastPage } = usePagination((page, size) => getPatientDiagnosisRecordsMethod(page, size, { patientId: props.patient.id }), {
+  initialData: { total: 0, data: [] },
+  initialPage: 1,
+  initialPageSize: 100,
+  append: true,
+  watchingStates: [() => props.patient.id],
+});
+const gridOptions = reactive<VxeGridProps<DiagnosisReportVO>>({
+  // @ts-ignore
+  loading,
+  height: 'auto',
+  headerAlign: 'center',
+  align: 'center',
+  columnConfig: {
+    resizable: true,
+  },
+  columns: [
+    { field: 'date', title: '就诊日期', width: '150px' },
+    { field: 'department.name', title: '就诊科室' },
+    { field: 'doctor.name', title: '就诊医生' },
+    { field: 'disease.name', title: '疾病' },
+    { field: 'symptom.name', title: '证型' },
+    { title: '操作', align: 'center', width: 120, slots: { default: 'cell-operation' } },
+  ],
+  // @ts-ignore
+  data,
+  scrollY: {
+    gt: 0,
+  },
+});
+const gridEvents: VxeGridListeners = {
+  scrollBoundary({ direction, isBottom }) {
+    if (isBottom && direction === 'bottom' && !isLastPage.value) page.value++;
+  },
+};
+
+function openPatientDiagnosisRecordPreview(row: DiagnosisReportVO, index?: number) {
+  const component = defineAsyncComponent(() => import('@/components/PatientMedicalHistoryPreview.vue'));
+  VxeUI.modal.open({
+    title: `门诊病历`,
+    escClosable: true,
+    destroyOnClose: true,
+    resize: true,
+    width: window.innerWidth * 0.6,
+    height: window.innerHeight * 0.8,
+    minWidth: Math.min(window.innerWidth * 0.98, 1200),
+    slots: {
+      default() {
+        return h(component, row.medicalHistory);
+      },
+    },
+  });
+}
+</script>
+
+<template>
+  <vxe-grid ref="grid" v-bind="gridOptions" v-on="gridEvents">
+    <template #cell-operation="{ row }">
+      <vxe-button-group mode="text">
+        <vxe-button content="查看" :disabled="!row.medicalHistory" status="warning" @click="openPatientDiagnosisRecordPreview(row)"></vxe-button>
+      </vxe-button-group>
+    </template>
+  </vxe-grid>
+</template>
+
+<style scoped lang="scss"></style>

+ 169 - 0
src/widgets/PatientHealthRecordsWidget.vue

@@ -0,0 +1,169 @@
+<script setup lang="ts">
+import { h } from 'vue';
+
+import { ArrowDownOutlined, ArrowUpOutlined, FileTextOutlined } from '@ant-design/icons-vue';
+
+import { usePagination } from 'alova/client';
+import { getPatientHealthRecordsMethod } from '@/request/api/report.api';
+import type { VxeGridInstance, VxeGridListeners, VxeGridProps } from 'vxe-table';
+import type { HealthReportSymptomItemVo } from '@/model/health-report.model';
+import { VxeUI } from 'vxe-pc-ui';
+
+const props = defineProps<{ patient: { id: string } }>();
+
+type SymptomItemVo = Record<
+  `symptom-${HealthReportSymptomItemVo['id']}`,
+  HealthReportSymptomItemVo & {
+    trend: -1 | 0 | 1;
+  }
+>;
+
+interface Model extends SymptomItemVo {
+  id: string;
+  duration?: string;
+  influence?: string;
+
+  tongueUpPictures?: string;
+  tongueDownPictures?: string;
+  facePictures?: string;
+}
+
+const grid = ref<VxeGridInstance>();
+const { loading, page, isLastPage } = usePagination((page, size) => getPatientHealthRecordsMethod(page, size, { patientId: props.patient.id }), {
+  initialData: { total: 0, data: [] },
+  initialPage: 0,
+  initialPageSize: 100,
+  append: true,
+  watchingStates: [() => props.patient.id],
+}).onSuccess(({ data: { data } }) => {
+  const rows = gridOptions.data as Model[];
+  const symptomColumns = gridOptions.columns?.find((column) => column.field === 'symptom')?.children ?? [];
+  let row = rows[rows.length - 1] || {};
+  for (const values of data) {
+    const { analysis, symptom, ...record } = values;
+    const items: SymptomItemVo = {};
+    for (const item of symptom.items) {
+      const field = `symptom-${item.id}` as keyof SymptomItemVo;
+      if (!symptomColumns.find((column) => column.field === field))
+        symptomColumns.push({
+          field,
+          title: item.name,
+          align: 'center',
+          resizable: true,
+          slots: { default: `symptom` },
+        });
+      const lastValue = row[field]?.value;
+      const value = item.value;
+      items[field] = {
+        ...item,
+        trend: lastValue ? (lastValue > value ? -1 : lastValue < value ? 1 : 0) : 0,
+      };
+    }
+    row = Object.assign(record, symptom, items, {
+      tongueUpPictures: analysis.tongue.pictures[0],
+      tongueDownPictures: analysis.tongue.pictures[1],
+      facePictures: analysis.face.pictures[0],
+    });
+    rows.push(row);
+    grid.value?.loadColumn(gridOptions.columns!);
+  }
+});
+const gridOptions = reactive<VxeGridProps<Model>>({
+  // @ts-ignore
+  loading,
+  height: 'auto',
+  headerAlign: 'center',
+  headerCellConfig: {
+    padding: false,
+    height: 28,
+  },
+  cellConfig: {
+    height: 126,
+  },
+  columns: [
+    { field: 'date', title: '日期', width: '150px', align: 'center', slots: { default: 'records' } },
+    {
+      title: '舌象',
+      children: [
+        { field: 'tongueUpPictures', title: '舌面', width: '126px', slots: { default: 'picture' } },
+        { field: 'tongueDownPictures', title: '舌底', width: '126px', slots: { default: 'picture' } },
+      ],
+    },
+    {
+      title: '面象',
+      children: [{ field: 'facePictures', title: '面部', width: '126px', slots: { default: 'picture' } }],
+    },
+    {
+      field: 'symptom',
+      title: '症状',
+      children: [],
+    },
+    { field: 'duration', title: '持续时间' },
+    { field: 'influence', title: '对生活的影响' },
+  ],
+  data: [],
+  scrollY: {
+    gt: 0,
+  },
+});
+const gridEvents: VxeGridListeners = {
+  scrollBoundary({ direction, isBottom }) {
+    if (isBottom && direction === 'bottom' && !isLastPage.value) page.value++;
+  },
+};
+
+function open(row: Model) {
+  const component = defineAsyncComponent(() => import('@/components/ReportPreview.vue'));
+  const id = `drawer:report:preview`;
+  const onDestroy = () => { VxeUI.drawer.close(id); };
+  onDestroy();
+  VxeUI.drawer.open({
+    id,
+    mask: true, lockView: false, padding: false,
+    width: window.innerWidth - 256,
+    escClosable: true, maskClosable: true,
+    title: `健康分析报告`,
+    slots: {
+      default() {
+        return h(component, {
+          reportId: row.id.toString(),
+          onDestroy,
+        });
+      },
+    },
+  });
+}
+</script>
+
+<template>
+  <vxe-grid ref="grid" v-bind="gridOptions" v-on="gridEvents">
+    <template #records="{ row, column }">
+      <div style="font-size: 16px">{{ row[column.field] }}</div>
+      <a-button class="mt-2" :icon="h(FileTextOutlined)" type="primary" size="small" :disabled="!row.id" @click="open(row)">健康分析报告</a-button>
+    </template>
+    <template #symptom="{ row, column }">
+      <div style="transform: translateX(12px)">
+        <span>{{ row[column.field]?.label }}</span>
+        <div class="inline-block ml-1 size-24px">
+          <a-button v-if="row[column.field]?.trend > 0" :icon="h(ArrowUpOutlined)" shape="circle" size="small" class="trend-up" />
+          <a-button v-else-if="row[column.field]?.trend < 0" :icon="h(ArrowDownOutlined)" shape="circle" size="small" class="trend-down" />
+        </div>
+      </div>
+    </template>
+    <template #picture="{ row, column }">
+      <a-image v-if="row[column.field]" class="w-full h-full" :src="row[column.field]" />
+    </template>
+  </vxe-grid>
+</template>
+
+<style scoped lang="scss">
+.trend-up {
+  color: #ff4d4f;
+  border-color: #ff4d4f;
+}
+
+.trend-down {
+  color: #87d068;
+  border-color: #87d068;
+}
+</style>

+ 110 - 0
src/widgets/PatientPhysicalSignRecordsWidget.vue

@@ -0,0 +1,110 @@
+<script setup lang="ts">
+import { ArrowDownOutlined, ArrowUpOutlined, CaretRightOutlined } from '@ant-design/icons-vue';
+import { h, ref } from 'vue';
+import { useRequest } from 'alova/client';
+import { getPatientHealthIndicatorsMethod } from '@/request/api/report.api';
+import type { HealthIndicatorItemVO, HealthIndicatorVO } from '@/model/health-report.model';
+import dayjs from 'dayjs';
+
+const props = defineProps<{ patient: { id: string } }>();
+
+const indicators = shallowRef<HealthIndicatorVO[]>([]);
+const activeKey = ref<string[]>([]);
+
+const { loading } = useRequest(() => getPatientHealthIndicatorsMethod(props.patient.id)).onSuccess(({ data }) => {
+  indicators.value = conversionGrouping(data);
+  activeKey.value = indicators.value.map((item) => item.name);
+});
+
+function conversionGrouping(data: HealthIndicatorVO[]): HealthIndicatorVO[] {
+  const gather = new Map<number, HealthIndicatorVO>();
+  const indicators = data.flatMap((indicator) => indicator.items);
+  for (const indicator of indicators) {
+    const key = dayjs(indicator.date).toDate().valueOf();
+    const value = gather.has(key)
+      ? gather.get(key)!
+      : (gather.set(key, {
+          id: indicator.date,
+          name: indicator.date,
+          items: [],
+        }),
+        gather.get(key)!);
+
+    value.items.push(indicator);
+  }
+  return Array.from(gather.keys())
+    .sort((a, b) => b - a)
+    .map((title) => ({ title, ...gather.get(title)! }));
+}
+</script>
+
+<template>
+  <a-spin :spinning="loading">
+    <a-collapse v-if="indicators.length" class="physical-sign-records-wrapper" v-model:activeKey="activeKey" :bordered="false">
+      <a-collapse-panel v-for="indicator in indicators" :key="indicator.name" :header="indicator.name">
+        <div class="flex flex-wrap">
+          <div class="text-center w-260px row" v-for="item in indicator.items" :key="item.name">
+            <div class="flex justify-center">
+              <span><label>{{ item.name }}</label>{{ item.value }}{{ item.unit }}</span>
+              <div class="inline-block ml-2 size-24px">
+                <a-button v-if="item.trend > 0" :icon="h(ArrowUpOutlined)" shape="circle" size="small" class="trend-up" />
+                <a-button v-else-if="item.trend < 0" :icon="h(ArrowDownOutlined)" shape="circle" size="small" class="trend-down" />
+              </div>
+            </div>
+          </div>
+        </div>
+      </a-collapse-panel>
+    </a-collapse>
+    <a-empty v-else></a-empty>
+  </a-spin>
+</template>
+
+<style scoped lang="scss">
+.physical-sign-records-wrapper {
+  background-color: transparent;
+
+  :deep(.ant-collapse-item) {
+    margin-bottom: 12px;
+    border-bottom: none;
+  }
+
+  .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;
+    }
+  }
+}
+
+.trend-up {
+  color: #ff4d4f;
+  border-color: #ff4d4f;
+}
+
+.trend-down {
+  color: #87d068;
+  border-color: #87d068;
+}
+</style>