Przeglądaj źródła

患者健康档案

cc12458 1 rok temu
rodzic
commit
0573f4c310

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

+ 176 - 2
src/components/PatientHealthRecordPreview.vue

@@ -1,10 +1,16 @@
 <script setup lang="ts">
+import { h, ref } from 'vue';
+
 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';
+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>;
@@ -16,7 +22,8 @@ const emits = defineEmits<{
   destroy: [];
 }>();
 
-const { data: patient, loading: loadPatientPending } = useWatcher(() => patientMethod(props.patient?.id!), [() => props.patient.id], {
+// 患者基本信息
+const { data: patient } = useWatcher(() => patientMethod(props.patient?.id!), [() => props.patient.id], {
   initialData: { ...props.patient },
   immediate: true,
   middleware: (_, next) => {
@@ -32,6 +39,55 @@ const { data: patientTags, loading: loadPatientTagsPending } = useWatcher(() =>
   },
 });
 
+// 患者最后一次就诊记录
+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;
@@ -72,6 +128,49 @@ function openPatientTagEdit(event: MouseEvent) {
 <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">更新记录</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>
@@ -85,6 +184,81 @@ function openPatientTagEdit(event: MouseEvent) {
         </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>
+          </header>
+          <main>
+            <div class="flex flex-wrap">
+              <div class="text-center w-260px row" v-for="item in indicator" :key="item.id">
+                <span
+                  ><label>{{ item.name }}</label
+                  >{{ item.value }}{{ item.unit }}</span
+                >
+                <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>
 

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

+ 9 - 10
src/components/PatientTagEdit.vue

@@ -48,18 +48,11 @@ onMounted(() => {
   if (!patientTags.value.length) loadPatientTags();
 });
 
-const { data: selectable, loading: tagsLoading } = useRequest(searchTagsFromSelectableMethod, {
+const { data: searchTags, 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,
@@ -74,7 +67,13 @@ const formProps = reactive<VxeFormProps<FormModel>>({
         name: 'VxeSelect',
         props: {
           loading: tagsLoading,
-          optionGroups: options,
+          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,
@@ -102,7 +101,7 @@ const formEmits: VxeFormListeners<FormModel> = {
 };
 
 function appendTag(id: PatientTagVO['id']) {
-  const tag = selectable.value.find((tag) => tag.id === id);
+  const tag = searchTags.value.find((tag) => tag.id === id);
   if (tag) form.value.tags.push(tag);
   form.value.selected = [];
 }

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

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

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

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

@@ -0,0 +1,67 @@
+export const HealthReportAnalysisItemConfig = [
+  { category: 'tongue', key: 'tongueColor', subcategory: '舌色' },
+  { category: 'tongue', key: 'tongueCoatingColor', subcategory: '苔色' },
+  { category: 'tongue', key: 'tongueShape', subcategory: '舌形' },
+  { category: 'tongue', key: 'tongueCoating', subcategory: '苔质' },
+  { category: 'tongue', key: 'bodyFluid', subcategory: '津液' },
+  { category: 'tongue', key: 'sublingualVein', subcategory: '舌下' },
+
+  { category: 'face', key: 'faceColor', subcategory: '面色' },
+  { category: 'face', key: 'mainColor', subcategory: '主色' },
+  { category: 'face', key: 'shine', subcategory: '光泽' },
+  { category: 'face', key: 'leftBlackEye', subcategory: '左黑眼圈' },
+  { category: 'face', key: 'rightBlackEye', subcategory: '右黑眼圈' },
+  { category: 'face', key: 'lipColor', subcategory: '唇色' },
+  { category: 'face', key: 'eyeContact', subcategory: '眼神' },
+  { category: 'face', key: 'leftEyeColor', subcategory: '左目色' },
+  { category: 'face', key: 'rightEyeColor', subcategory: '右目色' },
+  { category: 'face', key: 'hecticCheek', subcategory: '两颧红' },
+  { category: 'face', key: 'noseFold', subcategory: '鼻褶' },
+  { category: 'face', key: 'cyanGlabella', subcategory: '眉间/鼻柱青色' },
+  { category: 'face', key: 'faceSkinDefects', subcategory: '面部皮损' },
+] as const;
+
+type HealthReportAnalysisItem = (typeof HealthReportAnalysisItemConfig)[number];
+
+export type HealthReportAnalysisKey = HealthReportAnalysisItem['key'];
+export type HealthReportAnalysisCategory = HealthReportAnalysisItem['category'];
+export type HealthReportAnalysisSubcategory = HealthReportAnalysisItem['subcategory'];
+
+export const analysisPictureStrategy: Record<
+  HealthReportAnalysisCategory,
+  (data: any) => string[]
+> = {
+  face(data: any): string[] {
+    const p1 = data.faceImg || data.faceImgUrl;
+    const p2 = data.faceLeft || data.faceLeftImgUrl;
+    const p3 = data.faceRight || data.faceRightImgUrl;
+    return Object.assign([p1, p2, p3].filter(Boolean), {
+      ['正面']: p1,
+      ['左面']: p2,
+      ['右面']: p3,
+    });
+  },
+  tongue(data: any): string[] {
+    const p1 = data.upImg || data.tongueImgUrl;
+    const p2 = data.downImg || data.tongueBackImgUrl;
+    return Object.assign([p1, p2].filter(Boolean), {
+      ['舌上']: p1,
+      ['舌下']: p2,
+    });
+  },
+};
+
+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;

+ 270 - 0
src/model/health-report.model.ts

@@ -0,0 +1,270 @@
+import {
+  analysisPictureStrategy,
+  type HealthReportAnalysisCategory,
+  HealthReportAnalysisItemConfig,
+  type HealthReportAnalysisKey,
+  type HealthReportAnalysisSubcategory,
+  HealthReportSymptomItemConfig,
+} from '@/model/health-report-analysis.config';
+
+export interface HealthReportAnalysisItemDTO {
+  standardValue: string;
+  actualList: {
+    splitImage: string;
+    actualValue: string;
+    contrast: string;
+    attrs: string[];
+    features: string;
+    clinicalSignificance?: string;
+    mechanismAnalyze?: string;
+  }[];
+}
+
+export interface HealthReportDTO extends Record<HealthReportAnalysisKey, HealthReportAnalysisItemDTO>, Record<`${HealthReportAnalysisCategory}AnalysisResult`, string> {
+  tonguefaceAnalysisReportId: string;
+
+  healthAnalysisReportId: string;
+  reportTime: string;
+
+  pickedSymptomList?: { id: string; name: string; value: string; score: number }[];
+  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 {
+  category: HealthReportAnalysisCategory;
+  subcategory: HealthReportAnalysisSubcategory;
+  standardValue: string;
+  resultValue: string;
+  values: {
+    cover?: string;
+    contrast: string;
+    actualValue: string;
+    resultValue: string;
+    tags: string[];
+    /**
+     * 特征
+     */
+    feature: string;
+    /**
+     * 意义
+     * @description
+     *  - tongue  临床意义
+     *  - face    病理意义
+     */
+    significance: string;
+    /**
+     * 机理分析
+     */
+    analysis?: string;
+  }[];
+}
+
+export interface HealthReportAnalysisVO
+  extends Record<
+    HealthReportAnalysisCategory,
+    {
+      result: string;
+      analysis: HealthReportAnalysisItemVO[];
+      pictures: string[];
+    }
+  > {
+  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) {
+    const { analysis } =
+      result[category] ??
+      (result[category] = {
+        analysis: [],
+        result: data[`${category}AnalysisResult`] ?? '',
+        pictures: analysisPictureStrategy[category]?.(data) ?? [],
+      });
+    if (!data[key]) continue;
+    const { standardValue, actualList } = data[key]!;
+    const values: HealthReportAnalysisItemVO['values'] = [];
+    for (const { actualValue, splitImage: cover, ...item } of actualList) {
+      const contrast = item.contrast === 's' ? '' : (item.contrast ?? '');
+      const [_, features = _, significance = item.clinicalSignificance] = item.features?.match?.(
+        new RegExp(`【(?:${actualValue}|正常${subcategory}|正常面色)】([^<]*)<?[\\s\\S]*?【病理意义】([^<]*)`)
+      ) ?? item.features?.match?.(
+        new RegExp(`([^<]*)<?[\\s\\S]*?【病理意义】([^<]*)`)
+      ) ??  [item.features];
+      values.push({
+        cover: contrast ? cover : void 0,
+        actualValue,
+        resultValue: contrast ? `${actualValue} (${contrast})` : actualValue,
+        contrast,
+        feature: features ?? '',
+        significance: significance ?? '',
+        tags: item.attrs ?? [],
+        analysis: item.mechanismAnalyze ?? '',
+      });
+    }
+    analysis.push({
+      category,
+      subcategory,
+      standardValue,
+      resultValue: values.map((item) => item.resultValue).join('、'),
+      values,
+    });
+  }
+  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,
+  };
+}

+ 2 - 0
src/model/patient.model.ts

@@ -8,6 +8,8 @@ export interface PatientQuery {
   cardno?: string;
   isHaveHealthAnalysisReport?: boolean;
   tags?: string[];
+
+  count?: number;
 }
 
 export interface PatientModel extends PeopleModel {

+ 42 - 11
src/pages/index/patient/history.vue

@@ -10,6 +10,7 @@ import { VxeButton, type VxeFormListeners, type VxeFormProps, VxeUI } from 'vxe-
 import type { VxeGridInstance, VxeGridListeners, VxeGridProps } from 'vxe-table';
 
 import ReportAnalysisCountEdit from '@/components/ReportAnalysisCountEdit.vue';
+import PatientEdit from '@/components/PatientEdit.vue';
 import PatientTagEdit from '@/components/PatientTagEdit.vue';
 import PatientHealthRecordPreview from '@/components/PatientHealthRecordPreview.vue';
 
@@ -22,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 },
@@ -61,7 +65,8 @@ const searchFormProps = reactive<VxeFormProps<PatientQuery>>({
       },
     },
     {
-      span: 8, itemRender: {
+      span: 6,
+      itemRender: {
         name: 'VxeButtonGroup',
         options: [
           { type: 'submit', content: '查询', status: 'primary' },
@@ -79,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',
     },
   },
@@ -100,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: '操作',
@@ -201,7 +209,7 @@ 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)
+    if (tags) refresh(page.value);
     VxeUI.modal.close(id);
   };
   onDestroy();
@@ -218,7 +226,27 @@ function openPatientTagsEditHandle({ report, ...patient }: PatientReportModel) {
         });
       },
     },
-  })
+  });
+}
+
+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>
@@ -228,6 +256,9 @@ function openPatientTagsEditHandle({ report, ...patient }: PatientReportModel) {
     </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>

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

@@ -23,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: {},

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

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

+ 146 - 0
src/widgets/PatientHealthRecordsWidget.vue

@@ -0,0 +1,146 @@
+<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';
+
+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: 10,
+  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++;
+  },
+};
+</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">健康分析报告</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>

+ 94 - 0
src/widgets/PatientPhysicalSignRecordsWidget.vue

@@ -0,0 +1,94 @@
+<script setup lang="ts">
+import { CaretRightOutlined } from '@ant-design/icons-vue';
+import { 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">
+            <span><label>{{ item.name }}</label>{{item.value}}{{item.unit}}</span>
+          </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;
+    }
+  }
+}
+</style>