Explorar o código

患者管理 页面

cc12458 hai 1 ano
pai
achega
87e74ba180

+ 0 - 1
.env/.env.development

@@ -1 +0,0 @@
-REQUEST_API_PROXY_URL=http://121.43.162.141:8001

+ 0 - 1
.env/.env.production

@@ -1 +0,0 @@
-REQUEST_API_PROXY_URL=http://121.43.162.141:8001

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

@@ -19,13 +19,18 @@ declare module 'vue' {
     AForm: typeof import('ant-design-vue/es')['Form']
     AFormItem: typeof import('ant-design-vue/es')['FormItem']
     AImage: typeof import('ant-design-vue/es')['Image']
+    AImagePreviewGroup: typeof import('ant-design-vue/es')['ImagePreviewGroup']
     AInput: typeof import('ant-design-vue/es')['Input']
     AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
     AInputSearch: typeof import('ant-design-vue/es')['InputSearch']
+    AnalysisReportPreview: typeof import('./../src/components/AnalysisReportPreview.vue')['default']
+    ASelect: typeof import('ant-design-vue/es')['Select']
+    ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
     ASpace: typeof import('ant-design-vue/es')['Space']
     ASpin: typeof import('ant-design-vue/es')['Spin']
     ATag: typeof import('ant-design-vue/es')['Tag']
     ATooltip: typeof import('ant-design-vue/es')['Tooltip']
+    PatientReportPreview: typeof import('./../src/components/PatientReportPreview.vue')['default']
     RoleEdit: typeof import('./../src/components/RoleEdit.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']

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

@@ -19,6 +19,7 @@ declare module 'vue-router/auto-routes' {
    */
   export interface RouteNamedMap {
     '/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
+    '//patient/history': RouteRecordInfo<'//patient/history', '/patient/history', Record<never, never>, Record<never, never>>,
     '//system/role': RouteRecordInfo<'//system/role', '/system/role', Record<never, never>, Record<never, never>>,
     '//system/user': RouteRecordInfo<'//system/user', '/system/user', Record<never, never>, Record<never, never>>,
     '/login': RouteRecordInfo<'/login', '/login', Record<never, never>, Record<never, never>>,

+ 69 - 0
src/components/AnalysisReportPreview.vue

@@ -0,0 +1,69 @@
+<script setup lang="ts">
+import type { PatientModel, PatientReportModel } from '@/model';
+import { reportsMethod }                         from '@/request/api/report.api';
+
+import AnalysisReportWidget from '@/widgets/AnalysisReportWidget.vue';
+import AnalysisSchemeWidget from '@/widgets/AnalysisSchemeWidget.vue';
+
+import { useRequest } from 'alova/client';
+
+
+const props = defineProps<{ show: 'report' | 'scheme', report: PatientReportModel['report'], patient: PatientModel }>();
+
+
+const reportId = ref<string>();
+provide('report-id', reportId);
+
+const { data: list, loading: listLoading } = useRequest(
+  () => reportsMethod(props.patient.id),
+  { initialData: [] },
+).onSuccess(({ data }) => {
+  const report = data.find(item => item.id === props.report?.id);
+  reportId.value = report.id ?? data[ 0 ]?.id;
+  toggle(props.show, false);
+});
+
+const loading = ref(false);
+const showComponentProps = shallowRef<{ title: string; next: string; nextTitle: string }>();
+
+function toggle(show: 'report' | 'scheme', showLoading = true) {
+  loading.value = showLoading;
+  const next = { report: 'scheme', scheme: 'report' }[ show ];
+  const ref = {
+    'report': '健康分析报告',
+    'scheme': '调理方案',
+  };
+  const component = {
+    'report': AnalysisReportWidget,
+    'scheme': AnalysisSchemeWidget,
+  };
+  showComponentProps.value = {
+    key: show,
+    title: ref[ show ],
+    next, nextTitle: ref[ next ],
+    component: component[ show ],
+  };
+}
+</script>
+<template>
+  <div class="page-container flex flex-col">
+    <Teleport to=".analysis-record-title">
+      <span>{{ showComponentProps?.title }}</span>
+      <a-select style="width: 120px;margin-left: 8px;" v-model:value="reportId">
+        <a-select-option v-for="option in list" :value="option.id">{{ option.time }}</a-select-option>
+      </a-select>
+    </Teleport>
+    <Teleport to=".analysis-record-corner">
+      <a-button type="primary" @click="toggle(showComponentProps.next)">
+        {{ showComponentProps?.nextTitle }}
+      </a-button>
+    </Teleport>
+    <a-spin :spinning="listLoading || loading">
+      <component :is="showComponentProps?.component" :key="showComponentProps?.key"
+                 :report="props.report" @loaded="loading = false"
+      />
+    </a-spin>
+  </div>
+</template>
+<style scoped lang="scss">
+</style>

+ 255 - 0
src/components/PatientReportPreview.vue

@@ -0,0 +1,255 @@
+<script setup lang="ts">
+import AnalysisReportPreview                                  from '@/components/AnalysisReportPreview.vue';
+import { useDictionaries }                                    from '@/libs/dictionary';
+import type { PatientModel, PatientReportModel }              from '@/model';
+import { patientMethod }                                      from '@/request/api/patient.api';
+import { patientIndicatorMethod, reportMethod, schemeMethod } from '@/request/api/report.api';
+import { ArrowDownOutlined, ArrowUpOutlined }                 from '@ant-design/icons-vue';
+import { useRequest, useWatcher }                             from 'alova/client';
+import { VxeUI }                                              from 'vxe-pc-ui';
+
+
+const props = defineProps<{ report?: PatientReportModel['report'], patient: PatientModel }>();
+const { data: patient, loading: petientLoading } = useRequest(
+  () => patientMethod(props.patient.id),
+  { initialData: props.patient },
+);
+const { data: indicator, loading: indicatorLoading } = useRequest(
+  () => patientIndicatorMethod(props.patient.id),
+  { initialData: [] },
+);
+const reportId = ref<string>();
+const { data: report, loading: reportLoading } = useWatcher(
+  () => reportMethod(reportId.value!),
+  [ reportId ],
+  { initialData: props.report },
+);
+const { data: scheme, loading: schemeLoading } = useWatcher(
+  () => schemeMethod(reportId.value!),
+  [ reportId ],
+  { initialData: props.report },
+);
+
+onBeforeMount(() => {
+  if ( props.report?.id ) reportId.value = props.report.id;
+});
+
+const gender = useDictionaries('sys_user_sex', computed(() => patient.value.gender));
+const job = useDictionaries('job', computed(() => patient.value.job));
+const drinkState = useDictionaries('sys_yes_no', computed(() => patient.value.drinkState));
+const smokeState = useDictionaries('sys_yes_no', computed(() => patient.value.smokeState));
+const hobbyFlavor = useDictionaries('hobby_flavor', computed(() => patient.value.hobbyFlavor));
+const foodAllergy = useDictionaries('food_allergy', computed(() => patient.value.foodAllergy));
+const womenSpecialPeriod = useDictionaries('women_special_period', computed(() => patient.value.womenSpecialPeriod));
+
+function previewAnalysisRecord(show: 'report' | 'scheme') {
+  const titleRef = { report: `健康分析报告`, scheme: `调理方案` };
+  VxeUI.drawer.open({
+    title: titleRef[ show ],
+    maskClosable: true,
+    escClosable: true,
+    width: window.innerWidth * 0.85,
+    slots: {
+      default() {
+        return h(AnalysisReportPreview, <any> {
+          show,
+          report: report.value,
+          patient: patient.value,
+        });
+      },
+      title() {
+        return h('div', { class: 'analysis-record-title' });
+      },
+      corner() {
+        return h('div', { class: 'analysis-record-corner' });
+      },
+    },
+  });
+}
+</script>
+<template>
+  <div class="page-container flex flex-col">
+    <div class="card patient-card">
+      <div class="card__title">
+        <span>基础信息</span>
+        <a-button type="primary" size="small">更新记录</a-button>
+      </div>
+      <div class="card__content">
+        <div class="row">
+          <span>{{ patient.name }}</span>
+          <span>{{ gender.join() }}</span>
+          <span>{{ patient.age }}岁</span>
+          <span v-if="womenSpecialPeriod?.length">饮酒:{{ womenSpecialPeriod.join() }}</span>
+          <template v-for="d in job">
+            <span v-if="d">{{ d }}</span>
+          </template>
+          <span v-if="patient.phone">手机号:{{ patient.phone }}</span>
+          <span>身份证号:{{ patient.cardno }}</span>
+        </div>
+        <div class="row">
+          <span>{{ patient.height }}cm</span>
+          <span>{{ patient.weight }}kg</span>
+          <span v-if="drinkState?.length">饮酒:{{ drinkState.join() }}</span>
+          <span v-if="smokeState?.length ">抽烟:{{ smokeState.join() }}</span>
+          <span v-if="hobbyFlavor?.length">喜好口味:<a-tag v-for="d in hobbyFlavor" :key="d">{{ d }}</a-tag></span>
+          <span v-if="foodAllergy?.length">食物过敏:<a-tag v-for="d in foodAllergy" :key="d">{{ d }}</a-tag></span>
+        </div>
+      </div>
+    </div>
+    <div class="card report-card">
+      <div class="card__title">
+        <span>健康状况</span>
+        <a-button type="primary" size="small">更新记录</a-button>
+      </div>
+      <div class="card__content">
+        <a-descriptions :column="3">
+          <a-descriptions-item v-if="report.pickedSymptom" label="症状信息" :span="3">{{ report.pickedSymptom }}
+          </a-descriptions-item>
+          <a-descriptions-item>
+            <a-image :width="200" :src="report.upImg" :preview="false" />
+          </a-descriptions-item>
+          <a-descriptions-item>
+            <a-image :width="200" :src="report.downImg" :preview="false" />
+          </a-descriptions-item>
+          <a-descriptions-item>
+            <a-image :width="200" :src="report.faceImg" :preview="false" />
+          </a-descriptions-item>
+        </a-descriptions>
+      </div>
+    </div>
+    <div class="card report-card">
+      <div class="card__title">
+        <span>健康分析报告</span>
+        <a-button type="primary" size="small" :loading="reportLoading" @click="previewAnalysisRecord('report')">
+          报告详情
+        </a-button>
+      </div>
+      <div class="card__content">
+        <a-descriptions :column="3">
+          <a-descriptions-item v-if="report.time" label="报告日期" :span="3">{{ report.time }}
+          </a-descriptions-item>
+          <a-descriptions-item v-if="report.willillStateName" label="健康状态">{{ report.willillStateName }}
+          </a-descriptions-item>
+          <a-descriptions-item v-if="report.willillDegreeName" label="程度" :span="2">{{ report.willillDegreeName }}
+          </a-descriptions-item>
+          <a-descriptions-item v-if="report.willillFunctionName" label="表现">{{ report.willillFunctionName }}
+          </a-descriptions-item>
+          <a-descriptions-item v-if="report.constitutionGroupName" label="体质" :span="2">
+            {{ report.constitutionGroupName }}
+          </a-descriptions-item>
+        </a-descriptions>
+      </div>
+    </div>
+    <div class="card report-card">
+      <div class="card__title">
+        <span>调理方案</span>
+        <a-button type="primary" size="small" :loading="schemeLoading" @click="previewAnalysisRecord('scheme')">
+          方案详情
+        </a-button>
+      </div>
+      <div class="card__content">
+        <a-descriptions :column="1">
+          <a-descriptions-item v-if="scheme.process" label="调理进程">{{ scheme.process }}</a-descriptions-item>
+          <a-descriptions-item v-if="scheme.types?.length" label="方案内容">
+            <div style="margin-top: -8px">
+              <a-tag style="margin-top: 8px;" v-for="item in scheme.types" :key="item.type">{{ item.type }}</a-tag>
+            </div>
+          </a-descriptions-item>
+        </a-descriptions>
+        <!--{{  scheme }}-->
+      </div>
+    </div>
+    <div class="card report-card">
+      <div class="card__title">
+        <span>指标信息</span>
+        <a-button type="primary" size="small">更新记录</a-button>
+      </div>
+      <div class="card__content">
+        <a-descriptions :column="3">
+          <a-descriptions-item v-for="item in indicator" :label="item?.name" :key="item?.quotaId">
+            {{ item.quotaVal }}
+            {{ item.unit }}
+            <ArrowUpOutlined class="icon" v-if="item.abnormal === 1" />
+            <ArrowDownOutlined class="icon" v-else-if="item.abnormal === -1" />
+          </a-descriptions-item>
+        </a-descriptions>
+      </div>
+    </div>
+  </div>
+</template>
+<style scoped lang="scss">
+.card {
+  color: #333;
+
+  &__title {
+    font-size: 16px;
+    font-weight: 700;
+
+    .ant-btn {
+      margin-left: 8px;
+    }
+
+    &::before {
+      content: "";
+      display: inline-block;
+      margin-right: 8px;
+      height: 1em;
+      width: 2px;
+      background: linear-gradient(0deg, #4c6fd7 0%, #435cc8 100%);
+      transform: translateY(2px);
+    }
+  }
+
+  &__content {
+    padding: 16px 24px 0;
+    font-size: 14px;
+
+    .row {
+      padding-bottom: 16px;
+
+      &:first-of-type {
+        margin-top: 0;
+      }
+
+      &:last-of-type {
+        margin-bottom: 0;
+      }
+    }
+
+    :deep(.ant-descriptions-item-container) {
+      .ant-descriptions-item-label {
+        flex: none;
+      }
+
+      .ant-descriptions-item-content {
+        flex-wrap: wrap;
+      }
+    }
+  }
+}
+
+.patient-card {
+  .card__content {
+    > div {
+      &:nth-of-type(1) {
+        > span::after {
+          content: ",";
+        }
+      }
+
+      &:nth-of-type(2) {
+        > span::after {
+          content: ";";
+        }
+      }
+    }
+  }
+}
+
+.icon {
+  margin-left: 4px;
+  font-size: 18px;
+  color: #b22222ff;
+  transform: translateY(2px);
+}
+</style>

+ 45 - 0
src/libs/dictionary.ts

@@ -0,0 +1,45 @@
+import { dictionaryMethod }   from '@/request/api/dictionary.api';
+import { tryOnBeforeUnmount } from '@vueuse/core';
+
+import type { App, ComputedRef, MaybeRefOrGetter } from 'vue';
+
+
+type Dictionaries = Awaited<ReturnType<typeof dictionaryMethod>>;
+const $dictionaries = dictionaryMethod();
+
+function get(dictionaries: Dictionaries, key: keyof Dictionaries, value?: string): string[] {
+  const options = dictionaries[ key ];
+  value ??= '';
+  return value.split(',')
+              .map(value => {
+                const [ key, label ] = value.trim().split(':');
+                return label ?? options.find(item => item.dictValue === key.trim())?.dictLabel;
+              })
+              .filter(item => !!item && item !== '无');
+}
+
+export default {
+  install: (app: App) => {
+    $dictionaries.then(dictionaries => {
+      app.config.globalProperties.$d = get.bind(dictionaries);
+      app.provide('dictionaries', dictionaries);
+      console.log('[log]:dictionaries-->', dictionaries);
+    });
+
+  },
+};
+
+
+export function useDictionaries(key: keyof Dictionaries, value: MaybeRefOrGetter | ComputedRef) {
+  console.log(value, '1234-->', toRef(value));
+  const values = shallowRef<string[]>([]);
+  const stop = watchEffect(() => {
+    const v = unref(value)
+    $dictionaries.then(_ => {
+      values.value = get(_, key, v);
+    });
+  });
+  tryOnBeforeUnmount(() => stop());
+  return values;
+}
+

+ 5 - 3
src/main.ts

@@ -1,9 +1,10 @@
 import 'virtual:uno.css';
 import '@/themes/index.scss';
 
-import vxe    from '@/libs/vxe';
-import router from '@/router';
-import pinia  from '@/stores';
+import dictionary from '@/libs/dictionary';
+import vxe        from '@/libs/vxe';
+import router     from '@/router';
+import pinia      from '@/stores';
 
 import { createApp } from 'vue';
 
@@ -12,6 +13,7 @@ import App from './App.vue';
 
 const app = createApp(App);
 
+app.use(dictionary);
 app.use(vxe);
 app.use(pinia);
 app.use(router);

+ 2 - 0
src/model/index.ts

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

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

@@ -0,0 +1,31 @@
+import type { PeopleModel } from '@/model/people.model';
+import type { ReportModel } from '@/model/report.model';
+
+
+export interface PatientQuery {
+  patientName?: string;
+  phone?: string;
+  cardno?: string;
+  isHaveHealthAnalysisReport?: boolean;
+  tags?: string[];
+}
+
+export interface PatientModel extends PeopleModel {
+  tags?: string[];
+  [ key: string ]: any;
+}
+
+export interface PatientReportModel extends PatientModel {
+  report: ReportModel;
+}
+
+export function transformPatient(data: any): PatientModel {
+  return {
+    ...data,
+    id: data?.patientId ?? '',
+    name: data?.patientName ?? data?.name ?? '',
+    gender: data?.sex,
+    age: data?.age,
+    tags: data?.tags ?? [],
+  };
+}

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

@@ -2,6 +2,8 @@ export interface PeopleModel {
   id: string;
   name: string;
   avatar?: string;
+  gender?: '0' | '1';
+  age?: number;
 }
 
 export function transformAccount(data: any): PeopleModel {

+ 19 - 0
src/model/report.model.ts

@@ -0,0 +1,19 @@
+export interface ReportModel {
+  id: string;
+  time?: string;
+  [ key: string ]: any;
+};
+
+
+export function transformIndicator(data: any[]) {
+  return data?.map((item: any) => {
+    const { patientQuotaRecordDTOS = [], ..._item } = item;
+    const lest = patientQuotaRecordDTOS?.[ patientQuotaRecordDTOS?.length - 1 ] ?? {};
+    const abnormal = lest.abnormal ? 1 : 0;
+    return {
+      ..._item,
+      ...lest,
+      abnormal: abnormal && lest.abnormalDesc?.includes('偏高') ? 1 : lest.abnormalDesc?.includes('偏低') ? -1 : 0,
+    };
+  }).filter(item => item.quotaVal != null) ?? [];
+}

+ 182 - 0
src/pages/index/patient/history.vue

@@ -0,0 +1,182 @@
+<script setup lang="ts">
+import PatientReportPreview                      from '@/components/PatientReportPreview.vue';
+import type { PatientQuery, PatientReportModel } from '@/model';
+import { patientsHistoryMethod }                 from '@/request/api/patient.api';
+import { usePagination }                         from 'alova/client';
+
+import {
+  VxeButton,
+  type VxeFormListeners,
+  type VxeFormProps,
+  type VxeGridInstance,
+  type VxeGridListeners,
+  type VxeGridProps,
+  VxeUI,
+} from 'vxe-pc-ui';
+
+
+const model = shallowRef<PatientQuery>();
+const searchFormProps = reactive<VxeFormProps<PatientQuery>>({
+  titleWidth: 120,
+  titleAlign: 'right',
+  titleColon: true,
+  data: {},
+  items: [
+    { 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: 6, itemRender: {
+        name: 'VxeRadioGroup',
+        options: [
+          { label: '有', value: true },
+          { label: '无', value: false },
+        ],
+        props: {
+          strict: false,
+        },
+      },
+    },
+    {
+      field: 'tags', title: '标签', span: 6, itemRender: {
+        name: 'VxeTreeSelect',
+        props: {
+          loading: computed(() => true),
+          options: computed(() => []),
+          optionProps: { value: 'id' },
+        },
+      },
+    },
+    {
+      span: 6, itemRender: {
+        name: 'VxeButtonGroup',
+        options: [
+          { type: 'submit', content: '查询', status: 'primary' },
+          { type: 'reset', content: '清空' },
+        ],
+      },
+    },
+  ],
+});
+const searchFormEmits: VxeFormListeners<PatientQuery> = {
+  submit({ data }) { model.value = { ...data }; },
+  reset({ data }) { model.value = { ...data }; },
+};
+
+const gridRef = ref<VxeGridInstance<PatientReportModel>>();
+const gridOptions = reactive<VxeGridProps<PatientReportModel>>({
+  id: 'patient-history-list',
+  border: true,
+  showOverflow: true,
+  height: 'auto',
+  scrollY: { enabled: true, gt: 0 },
+  toolbarConfig: {
+    custom: true,
+    zoom: true,
+    slots: {
+      tools: 'toolbar-extra',
+    },
+  },
+  columnConfig: {
+    resizable: true,
+  },
+  customConfig: {
+    storage: true,
+  },
+  columns: [
+    { type: 'seq', width: 70, fixed: 'left' },
+    { field: 'name', title: '姓名', minWidth: 80 },
+    { field: 'gender', title: '性别', minWidth: 40 },
+    { field: 'age', title: '年龄', minWidth: 40 },
+    { field: 'tags', title: '标签', minWidth: 160 },
+    { field: 'report.time', title: '最近一次健康分析时间', minWidth: 160 },
+    { field: 'createTime', title: '创建时间', minWidth: 160 },
+    {
+      title: '操作',
+      width: 200,
+      cellRender: {
+        name: 'VxeButtonGroup',
+        props: {
+          mode: 'text',
+        },
+        options: [
+          { content: '健康档案', name: 'previewPatientRecord' },
+        ],
+        events: {
+          click({ row, rowIndex }, { name }) {
+            let method;
+            if ( name === 'previewPatientRecord' ) { method = previewPatientRecord; }
+            method?.(row, rowIndex);
+          },
+        },
+      },
+    },
+  ],
+  data: [],
+});
+const gridEvents: VxeGridListeners = {};
+
+const { loading, page, pageSize, total, onSuccess, replace, refresh, remove } = usePagination(
+  (page, size) => patientsHistoryMethod(page, size, model.value), {
+    initialData: { data: [], total: 0 },
+    initialPage: 1,
+    initialPageSize: 100,
+    watchingStates: [ model ],
+    immediate: false,
+  },
+);
+onSuccess(({ data: { data } }) => {
+  gridRef.value?.loadData(data);
+});
+
+onMounted(() => {
+  model.value = toRaw(searchFormProps.data);
+});
+
+
+function previewPatientRecord(model: PatientReportModel, index?: number) {
+  const { report, ...patient } = model;
+  VxeUI.drawer.open({
+    title: `健康档案`,
+    maskClosable: true,
+    escClosable: true,
+    width: window.innerWidth * 0.85,
+    slots: {
+      default() {
+        return h(PatientReportPreview, <any> { report, patient });
+      },
+      // corner() {
+      //
+      // },
+    },
+  });
+}
+</script>
+<template>
+  <div class="page-container flex flex-col">
+    <header class="flex-none">
+      <vxe-form v-bind="searchFormProps" v-on="searchFormEmits"></vxe-form>
+    </header>
+    <main class="flex-auto overflow-hidden">
+      <vxe-grid ref="gridRef" v-bind="gridOptions" v-on="gridEvents" :loading="loading">
+        <template #toolbar-extra>
+          <vxe-button style="margin-right: 12px;" icon="vxe-icon-repeat" circle @click="refresh(page)"></vxe-button>
+        </template>
+      </vxe-grid>
+    </main>
+    <footer class="flex-none">
+      <vxe-pager
+        v-model:current-page="page"
+        v-model:page-size="pageSize"
+        :total="total"
+        :layouts="['Home', 'PrevJump', 'PrevPage', 'Number', 'NextPage', 'NextJump', 'End', 'Sizes', 'FullJump', 'Total']"
+      />
+    </footer>
+  </div>
+</template>
+<style scoped lang="scss">
+.page-container {
+  padding: 0 24px;
+  max-height: var(--page-main-container);
+}
+</style>

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

@@ -0,0 +1,90 @@
+const defaultDictionaries = [
+  {
+    'dictName': '职业',
+    'dictType': 'job',
+    'items': [
+      { 'dictLabel': '一般行政人员', 'dictValue': '0' },
+      { 'dictLabel': '久坐、久站、弯腰工作者', 'dictValue': '1' },
+      { 'dictLabel': '企事业单位负责人', 'dictValue': '2' },
+      { 'dictLabel': '中层管理', 'dictValue': '3' },
+      { 'dictLabel': '接触有毒化学物作业者', 'dictValue': '4' },
+      { 'dictLabel': '接触粉尘作业者', 'dictValue': '5' },
+      { 'dictLabel': '轮班或夜班工作者', 'dictValue': '6' },
+      { 'dictLabel': '户外工作者', 'dictValue': '7' },
+      { 'dictLabel': '运动员', 'dictValue': '8' },
+      { 'dictLabel': '其他', 'dictValue': '9' },
+    ],
+  },
+  {
+    'dictName': '系统是否',
+    'dictType': 'sys_yes_no',
+    'items': [
+      { 'dictLabel': '是', 'dictValue': 'Y' },
+      { 'dictLabel': '否', 'dictValue': 'N' },
+    ],
+  },
+  {
+    'dictName': '食物过敏',
+    'dictType': 'food_allergy',
+    'items': [
+      { 'dictLabel': '无', 'dictValue': '0' },
+      { 'dictLabel': '海鲜', 'dictValue': '1' },
+      { 'dictLabel': '鸡蛋', 'dictValue': '2' },
+      { 'dictLabel': '花生', 'dictValue': '3' },
+      { 'dictLabel': '大豆', 'dictValue': '4' },
+      { 'dictLabel': '牛奶', 'dictValue': '5' },
+      { 'dictLabel': '坚果', 'dictValue': '6' },
+      { 'dictLabel': '芒果', 'dictValue': '7' },
+      { 'dictLabel': '其他', 'dictValue': '8' },
+    ],
+  },
+  {
+    'dictName': '指标精度',
+    'dictType': 'quota_precision',
+    'items': [
+      { 'dictLabel': '小数点后2位', 'dictValue': '0' }, { 'dictLabel': '小数点后1位 ', 'dictValue': '1' },
+      { 'dictLabel': '到个位数,只有偶数', 'dictValue': '2' }, { 'dictLabel': '到个位数', 'dictValue': '3' },
+    ],
+  }, {
+    'dictName': '喜好口味',
+    'dictType': 'hobby_flavor',
+    'items': [
+      { 'dictLabel': '清淡', 'dictValue': '0' }, { 'dictLabel': '酸', 'dictValue': '1' }, { 'dictLabel': '甜', 'dictValue': '2' },
+      { 'dictLabel': '苦', 'dictValue': '3' }, { 'dictLabel': '辣', 'dictValue': '4' }, { 'dictLabel': '咸鲜味', 'dictValue': '5' },
+      { 'dictLabel': '酸甜味', 'dictValue': '6' }, { 'dictLabel': '香辣味', 'dictValue': '7' }, { 'dictLabel': '麻辣味', 'dictValue': '8' },
+      { 'dictLabel': '酸辣味', 'dictValue': '9' }, { 'dictLabel': '蒜泥味', 'dictValue': '10' }, { 'dictLabel': '椒麻味', 'dictValue': '11' },
+      { 'dictLabel': '咖喱味', 'dictValue': '12' }, { 'dictLabel': '芥末味', 'dictValue': '13' },
+    ],
+  }, {
+    'dictName': '用户性别',
+    'dictType': 'sys_user_sex',
+    'items': [ { 'dictLabel': '男', 'dictValue': '0' }, { 'dictLabel': '女', 'dictValue': '1' }, { 'dictLabel': '未知', 'dictValue': '2' } ],
+  }, {
+    'dictName': '系统开关',
+    'dictType': 'sys_normal_disable',
+    'items': [ { 'dictLabel': '正常', 'dictValue': '0' }, { 'dictLabel': '停用', 'dictValue': '1' } ],
+  }, {
+    'dictName': '女性特殊期',
+    'dictType': 'women_special_period',
+    'items': [
+      { 'dictLabel': '无', 'dictValue': '0' }, { 'dictLabel': '月经期', 'dictValue': '1' }, { 'dictLabel': '孕期', 'dictValue': '2' },
+      { 'dictLabel': '产后', 'dictValue': '3' }, { 'dictLabel': '哺乳期', 'dictValue': '4' },
+    ],
+  }, {
+    'dictName': '删除标志',
+    'dictType': 'del_flag',
+    'items': [ { 'dictLabel': '存在', 'dictValue': '0' }, { 'dictLabel': '删除', 'dictValue': '2' } ],
+  },
+] as const;
+
+type Key = typeof defaultDictionaries[number]['dictType']
+
+export async function dictionaryMethod() {
+  // @ts-ignore
+  const dictionaries: Record<Key, { dictLabel: string; dictValue: string; }[]> = {};
+  for ( const { dictType, items } of defaultDictionaries ) {
+    // @ts-ignore
+    dictionaries[ dictType ] = items;
+  }
+  return dictionaries;
+}

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

@@ -0,0 +1,37 @@
+import { type List, type PatientModel, type PatientQuery, type PatientReportModel, transformPatient } from '@/model';
+import request                                                                                        from '@/request/alova';
+
+
+export function patientsHistoryMethod(page: number, size: number, query?: PatientQuery) {
+  return request.Post<List<PatientReportModel>, List<any>>(`/fdhb-pc/patientInfoManage/pageMyPatient`, query ?? {}, {
+    params: { pageNum: page, pageSize: size },
+    transform(data, headers) {
+      return {
+        total: data.total,
+        data: data.data.map((item: any) => Object.assign(transformPatient(item), {
+          report: {
+            id: item.healthAnalysisReportId,
+            time: item.reportTime,
+          },
+          createTime: item.createTime,
+        })),
+      };
+    },
+  });
+}
+
+export function patientsRoomMethod(page: number, size: number) {
+  return request.Post(`/fdhb-pc/patientInfoManage/pendPatient`, {}, {
+    params: { pageNum: page, pageSize: size },
+  });
+}
+
+export function patientMethod(id: string) {
+  return request.Get<PatientModel, any>(`/fdhb-pc/patientInfoManage/getPatientInfoDetailById`, {
+    params: { patientId: id },
+    transform(data, headers) {
+      data.patientId ??= id;
+      return transformPatient(data);
+    },
+  });
+}

+ 71 - 0
src/request/api/report.api.ts

@@ -0,0 +1,71 @@
+import { type ReportModel, transformIndicator } from '@/model';
+import request                                  from '@/request/alova';
+
+
+export function reportsMethod(patientId: string) {
+  return request.Get<ReportModel[], any[]>(`/fdhb-pc/analysisManage/getHarsTid`, {
+    params: { patientId },
+    transform(data, headers) {
+      return data?.map(item => {
+        return {
+          ...item,
+          id: item.healthAnalysisReportId,
+          time: item.time2,
+        }
+      }) ?? [];
+    },
+  });
+}
+
+export function reportMethod(id: string) {
+  return request.Get<ReportModel, any>(`/fdhb-pc/analysisManage/getHealRepDetailById`, {
+    params: { healthAnalysisReportId: id },
+    transform(data, headers) {
+      return {
+        ...data,
+        id: data.healthAnalysisReportId,
+        time: data.reportTime,
+      };
+    },
+  });
+}
+
+export function schemeMethod(reportId: string) {
+  return request.Get<ReportModel>(`/fdhb-pc/analysisManage/getCondProgDetailById`, {
+    params: { healthAnalysisReportId: reportId },
+  });
+}
+
+export function indicatorMethod(reportId: string) {
+  return request.Get<Record<string, any>[], any[]>(`fdhb-pc/analysisManage/getLast7Day`, {
+    params: { healthAnalysisReportId: reportId },
+    transform(data, headers) {
+      return transformIndicator(data);
+    },
+  });
+}
+
+
+// 患者指标
+export function patientIndicatorMethod(patientId: string) {
+  return request.Get<Record<string, any>[], any[]>(`/fdhb-pc/patientQuota/getCurQuovalByPatId`, {
+    params: { patientId },
+    transform(data, headers) {
+      return transformIndicator(data);
+    },
+  });
+}
+
+// 患者指标更新记录
+export function patientIndicatorUpdateReportMethod(patientId: string) {
+  return request.Get(`/fdhb-pc/patientQuota/getQuovalRecord`, {
+    params: { patientId },
+  });
+}
+
+// 患者数据更新记录
+export function patientInfoUpdateReportMethod(id: string) {
+  return request.Get(`/fdhb-pc/patientInfoManage/pageChangeRecordById`, {
+    params: { patientId: id },
+  });
+}

+ 214 - 0
src/widgets/AnalysisReportWidget.vue

@@ -0,0 +1,214 @@
+<script setup lang="ts">
+import type { ReportModel }                   from '@/model';
+import { indicatorMethod, reportMethod }      from '@/request/api/report.api';
+import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons-vue';
+import { useWatcher }                         from 'alova/client';
+
+
+const props = defineProps<{ report?: ReportModel }>();
+const emits = defineEmits<{ loaded: [] }>();
+const reportId = inject('report-id', toRef(() => props.report?.id));
+
+const { data: report, loading: reportLoading } = useWatcher(
+  () => reportMethod(reportId.value!),
+  [ reportId ],
+  { initialData: props.report, immediate: true },
+).onComplete(() => { emits('loaded'); });
+const { data: indicator, loading: indicatorLoading } = useWatcher(
+  () => indicatorMethod(reportId.value!),
+  [ reportId ],
+  { initialData: [] , immediate: true },
+);
+</script>
+<template>
+  <div class="widget-wrapper">
+    <a-card class="card no-bordered" size="small">
+      <a-descriptions :column="3">
+        <a-descriptions-item v-if="report.willillStateName" label="健康状态">{{ report.willillStateName }}
+        </a-descriptions-item>
+        <a-descriptions-item v-if="report.willillDegreeName" label="程度" :span="2">{{ report.willillDegreeName }}
+        </a-descriptions-item>
+        <a-descriptions-item v-if="report.willillFunctionName" label="表现">{{ report.willillFunctionName }}
+        </a-descriptions-item>
+        <a-descriptions-item v-if="report.constitutionGroupName" label="体质" :span="2">
+          {{ report.constitutionGroupName }}
+        </a-descriptions-item>
+      </a-descriptions>
+    </a-card>
+    <a-card class="card" size="small">
+      <p v-if="report.constitutionGroupDefinition">体质: {{ report.constitutionGroupDefinition }}</p>
+      <a-descriptions :column="1" bordered>
+        <a-descriptions-item v-if="report.constitutionGroupGeneralCharacteristics" label="总体特征">
+          {{ report.constitutionGroupGeneralCharacteristics }}
+        </a-descriptions-item>
+        <a-descriptions-item v-if="report.constitutionGroupPhysicalCharacteristics" label="形体特征">
+          {{ report.constitutionGroupPhysicalCharacteristics }}
+        </a-descriptions-item>
+        <a-descriptions-item v-if="report.constitutionGroupPsychicCharacteristics" label="精神特征">
+          {{ report.constitutionGroupPsychicCharacteristics }}
+        </a-descriptions-item>
+        <a-descriptions-item v-if="report.constitutionGroupCommonManifestations" label="常见表现">
+          {{ report.constitutionGroupCommonManifestations }}
+        </a-descriptions-item>
+        <a-descriptions-item v-if="report.constitutionGroupDiseaseTendency" label="发病倾向">
+          {{ report.constitutionGroupDiseaseTendency }}
+        </a-descriptions-item>
+        <a-descriptions-item v-if="report.constitutionGroupAdaptability" label="环境适应能力">
+          {{ report.constitutionGroupAdaptability }}
+        </a-descriptions-item>
+      </a-descriptions>
+    </a-card>
+    <a-card class="card" size="small" title="舌象分析">
+      <a-descriptions :column="3">
+        <a-descriptions-item>
+          <a-image :width="200" :src="report.upImg" :preview="false" />
+        </a-descriptions-item>
+        <a-descriptions-item>
+          <a-image :width="200" :src="report.downImg" :preview="false" />
+        </a-descriptions-item>
+        <a-descriptions-item></a-descriptions-item>
+      </a-descriptions>
+      <a-descriptions :column="3" bordered>
+        <a-descriptions-item style="font-size: 16px;font-weight: 600;">舌象维度</a-descriptions-item>
+        <a-descriptions-item style="font-size: 16px;font-weight: 600;">检测结果</a-descriptions-item>
+        <a-descriptions-item style="font-size: 16px;font-weight: 600;">标准值</a-descriptions-item>
+        <template v-if="report.tongueColor?.actualList">
+          <a-descriptions-item>舌色</a-descriptions-item>
+          <a-descriptions-item>
+            <span class="tongue-value" v-for="item in report.tongueColor.actualList" :key="item?.actualValue">
+              <span>{{ item.actualValue }}</span>
+              <span v-if="item.contrast !== 's'">({{ item.contrast }})</span>
+            </span>
+          </a-descriptions-item>
+          <a-descriptions-item>{{ report.tongueColor.standardValue }}</a-descriptions-item>
+        </template>
+        <template v-if="report.tongueCoatingColor?.actualList">
+          <a-descriptions-item>苔色</a-descriptions-item>
+          <a-descriptions-item>
+            <span class="tongue-value" v-for="item in report.tongueCoatingColor.actualList" :key="item?.actualValue">
+              <span>{{ item.actualValue }}</span>
+              <span v-if="item.contrast !== 's'">({{ item.contrast }})</span>
+            </span>
+          </a-descriptions-item>
+          <a-descriptions-item>{{ report.tongueCoatingColor.standardValue }}</a-descriptions-item>
+        </template>
+        <template v-if="report.tongueShape?.actualList">
+          <a-descriptions-item>舌形</a-descriptions-item>
+          <a-descriptions-item>
+            <span class="tongue-value" v-for="item in report.tongueShape.actualList" :key="item?.actualValue">
+              <span>{{ item.actualValue }}</span>
+              <span v-if="item.contrast !== 's'">({{ item.contrast }})</span>
+            </span>
+          </a-descriptions-item>
+          <a-descriptions-item>{{ report.tongueShape.standardValue }}</a-descriptions-item>
+        </template>
+        <template v-if="report.tongueCoating?.actualList">
+          <a-descriptions-item>苔质</a-descriptions-item>
+          <a-descriptions-item>
+            <span class="tongue-value" v-for="item in report.tongueCoating.actualList" :key="item?.actualValue">
+              <span>{{ item.actualValue }}</span>
+              <span v-if="item.contrast !== 's'">({{ item.contrast }})</span>
+            </span>
+          </a-descriptions-item>
+          <a-descriptions-item>{{ report.tongueCoating.standardValue }}</a-descriptions-item>
+        </template>
+        <template v-if="report.bodyFluid?.actualList">
+          <a-descriptions-item>津液</a-descriptions-item>
+          <a-descriptions-item>
+            <span class="tongue-value" v-for="item in report.bodyFluid.actualList" :key="item?.actualValue">
+              <span>{{ item.actualValue }}</span>
+              <span v-if="item.contrast !== 's'">({{ item.contrast }})</span>
+            </span>
+          </a-descriptions-item>
+          <a-descriptions-item>{{ report.bodyFluid.standardValue }}</a-descriptions-item>
+        </template>
+        <template v-if="report.sublingualVein?.actualList">
+          <a-descriptions-item>舌下</a-descriptions-item>
+          <a-descriptions-item>
+            <span class="" v-for="item in report.sublingualVein.actualList" :key="item?.actualValue">
+              <span>{{ item.actualValue }}</span>
+              <span v-if="item.contrast !== 's'">({{ item.contrast }})</span>
+            </span>
+          </a-descriptions-item>
+          <a-descriptions-item>{{ report.sublingualVein.standardValue }}</a-descriptions-item>
+        </template>
+      </a-descriptions>
+    </a-card>
+    <a-card class="card no-bordered" size="small" title="面象分析" v-if="report.faceAnalysisResult">
+      <a-descriptions :column="3">
+        <a-descriptions-item>
+          <a-image :width="200" :src="report.faceImg" :preview="false" />
+        </a-descriptions-item>
+        <a-descriptions-item :span="2">{{ report.faceAnalysisResult }}</a-descriptions-item>
+      </a-descriptions>
+    </a-card>
+    <a-card class="card symptom-card" size="small" title="中医证素" v-if="report.factorItems?.length">
+      <a-descriptions :column="1" bordered>
+        <a-descriptions-item v-for="item in report.factorItems" :key="item.factorItemName"
+                             :label="item.factorItemName"
+        >
+          {{ item.factorItemDescription }}
+        </a-descriptions-item>
+      </a-descriptions>
+    </a-card>
+    <a-card class="card symptom-card" size="small" title="中医证型" v-if="report.diagnoseSyndromes?.length">
+      <a-descriptions :column="1" bordered>
+        <a-descriptions-item v-for="item in report.diagnoseSyndromes" :key="item.diagnoseSyndromeName"
+                             :label="item.diagnoseSyndromeName"
+        >
+          {{ item.diagnoseSyndromeAnalysis }}
+        </a-descriptions-item>
+      </a-descriptions>
+    </a-card>
+    <a-card class="card no-bordered" size="small" :loading="indicatorLoading" title="指标信息" v-if="indicator?.length">
+      <a-descriptions :column="3">
+        <a-descriptions-item v-for="item in indicator" :label="item?.name" :key="item?.quotaId">
+          {{ item.quotaVal }}
+          {{ item.unit }}
+          <ArrowUpOutlined class="icon" v-if="item.abnormal === 1" />
+          <ArrowDownOutlined class="icon" v-else-if="item.abnormal === -1" />
+        </a-descriptions-item>
+      </a-descriptions>
+    </a-card>
+  </div>
+</template>
+<style scoped lang="scss">
+
+.card {
+  margin-bottom: 12px;
+
+  &.no-bordered {
+    :deep(.ant-card-body) {
+      padding-top: 16px;
+      padding-bottom: 0;
+    }
+  }
+
+  .tongue-value {
+    margin: 0 4px;
+
+    &:first-of-type {
+      margin-left: 0;
+    }
+
+    &:last-of-type {
+      margin-right: 0;
+    }
+  }
+}
+
+.symptom-card {
+  :deep(.ant-descriptions-item-label) {
+    padding: 8px 12px;
+    width: 120px;
+    text-align: center;
+  }
+}
+
+.icon {
+  margin-left: 4px;
+  font-size: 18px;
+  color: #b22222ff;
+  transform: translateY(2px);
+}
+</style>

+ 21 - 0
src/widgets/AnalysisSchemeWidget.vue

@@ -0,0 +1,21 @@
+<script setup lang="ts">
+import type { ReportModel } from '@/model';
+import { schemeMethod }     from '@/request/api/report.api';
+import { useWatcher }       from 'alova/client';
+
+
+const props = defineProps<{ report?: ReportModel }>();
+const emits = defineEmits<{ loaded: [] }>();
+const reportId = inject('report-id', toRef(() => props.report?.id));
+
+const { data: scheme, loading: schemeLoading } = useWatcher(
+  () => schemeMethod(reportId.value!),
+  [ reportId ],
+  { initialData: {}, immediate: true },
+).onComplete(() => { emits('loaded'); });
+</script>
+<template>
+  <div class="widget-wrapper">{{ scheme }}</div>
+</template>
+<style scoped lang="scss">
+</style>

+ 7 - 1
vite.config.ts

@@ -76,7 +76,13 @@ export default defineConfig((configEnv) => {
       open: true,
       proxy: {
         '/prod-api': {
-          target: env.REQUEST_API_PROXY_URL,
+          target: 'http://121.43.162.141:8001',
+          secure: false,
+          changeOrigin: false,
+          logLevel: 'debug',
+        },
+        '/fdhb-pc': {
+          target: 'https://wx.hzliuzhi.com/manager',
           secure: false,
           changeOrigin: false,
           logLevel: 'debug',