cc12458 há 1 ano atrás
pai
commit
5a8ba33398
41 ficheiros alterados com 833 adições e 407 exclusões
  1. 0 12
      @types/components.d.ts
  2. 1 1
      package.json
  3. 46 2
      src/components/ReportAnalysisEdit.vue
  4. 105 59
      src/components/ReportHistoryPreview.vue
  5. 33 51
      src/components/ReportPreview.vue
  6. 27 6
      src/components/ReportSchemeEdit.vue
  7. 1 1
      src/components/ReportSchemePreview.vue
  8. 1 1
      src/components/TagEdit.vue
  9. 2 2
      src/components/UserEdit.vue
  10. 7 6
      src/components/UserPassword.vue
  11. 13 5
      src/components/UserPreview.vue
  12. 0 3
      src/libs/v-select-page/RemoteSelect.vue
  13. 3 2
      src/libs/vxe/index.ts
  14. 6 8
      src/libs/vxe/plugin.ts
  15. 5 0
      src/model/people.model.ts
  16. 3 2
      src/model/report.model.ts
  17. 6 3
      src/model/system.model.ts
  18. 65 9
      src/pages/index.vue
  19. 6 5
      src/pages/index/patient/history.vue
  20. 1 1
      src/pages/index/patient/room@aside.vue
  21. 72 21
      src/pages/index/patient/room@default.vue
  22. 3 1
      src/pages/index/system/role.vue
  23. 3 6
      src/pages/index/system/tag.vue
  24. 7 6
      src/pages/index/system/user.vue
  25. 3 0
      src/request/alova.ts
  26. 3 3
      src/request/api/account.api.ts
  27. 18 6
      src/request/api/patient.api.ts
  28. 19 6
      src/request/api/report.api.ts
  29. 3 0
      src/request/api/system.api.ts
  30. 5 2
      src/router/index.ts
  31. 4 3
      src/stores/index.ts
  32. 24 0
      src/themes/fix.scss
  33. 21 1
      src/themes/report-card.scss
  34. 5 6
      src/widgets/AnalysisReportWidget.vue
  35. 5 1
      src/widgets/PatientCardWidget.vue
  36. 88 17
      src/widgets/PatientTagWidget.vue
  37. 2 1
      src/widgets/ReportAnalysisWidgetTongue.vue
  38. 167 20
      src/widgets/ReportCardWidget.vue
  39. 4 4
      src/widgets/ReportIndicatorChartWidget.vue
  40. 4 1
      src/widgets/ReportIndicatorWidget.vue
  41. 42 123
      src/widgets/ReportSchemeCardWidget.vue

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

@@ -7,9 +7,7 @@ export {}
 /* prettier-ignore */
 declare module 'vue' {
   export interface GlobalComponents {
-    AAffix: typeof import('ant-design-vue/es')['Affix']
     AAvatar: typeof import('ant-design-vue/es')['Avatar']
-    ABadge: typeof import('ant-design-vue/es')['Badge']
     AButton: typeof import('ant-design-vue/es')['Button']
     ACard: typeof import('ant-design-vue/es')['Card']
     ACarousel: typeof import('ant-design-vue/es')['Carousel']
@@ -23,35 +21,25 @@ 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']
     AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
     AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
     AInputSearch: typeof import('ant-design-vue/es')['InputSearch']
     AMenu: typeof import('ant-design-vue/es')['Menu']
-    AnalysisReportPreview: typeof import('./../src/components/AnalysisReportPreview.vue')['default']
     APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
-    ARadio: typeof import('ant-design-vue/es')['Radio']
-    ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
     AResult: typeof import('ant-design-vue/es')['Result']
     ARow: typeof import('ant-design-vue/es')['Row']
     ASelect: typeof import('ant-design-vue/es')['Select']
-    ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
     ASpace: typeof import('ant-design-vue/es')['Space']
     ASpaceCompact: typeof import('ant-design-vue/es')['Compact']
     ASpin: typeof import('ant-design-vue/es')['Spin']
     ATag: typeof import('ant-design-vue/es')['Tag']
     ATextarea: typeof import('ant-design-vue/es')['Textarea']
     ATooltip: typeof import('ant-design-vue/es')['Tooltip']
-    PatientInfoUpdateReport: typeof import('./../src/components/PatientInfoUpdateReport.vue')['default']
-    PatientReportPreview: typeof import('./../src/components/PatientReportPreview.vue')['default']
-    PatientTagEdit: typeof import('./../src/components/PatientTagEdit.vue')['default']
-    RecordPatientPreview: typeof import('./../src/components/RecordPatientPreview.vue')['default']
     RecordsAnalysisPreview: typeof import('./../src/components/RecordsAnalysisPreview.vue')['default']
     RecordsIndicatorPreview: typeof import('./../src/components/RecordsIndicatorPreview.vue')['default']
     RecordsPatientPreview: typeof import('./../src/components/RecordsPatientPreview.vue')['default']
     ReportAnalysisEdit: typeof import('./../src/components/ReportAnalysisEdit.vue')['default']
-    ReportAnalysisWidget: typeof import('./../src/components/ReportAnalysisWidget.vue')['default']
     ReportHistoryPreview: typeof import('./../src/components/ReportHistoryPreview.vue')['default']
     ReportPreview: typeof import('./../src/components/ReportPreview.vue')['default']
     ReportSchemeEdit: typeof import('./../src/components/ReportSchemeEdit.vue')['default']

+ 1 - 1
package.json

@@ -7,7 +7,7 @@
     "dev": "vite",
     "build": "run-p type-check \"build-only {@}\" --",
     "preview": "vite preview",
-    "build-only": "vite build",
+    "build-only": "vite build --base=/admin/",
     "type-check": "vue-tsc --build --force",
     "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
     "format": "prettier --write src/"

+ 46 - 2
src/components/ReportAnalysisEdit.vue

@@ -3,7 +3,6 @@ import type { PatientModel, ReportModel }           from '@/model';
 import { patientMethod }                            from '@/request/api/patient.api';
 import { indicatorByPatientIdMethod, reportMethod } from '@/request/api/report.api';
 import PatientCardWidget                            from '@/widgets/PatientCardWidget.vue';
-import ReportAnalysisWidget                         from '@/widgets/ReportAnalysisWidget.vue';
 import ReportIndicatorWidget                        from '@/widgets/ReportIndicatorWidget.vue';
 import { useWatcher }                               from 'alova/client';
 
@@ -45,7 +44,51 @@ const { data: indicator, loading: indicatorLoading } = useWatcher(
 <template>
   <div id="page-container-scroller" class="page-container flex flex-col">
     <PatientCardWidget :dataset="patient" :loading="patientLoading" />
-    <ReportAnalysisWidget :dataset="report" :loading="reportLoading" />
+    <div ref="container" class="card card__report-analysis">
+      <div class="card__header sticky flex justify-between items-center">
+        <div class="card__title">
+          <span>症状信息</span>
+          <a-spin v-if="reportLoading" size="small" style="margin-left: 4px;" />
+        </div>
+      </div>
+      <div class="card__content">
+        <a-descriptions :column="2">
+          <a-descriptions-item v-if="report.tongueAnalysisResult">
+            <a-space align="start">
+              <a-image :width="200" :height="200" :src="report.upImg" :preview="false" />
+              <a-image :width="200" :height="200" :src="report.downImg" :preview="false" />
+              <a-card size="small" title="舌象分析结果">
+                {{ report.tongueAnalysisResult }}
+              </a-card>
+            </a-space>
+          </a-descriptions-item>
+          <a-descriptions-item v-if="report.faceAnalysisResult">
+            <a-space wrap>
+              <a-space align="start">
+                <a-image :width="200" :height="200" :src="report.faceImg" :preview="false" />
+                <a-card size="small" title="面象分析结果">
+                  {{ report.faceAnalysisResult }}
+                </a-card>
+              </a-space>
+            </a-space>
+          </a-descriptions-item>
+        </a-descriptions>
+        <a-card class="card no-bordered background m-t-8px" size="small">
+          <a-descriptions :column="1">
+            <a-descriptions-item v-if="report.pickedSymptom || report.influenceDegree || report.duration"
+                                 label="补充症状"
+            >
+              <span class="phrase" v-if="report.pickedSymptom">{{ report.pickedSymptom }}</span>
+              <span class="phrase" v-if="report.influenceDegree">{{ report.influenceDegree }}</span>
+              <span class="phrase" v-if="report.duration">{{ report.duration }}</span>
+            </a-descriptions-item>
+            <a-descriptions-item v-if="report.algorithmInferSymptom" label="联想症状">
+              {{ report.algorithmInferSymptom }}
+            </a-descriptions-item>
+          </a-descriptions>
+        </a-card>
+      </div>
+    </div>
     <ReportIndicatorWidget
       :dataset="indicator" :loading="indicatorLoading"
       :patientId editable
@@ -54,4 +97,5 @@ const { data: indicator, loading: indicatorLoading } = useWatcher(
   </div>
 </template>
 <style scoped lang="scss">
+@import "@/themes/report-card";
 </style>

+ 105 - 59
src/components/ReportHistoryPreview.vue

@@ -6,17 +6,18 @@ import ReportPreview           from '@/components/ReportPreview.vue';
 import ReportSchemePreview     from '@/components/ReportSchemePreview.vue';
 
 import type { PatientModel, ReportModel, ReportSchemeItemModel }        from '@/model';
-import { patientMethod }                                                from '@/request/api/patient.api';
+import { patientMethod, patientTags }                                   from '@/request/api/patient.api';
 import { indicatorByPatientIdMethod, reportMethod, reportSchemeMethod } from '@/request/api/report.api';
 
-import PatientCardWidget     from '@/widgets/PatientCardWidget.vue';
-import ReportAnalysisWidget  from '@/widgets/ReportAnalysisWidget.vue';
-import ReportCardWidget      from '@/widgets/ReportCardWidget.vue';
-import ReportIndicatorWidget from '@/widgets/ReportIndicatorWidget.vue';
-import { useWatcher }        from 'alova/client';
-import { Button }            from 'ant-design-vue';
-import { h }                 from 'vue';
-import { VxeUI }             from 'vxe-pc-ui';
+import PatientCardWidget      from '@/widgets/PatientCardWidget.vue';
+import PatientTagWidget       from '@/widgets/PatientTagWidget.vue';
+import ReportIndicatorWidget  from '@/widgets/ReportIndicatorWidget.vue';
+import ReportSchemeCardWidget from '@/widgets/ReportSchemeCardWidget.vue';
+import { useElementSize }     from '@vueuse/core';
+import { useWatcher }         from 'alova/client';
+import { Button }             from 'ant-design-vue';
+import { h }                  from 'vue';
+import { VxeUI }              from 'vxe-pc-ui';
 
 
 const props = defineProps<{
@@ -36,6 +37,15 @@ const { data: patient, loading: patientLoading } = useWatcher(
     middleware: (_, next) => { if ( patientId.value ) next(); },
   },
 );
+
+const { data: tags, loading: tagsLoading, send: loadTags } = useWatcher(
+  () => patientTags(patientId.value),
+  [ patientId ],
+  {
+    initialData: [], immediate: true,
+    middleware: (_, next) => { if ( patientId.value ) next(); },
+  },
+);
 const { data: report, loading: reportLoading } = useWatcher(
   () => reportMethod(reportId.value!),
   [ reportId ],
@@ -145,7 +155,7 @@ function openSchemeItemPreviewHandle(value: ReportSchemeItemModel) {
   });
 }
 
-const reportType = ref<'analysis' | 'scheme'>();
+const reportType = ref<'analysis' | 'scheme'>('analysis');
 const analysisReportPreviewOpening = ref(false);
 const schemeReportPreviewOpening = ref(false);
 
@@ -183,8 +193,8 @@ function openReportPreviewHandle(type: 'analysis' | 'scheme') {
       },
       corner() {
         const { next, label } = {
-          analysis: { next: 'scheme', label: '调理方案' },
-          scheme: { next: 'analysis', label: '健康分析报告' },
+          analysis: { next: 'scheme' as const, label: '调理方案' },
+          scheme: { next: 'analysis' as const, label: '健康分析报告' },
         }[ reportType.value ];
         return h(Button, {
             type: 'primary', size: 'small',
@@ -200,64 +210,100 @@ function openReportPreviewHandle(type: 'analysis' | 'scheme') {
     },
   });
 }
+
+const patientCardRef = ref<HTMLElement>();
+const { height } = useElementSize(patientCardRef);
 </script>
 <template>
   <div id="page-container-scroller" class="page-container flex flex-col">
-    <PatientCardWidget :dataset="patient" :loading="patientLoading">
-      <template #tool-bar>
-        <a-button type="primary" size="small" :disabled="!patientId" @click="openPatientRecordsPreviewHandle()">
-          更新记录
-        </a-button>
-      </template>
-    </PatientCardWidget>
-    <ReportAnalysisWidget :dataset="report" :loading="reportLoading" :collapsed="true" title="健康状况">
-      <template #tool-bar>
-        <a-button type="primary" size="small" :disabled="!reportId" @click="openAnalysisRecordsPreviewHandle()">
-          更新记录
-        </a-button>
-      </template>
-    </ReportAnalysisWidget>
-    <ReportCardWidget :dataset="report" :loading="reportLoading" :collapsed="true">
-      <template #analysis>
-        <a-button
-          type="primary" size="small" :disabled="!reportId" :loading="analysisReportPreviewOpening"
-          @click="analysisReportPreviewOpening = true; openReportPreviewHandle('analysis');"
-        >报告详情
-        </a-button>
-      </template>
-    </ReportCardWidget>
+    <div style="display: flex">
+      <PatientCardWidget style="flex: auto" ref="patientCardRef" :dataset="patient" :loading="patientLoading">
+        <template #tool-bar>
+          <a-button type="primary" size="small" :disabled="!patientId" @click="openPatientRecordsPreviewHandle()">
+            更新记录
+          </a-button>
+        </template>
+      </PatientCardWidget>
+      <PatientTagWidget
+        :style="{height: `${height}px`, minHeight: '112px'}" style="flex: none;width: 256px"
+        :dataset="tags" :loading="tagsLoading"
+        editable @refresh="loadTags()"
+      />
+    </div>
+    <div class="card report-card" ref="container">
+      <div class="card__header sticky flex justify-between items-center">
+        <div class="card__title">
+          <span>健康状况</span>
+          <a-spin v-if="reportLoading" size="small" style="margin-left: 4px;" />
+          <a-space style="margin-left: 8px">
+            <a-button type="primary" size="small" :disabled="!reportId" @click="openAnalysisRecordsPreviewHandle()">
+              更新记录
+            </a-button>
+          </a-space>
+        </div>
+      </div>
+      <div class="card__content">
+        <a-row>
+          <a-col :span="12">
+            <a-card class="card no-bordered" size="small" style="background-color: #f7f7f7">
+              <a-descriptions :column="1">
+                <a-descriptions-item v-if="report?.pickedSymptom" label="症状信息">
+                  {{ report.pickedSymptom }}
+                </a-descriptions-item>
+                <a-descriptions-item v-if="report?.algorithmInferSymptom" label="联想症状">
+                  {{ report.algorithmInferSymptom }}
+                </a-descriptions-item>
+              </a-descriptions>
+            </a-card>
+          </a-col>
+          <a-col :span="12">
+            <a-space style="margin-left: 8px;">
+              <a-image v-if="report?.upImg" :width="200" :height="200" :src="report.upImg" :preview="false" />
+              <a-image v-if="report?.downImg" :width="200" :height="200" :src="report.downImg" :preview="false" />
+              <a-image v-if="report?.faceImg" :width="200" :height="200" :src="report.faceImg" :preview="false" />
+            </a-space>
+          </a-col>
+        </a-row>
+      </div>
+    </div>
     <div class="card report-card" ref="container">
       <div class="card__header sticky flex justify-between items-center">
         <div class="card__title">
-          <span>调理方案</span>
-          <a-spin v-if="schemeLoading" size="small" style="margin-left: 4px;" />
+          <span>健康分析报告</span>
+          <a-spin v-if="reportLoading" size="small" style="margin-left: 4px;" />
+          <a-space style="margin-left: 8px">
+            <a-button
+              type="primary" size="small" :disabled="!reportId" :loading="schemeReportPreviewOpening"
+              @click="schemeReportPreviewOpening = true; openReportPreviewHandle('analysis');"
+            >报告详情
+            </a-button>
+          </a-space>
         </div>
       </div>
       <div class="card__content">
-        <a-card class="card no-bordered" size="small" style="background-color: #f7f7f7">
-          <a-descriptions :column="1">
-            <a-descriptions-item v-if="scheme.process" label="调理进程">
-              <span style="margin-right: 8px;">{{ scheme.process }}</span>
-              <a-button
-                type="primary" size="small" :disabled="!reportId" :loading="schemeReportPreviewOpening"
-                @click="schemeReportPreviewOpening = true; openReportPreviewHandle('scheme');"
-              >方案详情
-              </a-button>
-            </a-descriptions-item>
-            <a-descriptions-item v-if="scheme.children?.length" label="方案内容">
-              <a-space :size="[8, 8]" wrap>
-                <template v-for="item in scheme.children" :key="item.id">
-                  <a-tag class="scheme-tag-item" @click="openSchemeItemPreviewHandle(item)">
-                    <span class="header">{{ item.category }}</span>
-                    <span class="header" v-if="item.name">{{ item.name }}</span>
-                  </a-tag>
-                </template>
-              </a-space>
-            </a-descriptions-item>
-          </a-descriptions>
-        </a-card>
+        <a-row>
+          <a-col :span="12">
+            <a-card class="card no-bordered" size="small" style="background-color: #f7f7f7">
+              <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-col>
+        </a-row>
       </div>
     </div>
+    <ReportSchemeCardWidget :dataset="scheme" :loading="schemeLoading" :editable="false" />
     <ReportIndicatorWidget :dataset="indicator" :loading="indicatorLoading">
       <template #tool-bar>
         <a-button type="primary" size="small" :disabled="!patientId" @click="openIndicatorRecordsPreviewHandle()">

+ 33 - 51
src/components/ReportPreview.vue

@@ -3,11 +3,10 @@ import type { PatientModel, ReportModel, ReportSchemeModel }
 import { indicatorByReportIdMethod, reportMethod, reportSchemeMethod, reportsMethod } from '@/request/api/report.api';
 import ReportCardWidget
                                                                                       from '@/widgets/ReportCardWidget.vue';
-import ReportIndicatorWidget
-                                                                                      from '@/widgets/ReportIndicatorWidget.vue';
-import { ArrowsAltOutlined, CaretRightOutlined, ShrinkOutlined }                      from '@ant-design/icons-vue';
+import ReportSchemeCardWidget
+                                                                                      from '@/widgets/ReportSchemeCardWidget.vue';
+import { ArrowDownOutlined, ArrowUpOutlined }                                         from '@ant-design/icons-vue';
 import { useWatcher }                                                                 from 'alova/client';
-import { h }                                                                          from 'vue';
 
 
 const props = defineProps<{
@@ -30,7 +29,7 @@ const { data: report, loading: reportLoading } = useWatcher(
     initialData: { ...props.report }, immediate: true,
     middleware: (_, next) => { if ( reportId.value && showType.value === 'analysis' ) next(); },
   },
-);
+).onSuccess(() => scrollIntoView());
 const { data: indicator, loading: indicatorLoading } = useWatcher(
   () => indicatorByReportIdMethod(reportId.value!),
   [ reportId, showType ],
@@ -56,24 +55,28 @@ const { data: scheme, loading: schemeLoading } = useWatcher(
     initialData: { ...props.scheme }, immediate: true,
     middleware: (_, next) => { if ( reportId.value && showType.value === 'scheme' ) next(); },
   },
-).onSuccess(() => {
-  expandAll(true);
-});
+).onSuccess(() => scrollIntoView());
 
-onBeforeMount(() => { reportId.value ??= props.report.id; });
+onBeforeMount(() => { reportId.value ??= props.report.id!; });
 
 const schemeKeys = ref([]);
 const collapsed = ref(false);
 const expandAll = (value: boolean) => {
   collapsed.value = value;
-  schemeKeys.value = value ? scheme.value.children.map(child => child.id) : [];
+  schemeKeys.value = value ? scheme.value.children.map(child => child.id) : [] as string[];
 };
-const expand = () => {};
+
+const containerRef = ref<HTMLElement & { elementTarget: HTMLElement }>();
+
+function scrollIntoView() {
+  const el = unref(containerRef.value?.elementTarget) ?? containerRef.value;
+  el?.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' });
+}
 </script>
 <template>
   <div id="page-container-scroller" class="page-container flex flex-col">
     <template v-if="showType === 'analysis'">
-      <ReportCardWidget :dataset="report" :loading="reportLoading">
+      <ReportCardWidget ref="containerRef" :dataset="report" :loading="reportLoading" :collapsible="false" hide-title>
         <template #select>
           <a-select
             v-model:value="reportId" :loading="reportsLoading"
@@ -81,26 +84,26 @@ const expand = () => {};
             style="min-width: 120px;"
           ></a-select>
         </template>
+        <a-card class="card background no-bordered-8" size="small" title="指标信息" :loading="indicatorLoading">
+          <a-descriptions v-if="indicator?.length" :column="3" size="small">
+            <a-descriptions-item v-for="item in indicator" :key="item.id" :label="item.name">
+              <div v-if="item.value">
+                <span>{{ item.value }}</span>
+                <span v-if="item.unit">{{ item.unit }}</span>
+                <ArrowUpOutlined class="icon" v-if="item.records?.[0]?.abnormal === 1" />
+                <ArrowDownOutlined class="icon" v-if="item.records?.[0]?.abnormal === -1" />
+              </div>
+              <span v-else>-</span>
+            </a-descriptions-item>
+          </a-descriptions>
+          <div v-else style="padding-bottom: 8px;">暂无数据</div>
+        </a-card>
       </ReportCardWidget>
-      <ReportIndicatorWidget :dataset="indicator" :loading="indicatorLoading" />
     </template>
     <template v-else-if="showType === 'scheme'">
-      <div class="card report-card" ref="container">
-        <div class="card__header sticky flex justify-between items-center">
-          <div class="card__title">
-            <span>调理方案</span>
-            <a-spin v-if="schemeLoading" size="small" style="margin-left: 4px;" />
-            <a-space style="margin-left: 8px;">
-              <a-tooltip :title="collapsed ? '展开' : '收起'">
-                <a-button shape="circle" size="small" :icon="collapsed ? h(ArrowsAltOutlined) : h(ShrinkOutlined)"
-                          @click="expandAll(!collapsed)"
-                />
-              </a-tooltip>
-            </a-space>
-          </div>
-        </div>
-        <div class="card__content">
-          <a-card class="card no-bordered" size="small" style="background-color: #f7f7f7;margin-bottom: 12px;">
+      <div class="card report-card" ref="containerRef">
+        <div class="card__content" style="padding-bottom: 0;">
+          <a-card class="card no-bordered background" size="small" :loading="schemeLoading">
             <a-descriptions :column="3">
               <a-descriptions-item v-if="scheme.time" label="方案日期" :span="3">
                 <span>{{ scheme.time }}</span>
@@ -110,30 +113,9 @@ const expand = () => {};
               </a-descriptions-item>
             </a-descriptions>
           </a-card>
-          <a-collapse
-            v-model:activeKey="schemeKeys"
-            :bordered="true"
-            class="scheme-dataset-collapse-2"
-          >
-            <template #expandIcon="{isActive,panelKey}">
-              <CaretRightOutlined class="collapse-expand-icon" :rotate="isActive ? 90 : 0"
-                                  @click="expand(panelKey, !isActive)"
-              />
-            </template>
-            <template v-for="(panel, index) in scheme.children" :key="panel.id">
-              <a-collapse-panel>
-                <template #header>
-                  <div>
-                    <span class="header">{{ panel.category }}</span>
-                    <span class="header" v-if="panel.name">{{ panel.name }}</span>
-                  </div>
-                </template>
-                <ReportSchemePreview :value="panel" />
-              </a-collapse-panel>
-            </template>
-          </a-collapse>
         </div>
       </div>
+      <ReportSchemeCardWidget :dataset="scheme" :loading="schemeLoading" hide-title />
     </template>
   </div>
 </template>

+ 27 - 6
src/components/ReportSchemeEdit.vue

@@ -32,7 +32,6 @@ const getModel = (item: Partial<ReportSchemeItemModel> = {}): ReportSchemeItemMo
 };
 
 const dataValidValidator = (_rule, value: number, callback): Rule['validator'] => {
-  console.log(_rule, value, 'count');
   return model.content?.filter(t => t.name)?.length || model.descriptions?.filter(t => t?.name)?.length
          ? Promise.resolve()
          : Promise.reject();
@@ -83,11 +82,11 @@ const { data: categoryOptions, loading: categoryLoading } = useRequest(
   if ( !model.category ) model.category = data[ 0 ]?.value;
 });
 
-const { data: scheme, loading: schemeLoading } = useWatcher(
-  () => searchSchemeMethod(model),
-  [ () => model.category, () => model.name ],
+const { data: scheme, loading: schemeLoading, send: search } = useWatcher(
+  (keyword) => searchSchemeMethod(keyword, model),
+  [ () => model.category ],
   {
-    middleware: (_, next) => { if ( model.name && model.category ) next(); },
+    middleware: (_, next) => { if ( model.category ) next(); },
   },
 );
 
@@ -172,6 +171,20 @@ onBeforeMount(() => {
 function onFinishFailed(e) {
   Message.warning(`请补充完整!`);
 }
+
+function selectSchemeHandle(name: string) {
+  const data = scheme.value.find((s) => s.label === name);
+  Object.keys(data ?? {}).forEach((key: string) => {
+    const value = data[ key ];
+    if ( Array.isArray(value) ) {
+      model[ key ] = [ ...value ];
+    } else if ( value && typeof value === 'object' ) {
+      model[ key ] = { ...value };
+    } else {
+      model[ key ] = value;
+    }
+  });
+}
 </script>
 <template>
   <a-form
@@ -187,7 +200,15 @@ function onFinishFailed(e) {
       />
     </a-form-item>
     <a-form-item label="方案名称" v-bind="validateInfos.name">
-      <a-input v-model:value.trim="model.name" placeholder="方案名称" />
+      <a-select
+        show-search placeholder="方案名称"
+        :options="scheme" :loading="schemeLoading"
+        :field-names="{label: 'label', value: 'label'}"
+        v-model:value="model.name" @update:value="selectSchemeHandle"
+        allow-clear @deselect="model.name = void 0;"
+        @search="search" @dropdownVisibleChange="$event && search('');"
+        not-found-content="请输入方案名称"
+      ></a-select>
     </a-form-item>
     <a-form-item name="name">
       <template #label>

+ 1 - 1
src/components/ReportSchemePreview.vue

@@ -79,7 +79,7 @@ const stopPlay = () => {
   height: 296px;
 
   :deep(.ant-image),
-  video, {
+  video {
     margin: auto;
     height: 100%;
     max-height: 296px;

+ 1 - 1
src/components/TagEdit.vue

@@ -37,7 +37,7 @@ const formProps = reactive<VxeFormProps<FormModel>>({
           loading: tagsLoading,
           options: computed(() => tags.value.data),
           optionProps: { value: 'id', label: 'name' },
-          clearable: true, multiple: true, filterable: true,
+          clearable: true, filterable: true,
         },
       },
     },

+ 2 - 2
src/components/UserEdit.vue

@@ -72,7 +72,7 @@ const formProps = reactive<VxeFormProps<FormModel>>({
     password: [
       { required: !props.data?.userId, validator: 'ValidPassword' },
     ],
-    roles: [
+    roleIds: [
       { required: true, message: '请选择角色' },
     ],
     nickName: [
@@ -81,7 +81,7 @@ const formProps = reactive<VxeFormProps<FormModel>>({
     deptId: [
       { required: true, message: '请选择医院 / 科室' },
     ],
-    工号: [
+    jobnumber: [
       { required: true, message: '请输入工号' },
     ],
     phonenumber: [

+ 7 - 6
src/components/UserPassword.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts">
-import type { UserModel }                           from '@/model/system.model';
-import { editRoleMethod, updateUserPasswordMethod } from '@/request/api/system.api';
-import { useRequest }                               from 'alova/client';
+import type { UserModel }                      from '@/model/system.model';
+import { updateUserPasswordMethod }            from '@/request/api/system.api';
+import { useRequest }                          from 'alova/client';
 import type { VxeFormListeners, VxeFormProps } from 'vxe-pc-ui';
 
 
@@ -15,9 +15,10 @@ const props = defineProps<{ data: UserModel }>();
 const emits = defineEmits<{
   submit: [],
 }>();
-const { loading: submitting, send: submit } = useRequest(updateUserPasswordMethod, { immediate: false }).onSuccess(({ data }) => {
-  emits('submit');
-});
+const { loading: submitting, send: submit } = useRequest(updateUserPasswordMethod, { immediate: false }).onSuccess(
+  ({ data }) => {
+    emits('submit');
+  });
 const formProps = reactive<VxeFormProps<FormModel>>({
   titleWidth: 100,
   titleAlign: 'right',

+ 13 - 5
src/components/UserPreview.vue

@@ -4,16 +4,24 @@ import { userMethod }     from '@/request/api/system.api';
 import { useRequest }     from 'alova/client';
 
 
-const props = defineProps<{ data: UserModel }>();
-const { data, loading } = useRequest(() => userMethod(props.data), { initialData: props.data });
-
+const props = withDefaults(defineProps<{ data: UserModel, request?: boolean; }>(), {
+  request: true,
+});
+const { data, loading } = useRequest(
+  () => userMethod(props.data),
+  {
+    initialData: props.data,
+    middleware: (_, next) => { if ( props.request ) next(); },
+  },
+);
 </script>
 <template>
   <a-spin :spinning="loading">
     <a-descriptions bordered>
       <a-descriptions-item label="系统账号">{{ data.userId }}</a-descriptions-item>
-      <a-descriptions-item label="角色" :span="2">{{ data.roles?.map(t => t.roleName)?.join(', ') }}</a-descriptions-item>
-      <a-descriptions-item label="医院 / 科室" :span="2">{{ data['dept']?.deptName }}</a-descriptions-item>
+      <a-descriptions-item label="角色" :span="2">{{ data.roles?.map(t => t.roleName)?.join(', ') }}
+      </a-descriptions-item>
+      <a-descriptions-item label="医院 / 科室" :span="2">{{ data[ 'dept' ]?.deptName }}</a-descriptions-item>
       <a-descriptions-item label="工号">{{ data.jobnumber }}</a-descriptions-item>
       <a-descriptions-item label="姓名">{{ data.nickName }}</a-descriptions-item>
       <a-descriptions-item label="手机号码">{{ data.phonenumber }}</a-descriptions-item>

+ 0 - 3
src/libs/v-select-page/RemoteSelect.vue

@@ -26,12 +26,10 @@ const [ modelValue, modifiers ] = defineModel<any, 'complex'>('value', {
   get(v: any) {
     if ( !v || !v?.length ) return [];
     if ( !Array.isArray(v) ) v = [ v ];
-    console.log('modelValue-->get:v', v);
 
     return v;
   },
   set(v: SelectPageKey[]) {
-    console.log('modelValue-->set:v', v);
     return v[ 0 ];
   },
 });
@@ -47,7 +45,6 @@ async function fetchData(data: PageParameters, callback: FetchDataCallback) {
 }
 
 async function fetchSelectedData(keys: SelectPageKey[], callback: FetchSelectedDataCallback) {
-  console.log(keys, 'fetchSelectedData-->');
   if ( attrs.multiple ) {
     callback(keys.map(key => (
       {

+ 3 - 2
src/libs/vxe/index.ts

@@ -1,6 +1,7 @@
 import { createVxe } from './plugin';
-import './validator'
-import './formatter'
+import './validator';
+import './formatter';
+
 
 const vxe = createVxe();
 export default vxe;

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

@@ -1,9 +1,9 @@
 import type { App } from 'vue';
 
 import {
-  VxeUI,
   VxeButton,
   VxeButtonGroup,
+  VxeDrawer,
   VxeForm,
   VxeFormDesign,
   VxeFormGather,
@@ -11,21 +11,19 @@ import {
   VxeFormView,
   VxeIcon,
   VxeInput,
+  VxeList,
   VxeLoading,
+  VxeModal,
   VxePager,
   VxeRadio,
   VxeRadioGroup,
   VxeSelect,
   VxeSwitch,
   VxeTooltip,
-  VxeTreeSelect,
   VxeTree,
-
-  VxeList,
-
-  VxeModal,
-  VxeDrawer,
-}           from 'vxe-pc-ui';
+  VxeTreeSelect,
+  VxeUI,
+} from 'vxe-pc-ui';
 
 import zhCN from 'vxe-pc-ui/lib/language/zh-CN';
 

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

@@ -6,6 +6,11 @@ export interface PeopleModel {
   age?: number;
 }
 
+export interface PatientTagModel {
+  id: string;
+  tags: { id: string, name: string }[];
+}
+
 export function transformAccount(data: any): PeopleModel {
   return {
     id: data?.userId,

+ 3 - 2
src/model/report.model.ts

@@ -4,9 +4,10 @@ import dayjs, { type Dayjs } from 'dayjs';
 export interface ReportModel {
   id: string;
   time?: string;
+  analysable?: boolean;
   scheme?: { show: boolean; editable?: boolean };
   [ key: string ]: any;
-};
+}
 
 export interface ReportSchemeModel {
   id: string;
@@ -33,7 +34,7 @@ export function transformReportSchemeItem(category: string, data: Record<string,
   return {
     category,
     id: data.id,
-    name: data.name,
+    name: data.name || void 0,
     type, content: data.items,
     descriptions: data.attrs,
   };

+ 6 - 3
src/model/system.model.ts

@@ -28,9 +28,12 @@ export interface UserModel {
 export type UserQuery = Partial<UserModel> & { branch?: string };
 
 export interface TagQuery {
-  tagId: string;
-  name: string;
-  status: '0' | '1';
+  tagId?: string;
+  name?: string;
+  status?: '0' | '1';
+
+  parentId?: string;
+  parentIds?: string[] | string;
 }
 
 export interface TagModel {

+ 65 - 9
src/pages/index.vue

@@ -1,14 +1,22 @@
 <script setup lang="ts">
+import UserPassword        from '@/components/UserPassword.vue';
+import UserPreview         from '@/components/UserPreview.vue';
+import type { UserModel }  from '@/model/system.model';
+import { accountMethod }   from '@/request/api/account.api';
+import { userMethod }      from '@/request/api/system.api';
 import { useAccountStore } from '@/stores';
 import { LogoutOutlined }  from '@ant-design/icons-vue';
 import { useElementSize }  from '@vueuse/core';
+import { useRequest }      from 'alova/client';
+import { notification }    from 'ant-design-vue';
 
-import { storeToRefs } from 'pinia';
+import { storeToRefs }      from 'pinia';
+import { VxeButton, VxeUI } from 'vxe-pc-ui';
 
 
 const title = import.meta.env.SIX_TITLE;
 const Account = useAccountStore();
-const { local, menus } = storeToRefs(Account);
+const { local, menus, token } = storeToRefs(Account);
 
 const titleRef = ref();
 const { width } = useElementSize(titleRef, void 0, { box: 'border-box' });
@@ -18,14 +26,8 @@ const selectedKeys = computed(() => {
   return [ router.currentRoute.value.path ];
 });
 
-watchEffect(() => {
-  const path = router.currentRoute.value.path;
-  console.log(path, '120-->');
-});
-
 const selectMenuItem = ({ item, key, keyPath }) => {
   router.push({ path: key }).then();
-  console.log({ item, key, keyPath });
 };
 
 
@@ -35,6 +37,60 @@ function handleLogout() {
 }
 
 const current = ref<string[]>([]);
+
+
+const { loading: userLoading, send: loadUser } = useRequest(
+  () => accountMethod(token.value),
+  { immediate: false },
+);
+
+async function openUserPreview() {
+  if ( userLoading.value ) return;
+  const { user: model } = await loadUser();
+  VxeUI.drawer.open({
+    title: `用户信息`,
+    maskClosable: true,
+    escClosable: true,
+    slots: {
+      default() {
+        return h(UserPreview, <any> { data: model, request: false });
+      },
+      corner() {
+        return h(VxeButton, {
+          content: '修改登录密码', size: 'mini', onClick() {
+            updateUserPassword(model);
+          },
+        });
+      },
+    },
+  });
+}
+
+function updateUserPassword(model: UserModel, index?: number) {
+  const { userName } = model;
+  VxeUI.modal.open({
+    title: `重置 ${ userName } 登录密码`,
+    escClosable: true,
+    destroyOnClose: true,
+    id: `user-edit-password-modal`,
+    remember: true,
+    storage: true,
+    mask: false,
+    slots: {
+      default() {
+        return h(UserPassword, <any> {
+          data: model, onSubmit(data?: UserModel) {
+            notification.success({
+              message: `重置用户: ${ userName } 的登录密码`,
+              description: '操作成功',
+            });
+            VxeUI.modal.close(`user-edit-password-modal`);
+          },
+        });
+      },
+    },
+  });
+}
 </script>
 <template>
   <div class="page-container flex flex-col h-vh w-vw">
@@ -48,7 +104,7 @@ const current = ref<string[]>([]);
       </div>
       <div class="account">
         <a-avatar size="large" :src="local?.avatar">{{ local?.name?.slice(0, 1) }}</a-avatar>
-        <span class="m-x-2">{{ local?.name }}</span>
+        <span class="m-x-2 cursor-pointer" @click="openUserPreview()">{{ local?.name }}</span>
         <LogoutOutlined class="cursor-pointer" @click="handleLogout" />
       </div>
     </header>

+ 6 - 5
src/pages/index/patient/history.vue

@@ -1,10 +1,10 @@
 <script setup lang="ts">
 import ReportHistoryPreview                      from '@/components/ReportHistoryPreview.vue';
 import type { PatientQuery, PatientReportModel } from '@/model';
-import { patientsHistoryMethod }     from '@/request/api/patient.api';
+import { patientsHistoryMethod }                 from '@/request/api/patient.api';
 import { tagsSearchMethod }                      from '@/request/api/system.api';
-import { usePagination, useRequest } from 'alova/client';
-import { h }                         from 'vue';
+import { usePagination, useRequest }             from 'alova/client';
+import { h }                                     from 'vue';
 
 import {
   VxeButton,
@@ -16,6 +16,7 @@ import {
   VxeUI,
 } from 'vxe-pc-ui';
 
+
 const { data: tags, loading: tagsLoading } = useRequest(tagsSearchMethod, { initialData: { total: 0, data: [] } });
 
 const model = shallowRef<PatientQuery>();
@@ -26,7 +27,7 @@ const searchFormProps = reactive<VxeFormProps<PatientQuery>>({
   data: {},
   items: [
     { field: 'patientName', title: '患者姓名', span: 6, itemRender: { name: 'VxeInput' } },
-    { field: 'phone', title: '手机号码', span: 6, itemRender: { name: 'VxeInput', props: { maxLength: 11 } } },
+    // { 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: {
@@ -41,7 +42,7 @@ const searchFormProps = reactive<VxeFormProps<PatientQuery>>({
       },
     },
     {
-      field: 'tagIds', title: '上级标签', span: 6, itemRender: {
+      field: 'tagIds', title: '标签', span: 6, itemRender: {
         name: 'VxeSelect',
         props: {
           loading: tagsLoading,

+ 1 - 1
src/pages/index/patient/room@aside.vue

@@ -31,7 +31,7 @@ function handle(model: PatientReportModel) {
 </script>
 <template>
   <div class="page-aside flex flex-col">
-    <div class="page-aside__header flex-none">
+    <div class="page-aside__header flex-none sticky top-0 z-11">
       <a-input-search
         allow-clear placeholder="输入姓名搜索"
         v-model:value="keyword"

+ 72 - 21
src/pages/index/patient/room@default.vue

@@ -3,26 +3,35 @@ import ReportAnalysisEdit   from '@/components/ReportAnalysisEdit.vue';
 import ReportHistoryPreview from '@/components/ReportHistoryPreview.vue';
 import type { ReportModel } from '@/model';
 
-import { patientMethod }                                                        from '@/request/api/patient.api';
-import { confirmSchemeMethod, reportMethod, reportSchemeMethod, reportsMethod } from '@/request/api/report.api';
+import { patientMethod, patientTags } from '@/request/api/patient.api';
+import {
+  confirmSchemeMethod,
+  indicatorByReportIdMethod,
+  reportMethod,
+  reportSchemeMethod,
+  reportsMethod,
+}                                     from '@/request/api/report.api';
 
-import PatientCardWidget          from '@/widgets/PatientCardWidget.vue';
-import PatientTagWidget           from '@/widgets/PatientTagWidget.vue';
-import ReportCardWidget           from '@/widgets/ReportCardWidget.vue';
-import ReportSchemeCardWidget     from '@/widgets/ReportSchemeCardWidget.vue';
-import { useElementSize }         from '@vueuse/core';
-import { useRouteQuery }          from '@vueuse/router';
-import { invalidateCache }        from 'alova';
-import { useRequest, useWatcher } from 'alova/client';
-import { h }                      from 'vue';
-import { useRouter }              from 'vue-router';
-import { RecycleScroller }        from 'vue-virtual-scroller';
-import { VxeUI }                  from 'vxe-pc-ui';
+import PatientCardWidget                      from '@/widgets/PatientCardWidget.vue';
+import PatientTagWidget                       from '@/widgets/PatientTagWidget.vue';
+import ReportCardWidget                       from '@/widgets/ReportCardWidget.vue';
+import ReportSchemeCardWidget                 from '@/widgets/ReportSchemeCardWidget.vue';
+import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons-vue';
+import { useElementSize }                     from '@vueuse/core';
+import { useRouteQuery }                      from '@vueuse/router';
+import { invalidateCache }                    from 'alova';
+import { useRequest, useWatcher }             from 'alova/client';
+import { h }                                  from 'vue';
+import { useRouter }                          from 'vue-router';
+import { RecycleScroller }                    from 'vue-virtual-scroller';
+import { VxeUI }                              from 'vxe-pc-ui';
 
 
 const patientId = useRouteQuery<string>('patientId');
 const reportId = useRouteQuery<string>('reportId');
 
+const reportCollapsed = ref(true);
+const schemeCollapsed = ref(false);
 const { data: patient, loading: patientLoading } = useWatcher(
   () => patientMethod(patientId.value),
   [ patientId ],
@@ -30,6 +39,19 @@ const { data: patient, loading: patientLoading } = useWatcher(
     initialData: {}, immediate: true,
     middleware: (_, next) => { if ( patientId.value ) next(); },
   },
+).onSuccess(() => {
+  reportCollapsed.value = true;
+  schemeCollapsed.value = false;
+  unref(patientCardRef.value?.elementTarget)?.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' });
+});
+
+const { data: tags, loading: tagsLoading, send: loadTags } = useWatcher(
+  () => patientTags(patientId.value),
+  [ patientId ],
+  {
+    initialData: [], immediate: true,
+    middleware: (_, next) => { if ( patientId.value ) next(); },
+  },
 );
 
 const { data: reports, loading: reportsLoading } = useWatcher(
@@ -50,6 +72,15 @@ const { data: report, loading: reportLoading, send: loadReport } = useWatcher(
   },
 );
 
+const { data: indicator, loading: indicatorLoading } = useWatcher(
+  () => indicatorByReportIdMethod(reportId.value!),
+  [ reportId ],
+  {
+    initialData: [], immediate: true,
+    middleware: (_, next) => { if ( reportId.value ) next(); },
+  },
+);
+
 const { data: scheme, loading: schemeLoading, send: schemeRefresh } = useWatcher(
   () => reportSchemeMethod(reportId.value!),
   [ reportId ],
@@ -142,7 +173,7 @@ function openAnalysisEditHandle() {
   });
 }
 
-const patientCardRef = ref<HTMLElement>(null);
+const patientCardRef = ref<HTMLElement & { elementTarget: HTMLElement }>();
 const { height } = useElementSize(patientCardRef);
 </script>
 <template>
@@ -150,8 +181,8 @@ const { height } = useElementSize(patientCardRef);
     <div class="page-container-main flex-auto flex flex-col">
       <div class="area">
         <PatientCardWidget ref="patientCardRef" :dataset="patient" :loading="patientLoading" />
-        <ReportCardWidget :dataset="report" :loading="reportLoading" collapsible :collapsed="true">
-          <template #analysis>
+        <ReportCardWidget :dataset="report" :loading="reportLoading" collapsible v-model:collapsed="reportCollapsed">
+          <template #analysis v-if="report.analysable">
             <a-button
               type="primary" size="small"
               :loading="analysisReportPreviewOpening"
@@ -160,11 +191,26 @@ const { height } = useElementSize(patientCardRef);
               信息采集
             </a-button>
           </template>
+          <a-card class="card background no-bordered-8" size="small" title="指标信息" :loading="indicatorLoading">
+            <a-descriptions v-if="indicator?.length" :column="3" size="small">
+              <a-descriptions-item v-for="item in indicator" :key="item.id" :label="item.name">
+                <div v-if="item.value">
+                  <span>{{ item.value }}</span>
+                  <span v-if="item.unit">{{ item.unit }}</span>
+                  <ArrowUpOutlined class="icon" v-if="item.records?.[0]?.abnormal === 1" />
+                  <ArrowDownOutlined class="icon" v-if="item.records?.[0]?.abnormal === -1" />
+                </div>
+                <span v-else>-</span>
+              </a-descriptions-item>
+            </a-descriptions>
+            <div v-else style="padding-bottom: 8px;">暂无数据</div>
+          </a-card>
         </ReportCardWidget>
       </div>
       <div class="area flex-auto" v-if="report?.scheme?.show">
         <ReportSchemeCardWidget
           :dataset="scheme" :loading="schemeLoading" :editable="report.scheme.editable"
+          v-model:collapsed="schemeCollapsed"
           @refresh="schemeRefreshHandle()"
         >
           <template #footer v-if="report?.scheme?.editable">
@@ -188,8 +234,13 @@ const { height } = useElementSize(patientCardRef);
       </a-result>
     </div>
     <div class="page-container-aside flex-none flex flex-col">
-      <div class="area card flex-auto flex flex-col">
-        <PatientTagWidget :style="{height: `${height}px`}" :patient="patient" editable />
+      <PatientTagWidget
+        style="min-height: 112px;flex: none"
+        :style="{height: `${height}px`}"
+        :dataset="tags" :loading="tagsLoading"
+        editable @refresh="loadTags()"
+      />
+      <div class="flex-auto flex flex-col overflow-hidden">
         <div class="card__header flex justify-between items-center">
           <div class="card__title flex-none">
             <span>报告记录</span>
@@ -202,7 +253,7 @@ const { height } = useElementSize(patientCardRef);
             健康档案
           </a-button>
         </div>
-        <div class="flex-auto mt-16px" style="background-color: #f1f1f1;">
+        <div class="flex-auto overflow-scroll" style="background-color: #f1f1f1;">
           <a-empty v-if="!reportsLoading && !reports.length" />
           <RecycleScroller v-else ref="scroller" class="record-scroller" key-field="id"
                            :items="reports" :item-size="40"
@@ -226,10 +277,10 @@ const { height } = useElementSize(patientCardRef);
 
 .page-container {
   background-color: #dfe0eb;
+  max-height: var(--page-main-container);
 }
 
 .page-container-main {
-  max-height: var(--page-main-container);
   overflow-y: auto;
 
   > .area {

+ 3 - 1
src/pages/index/system/role.vue

@@ -176,7 +176,6 @@ function deleteRole(model: RoleModel, index: number) {
 }
 
 function editRole(model?: RoleModel, index?: number) {
-  console.log('editRole', model, index);
   VxeUI.modal.open({
     title: model?.roleId ? `修改角色` : `新增角色`,
     escClosable: true,
@@ -184,6 +183,9 @@ function editRole(model?: RoleModel, index?: number) {
     id: `role-edit-modal`,
     remember: true,
     storage: true,
+    position: {
+      top: Math.min(100,window.innerHeight * 0.15),
+    },
     width: 500,
     minHeight: 600,
     slots: {

+ 3 - 6
src/pages/index/system/tag.vue

@@ -25,10 +25,7 @@ const searchFormProps = reactive<VxeFormProps<TagQuery>>({
   titleWidth: 100,
   titleAlign: 'right',
   titleColon: true,
-  data: {
-    name: '',
-    status: void 0,
-  },
+  data: {},
   items: [
     { field: 'name', title: '标签名称', span: 6, itemRender: { name: 'VxeInput' } },
     {
@@ -39,13 +36,13 @@ const searchFormProps = reactive<VxeFormProps<TagQuery>>({
     },
     { field: 'status', title: '创建者', span: 6, itemRender: { name: 'VxeInput' } },
     {
-      field: 'parentId', title: '上级标签', span: 6, itemRender: {
+      field: 'parentIds', title: '上级标签', span: 6, itemRender: {
         name: 'VxeSelect',
         props: {
           loading: tagsLoading,
           options: computed(() => tags.value.data),
           optionProps: { value: 'id', label: 'name' },
-          clearable: true, multiple: true, filterable: true,
+          clearable: true, multiple: false, filterable: true,
         },
       },
     },

+ 7 - 6
src/pages/index/system/user.vue

@@ -5,8 +5,8 @@ import UserPreview                        from '@/components/UserPreview.vue';
 import UserQRCode                         from '@/components/UserQRCode.vue';
 import { type UserModel, type UserQuery } from '@/model/system.model';
 
-import { branchMethod, deleteUserMethod, usersMethod } from '@/request/api/system.api';
-import { usePagination, useRequest }                   from 'alova/client';
+import { branchMethod, deleteUserMethod, userMethod, usersMethod } from '@/request/api/system.api';
+import { usePagination, useRequest }                               from 'alova/client';
 import { notification }                                from 'ant-design-vue';
 
 import {
@@ -107,9 +107,10 @@ const gridOptions = reactive<VxeGridProps<UserModel>>({
         events: {
           click({ row, rowIndex }, { name }) {
             let method;
-            if ( name === 'editUser' ) { method = editUser; }
-            else if ( name === 'deleteUser' ) { method = deleteUser; }
-            else if ( name === 'QRCode' ) { method = QRCode; }
+            if ( name === 'editUser' ) { method = editUser; } else if ( name === 'deleteUser' ) {
+              method
+                = deleteUser;
+            } else if ( name === 'QRCode' ) { method = QRCode; }
             method?.(row, rowIndex);
           },
         },
@@ -248,7 +249,7 @@ function QRCode(model: UserModel) {
         });
       },
     },
-  })
+  });
 }
 </script>
 <template>

+ 3 - 0
src/request/alova.ts

@@ -1,3 +1,4 @@
+import router           from '@/router';
 import { createAlova }  from 'alova';
 import fetchAdapter     from 'alova/fetch';
 import VueHook          from 'alova/vue';
@@ -33,6 +34,8 @@ const request = createAlova({
                  : total != null && Array.isArray(rows)
                    ? { total, data: rows }
                    : data;
+        } else if ( code === 401 ) {
+          return await router.replace('/login');
         } else if ( warn ) {
           ignoreException = true;
           notification.warning({

+ 3 - 3
src/request/api/account.api.ts

@@ -14,6 +14,8 @@ export interface AccountModel {
   token: string;
   local: PeopleModel;
   menus?: Menu[];
+
+  user?: Record<string, any>;
 }
 
 
@@ -29,10 +31,10 @@ export function accountMethod(token: string) {
   return request.Get<AccountModel>(`/prod-api/system/user/getInfo`, {
     headers: { Authorization: token },
     transform(data: any, headers) {
-      console.log('accountMethod', data);
       return {
         token,
         local: transformAccount(data.user),
+        user: data.user,
       };
     },
     meta: { unconvert: true },
@@ -64,8 +66,6 @@ export function getMenusMethod(account: AccountModel) {
   return request.Get<AccountModel, any[]>(`/prod-api/system/menu/getRouters`, {
     headers: { Authorization: account.token },
     transform(data) {
-      console.log('菜单-->', data, transformMenus(data), [ ...routes ]);
-
       return { ...account, menus: transformMenus(data) };
     },
   });

+ 18 - 6
src/request/api/patient.api.ts

@@ -1,5 +1,5 @@
-import { type List, type PatientModel, type PatientQuery, type PatientReportModel, transformPatient } from '@/model';
-import request                                                                                        from '@/request/alova';
+import { type List, type PatientModel, type PatientQuery, type PatientReportModel, type PatientTagModel, transformPatient } from '@/model';
+import request                                                                                                              from '@/request/alova';
 
 
 export function patientsHistoryMethod(page: number, size: number, query?: PatientQuery) {
@@ -45,9 +45,21 @@ export function patientMethod(id: string) {
   });
 }
 
-export function patientMark(id: string, data: string[]) {
-  return request.Get(`/fdhb-pc/patientInfoManage/updatePatientTag`, {
-    params: { patientId: id, tagIds: data },
-    cacheFor: null,
+export function patientTags(id: string) {
+  return request.Get<PatientTagModel, any[]>(`/fdhb-pc/patientInfoManage/getPatientTag`, {
+    hitSource: 'update-tags',
+    params: { patientId: id },
+    transform(data, headers) {
+      return {
+        id,
+        tags: data.map(({ id, name }) => (
+          { id, name }
+        )),
+      };
+    },
   });
 }
+
+export function updatePatientTagsMethod(id: string, data: string[]) {
+  return request.Post(`/fdhb-pc/patientInfoManage/updatePatientTag`, { patientId: id, tagIds: data }, { name: 'update-tags' });
+}

+ 19 - 6
src/request/api/report.api.ts

@@ -13,6 +13,7 @@ import {
 } from '@/model';
 
 import request from '@/request/alova';
+import dayjs   from 'dayjs';
 
 
 export function reportsMethod(patientId: string) {
@@ -39,7 +40,7 @@ export function reportMethod(id: string) {
         ...data,
         id: data.healthAnalysisReportId,
         time: data.reportTime,
-
+        analysable: data.reportTime === dayjs().format('YYYY年MM月DD日'),
         scheme: {
           show: data.isHaveConditioningProgram === 'Y',
           editable: data.isConfirmConditioningProgram !== 'Y',
@@ -59,11 +60,20 @@ export function reportSchemeMethod(reportId: string) {
   });
 }
 
-export function searchSchemeMethod(query?: Partial<ReportSchemeItemModel>) {
+export function searchSchemeMethod(keyword?: string, query?: Partial<ReportSchemeItemModel>) {
   return request.Get(`/fdhb-pc/analysisManage/condProgramQuery`, {
-    params: { type: query?.category, name: query?.name },
+    params: { type: query?.category, name: keyword ?? query?.name ?? '' },
     transform(data: any[], headers) {
-      return data?.map((group: any) => transformReportSchemeItem(query?.category!, group));
+      const groups = data.map((group: any, index: number) => {
+        const { id, ...scheme } = transformReportSchemeItem(query?.category!, group);
+        return {
+          ...scheme,
+          name: scheme.name ?? `预设 ${ index + 1 }`,
+          label: scheme.name ?? `预设 ${ index + 1 }`,
+        };
+      });
+      if ( keyword && !groups.find(group => group.label === keyword) ) groups.unshift({ name: keyword, label: keyword });
+      return groups;
     },
   });
 }
@@ -116,6 +126,7 @@ export function confirmSchemeMethod(reportId: string) {
  */
 export function indicatorByReportIdMethod(reportId: string, filter = true) {
   return request.Get<ReportIndicatorModel[], any[]>(`fdhb-pc/analysisManage/getLast7Day`, {
+    hitSource: 'update-indicator',
     params: { healthAnalysisReportId: reportId },
     transform(data, headers) {
       const indicators = transformIndicators(data);
@@ -131,6 +142,7 @@ export function indicatorByReportIdMethod(reportId: string, filter = true) {
  */
 export function indicatorByPatientIdMethod(patientId: string, filter = true) {
   return request.Get<ReportIndicatorModel[], any[]>(`/fdhb-pc/patientQuota/getCurQuovalByPatId`, {
+    hitSource: 'update-indicator',
     params: { patientId },
     transform(data, headers) {
       const indicators = transformIndicators(data);
@@ -146,6 +158,7 @@ export function indicatorByPatientIdMethod(patientId: string, filter = true) {
  */
 export function indicatorUpdateRecordsMethod(patientId: string, filter = true) {
   return request.Get<ReportIndicatorModel[], any[]>(`/fdhb-pc/patientQuota/getQuovalRecord`, {
+    hitSource: 'update-indicator',
     params: { patientId },
     transform(data, headers) {
       const indicators = transformIndicators(data);
@@ -166,8 +179,8 @@ export function updateIndicatorByPatientIdMethod(patientId: string, model: Recor
       quotaVal,
     }
   )).filter(item => !!item.quotaVal);
-  return request.Post(`/fdhb-pc/patientQuota/updateQuovalByPatId`, data, {
-    params: { patientId },
+  return request.Post(`/fdhb-pc/patientQuota/updateQuovalByPatId`, { patientId, updatePatientQuotaVOS: data }, {
+    name: 'update-indicator',
   });
 }
 

+ 3 - 0
src/request/api/system.api.ts

@@ -104,6 +104,9 @@ export function getRoleMenusMethod(data?: Partial<RoleModel>) {
 }
 
 export function tagsMethod(page: number, size: number, query?: TagQuery) {
+  if ( Array.isArray(query?.parentIds) && query.parentIds.length === 1 ) {
+    query.parentId = query.parentIds[ 0 ];
+  } else if ( query?.parentIds ) query.parentId = query?.parentIds;
   return request.Post<List<TagModel>>(`/fdhb-pc/tagManage/pageTag`, query ?? {}, {
     hitSource: /tag$/,
     params: { pageNum: page, pageSize: size },

+ 5 - 2
src/router/index.ts

@@ -27,8 +27,11 @@ const router = createRouter({
         { path: '', redirect: '/patient/history' },
       ],
       beforeEnter(to, from, next) {
-        if ( useAccountStore(pinia).token ) next()
-        else next({ path: '/login' });
+        if ( useAccountStore(pinia).token ) {
+          next();
+        } else {
+          next({ path: '/login' });
+        }
       },
     },
   ],

+ 4 - 3
src/stores/index.ts

@@ -1,8 +1,9 @@
-import { createPinia } from 'pinia';
+import { createPinia }          from 'pinia';
 import { createPersistedState } from 'pinia-plugin-persistedstate';
 
+
 const persistedState = createPersistedState({
-  key: (storeKey) => `${import.meta.env.SIX_APP_NAME ?? '@six/unknown'}:${storeKey}`,
+  key: (storeKey) => `${ import.meta.env.SIX_APP_NAME ?? '@six/unknown' }:${ storeKey }`,
   auto: true,
   debug: import.meta.env.DEV,
 });
@@ -11,4 +12,4 @@ const pinia = createPinia();
 pinia.use(persistedState);
 
 export default pinia;
-export { useAccountStore } from './account.store';
+export { useAccountStore }      from './account.store';

+ 24 - 0
src/themes/fix.scss

@@ -77,3 +77,27 @@
 .ant-descriptions-item-label {
   align-items: center !important;
 }
+
+/* 设置滚动条的样式 */
+::-webkit-scrollbar {
+  width: 8px;
+  height: 8px;
+}
+
+/* 滚动槽 */
+::-webkit-scrollbar-track {
+  -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.2);
+  border-radius: 8px;
+}
+
+/* 滚动条滑块 */
+::-webkit-scrollbar-thumb {
+  border-radius: 8px;
+  background: #bbb;
+  -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.25);
+}
+
+/* 非激活窗口 */
+::-webkit-scrollbar-thumb:window-inactive {
+  background: rgba(87, 87, 87, 0.4);
+}

+ 21 - 1
src/themes/report-card.scss

@@ -7,6 +7,18 @@
       padding-bottom: 0;
     }
   }
+
+  &.no-bordered-8 {
+    :deep(.ant-card-body) {
+      padding-top: 16px;
+      padding-bottom: 8px;
+    }
+  }
+
+  &.background {
+    background-color: #f7f7f7;
+  }
+
   &__title {
     display: flex;
     align-items: center;
@@ -23,7 +35,7 @@
     }
   }
 
-  &__header{
+  &__header {
     padding: 12px;
     background-color: #fff;
     top: 0;
@@ -68,3 +80,11 @@
   color: #b22222ff;
   transform: translateY(2px);
 }
+
+.phrase {
+  & + .phrase::before {
+    content: "、";
+    margin-left: 2px;
+    margin-right: 4px;
+  }
+}

+ 5 - 6
src/widgets/AnalysisReportWidget.vue

@@ -1,8 +1,8 @@
 <script setup lang="ts">
-import type { ReportModel }                   from '@/model';
-import { indicatorByReportIdMethod, reportMethod }      from '@/request/api/report.api';
-import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons-vue';
-import { useWatcher }                         from 'alova/client';
+import type { ReportModel }                        from '@/model';
+import { indicatorByReportIdMethod, reportMethod } from '@/request/api/report.api';
+import { ArrowDownOutlined, ArrowUpOutlined }      from '@ant-design/icons-vue';
+import { useWatcher }                              from 'alova/client';
 
 
 const props = defineProps<{ report?: ReportModel }>();
@@ -17,7 +17,7 @@ const { data: report, loading: reportLoading } = useWatcher(
 const { data: indicator, loading: indicatorLoading } = useWatcher(
   () => indicatorByReportIdMethod(reportId.value!),
   [ reportId ],
-  { initialData: [] , immediate: true },
+  { initialData: [], immediate: true },
 );
 </script>
 <template>
@@ -176,7 +176,6 @@ const { data: indicator, loading: indicatorLoading } = useWatcher(
   </div>
 </template>
 <style scoped lang="scss">
-
 .card {
   margin-bottom: 12px;
 

+ 5 - 1
src/widgets/PatientCardWidget.vue

@@ -26,9 +26,13 @@ const womenSpecialPeriod = useDictionary(
   'women_special_period',
   computed(() => props.dataset.womenSpecialPeriod),
 );
+const containerRef = ref<HTMLElement>();
+defineExpose({
+  elementTarget: containerRef,
+});
 </script>
 <template>
-  <div class="card patient-card">
+  <div class="card patient-card" ref="containerRef">
     <div class="card__header sticky flex justify-between items-center">
       <div class="card__title">
         <span>{{ props.title }}</span>

+ 88 - 17
src/widgets/PatientTagWidget.vue

@@ -1,39 +1,110 @@
 <script setup lang="ts">
-import type { PatientModel } from '@/model';
-import { patientMark }       from '@/request/api/patient.api';
-import { tagsSearchMethod }  from '@/request/api/system.api';
-import { useRequest }        from 'alova/client';
+import type { PatientTagModel }                       from '@/model';
+import { updatePatientTagsMethod }                    from '@/request/api/patient.api';
+import { tagsSearchMethod }                           from '@/request/api/system.api';
+import { CheckOutlined, CloseOutlined, EditOutlined } from '@ant-design/icons-vue';
+import { useRequest }                                 from 'alova/client';
+import { message as Message }                         from 'ant-design-vue';
+import { h }                                          from 'vue';
 
 
-const { data: tags, loading: tagsLoading } = useRequest(tagsSearchMethod, { initialData: { total: 0, data: [] } });
-const { send } = useRequest((data: string[]) => patientMark(props.patient.id, data), { immediate: false });
-
 const props = defineProps<{
-  patient: Partial<PatientModel>;
+  dataset: PatientTagModel;
+  loading?: boolean;
 
   editable?: boolean;
 }>();
 
-function save({ value }) {
-  console.log(value);
-  send(value);
+const emits = defineEmits<{
+  refresh: [];
+}>();
+const { data: options, loading: optionsLoading, send: loadOptions } = useRequest(
+  tagsSearchMethod,
+  { initialData: { total: 0, data: [] }, immediate: false },
+);
+const { send: save, loading: submitting } = useRequest(
+  (data: string[]) => updatePatientTagsMethod(props.dataset.id, selected.value),
+  { immediate: false },
+).onSuccess(() => {
+  editing.value = false;
+  emits('refresh');
+  Message.success(`标签更新成功`);
+});
+
+const editing = ref(false);
+const selected = ref<string[]>([]);
+
+const selectRef = ref<HTMLSelectElement | null>(null);
+const onSelectVisibleChange = (visible: boolean) => {
+  if ( editing.value && !visible ) {
+    setTimeout(() => editing.value = submitting.value, 20);
+  }
+};
+
+async function edit(value = !editing.value) {
+  if ( value && !options.value.total ) await loadOptions();
+  if ( value ) {
+    selected.value = props.dataset.tags.map(item => item.id);
+    setTimeout(() => { selectRef.value?.focus(); }, 20);
+  }
+  editing.value = value;
 }
 </script>
 <template>
-  <div class="card">
-    <div class="card__header sticky flex justify-between items-center">
+  <div class="card flex flex-col">
+    <div class="card__header flex justify-between items-center">
       <div class="card__title">
         <span>标签</span>
         <a-spin v-if="props.loading" size="small" style="margin-left: 4px;" />
+        <a-space style="margin-left: 8px">
+          <a-tooltip v-if="props.editable" :title="editing ? '取消' : '编辑'">
+            <a-button
+              shape="circle" size="small" :icon="editing ? h(CloseOutlined) : h(EditOutlined)"
+              @click="edit()"
+            />
+          </a-tooltip>
+        </a-space>
       </div>
     </div>
-    <div class="card__content">
-      <vxe-select :options="tags.data" :loading="tagsLoading" :option-props="{value: 'id', label: 'name'}"
-                  @change="save" multiple
-      ></vxe-select>
+    <div class="card__content overflow-auto">
+      <a-space-compact v-if="editing" block size="small" class="tags-edit-select-wrapper">
+        <a-select
+          ref="selectRef" allow-clear autofocus
+          v-model:value="selected" mode="multiple"
+          :options="options.data" :field-names="{ label: 'name', value: 'id'}"
+          :disabled="submitting" :loading="optionsLoading"
+          :open="!optionsLoading" @dropdownVisibleChange="onSelectVisibleChange"
+        />
+        <a-tooltip title="保存">
+          <a-button type="primary" :icon="h(CheckOutlined)" :loading="submitting" @click="save"></a-button>
+        </a-tooltip>
+      </a-space-compact>
+      <a-space v-else wrap>
+        <a-tag v-for="item in props.dataset.tags" :key="item.id" :closable="editing">{{ item.name }}</a-tag>
+      </a-space>
     </div>
   </div>
 </template>
 <style scoped lang="scss">
 @import "@/themes/report-card";
+
+.ant-tag {
+  margin-right: 0;
+}
+
+.tags-edit-select-wrapper {
+  .ant-select {
+    width: calc(100% - 24px);
+    max-height: 48px;
+
+    :deep(.ant-select-selector) {
+      max-height: inherit;
+      overflow-y: auto;
+    }
+  }
+
+  button {
+    height: inherit !important;
+  }
+}
 </style>

+ 2 - 1
src/widgets/ReportAnalysisWidgetTongue.vue

@@ -121,7 +121,8 @@ const exceptionData = computed(() => {
               <div class="flex flex-row">
                 <a-image v-if="panel.splitImage" :src="panel.splitImage" :width="200" :preview="false" />
                 <a-space v-if="panel.attrs" direction="vertical">
-                  <a-tag v-for="v in panel.attrs" color="#cd201f" style="margin-left: 8px;margin-right: 0;">{{ v }}</a-tag>
+                  <a-tag v-for="v in panel.attrs" color="#cd201f" style="margin-left: 8px;margin-right: 0;">{{ v }}
+                  </a-tag>
                 </a-space>
               </div>
             </a-descriptions-item>

+ 167 - 20
src/widgets/ReportCardWidget.vue

@@ -1,7 +1,5 @@
 <script setup lang="ts">
-import type { ReportModel }       from '@/model';
-import ReportAnalysisWidgetFace   from '@/widgets/ReportAnalysisWidgetFace.vue';
-import ReportAnalysisWidgetTongue from '@/widgets/ReportAnalysisWidgetTongue.vue';
+import type { ReportModel } from '@/model';
 
 import { FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons-vue';
 import { h }                                          from 'vue';
@@ -13,9 +11,11 @@ const props = withDefaults(defineProps<{
   title?: string;
 
   collapsible?: boolean;
+  hideTitle?: boolean;
 }>(), {
   title: '健康分析报告',
   collapsible: true,
+  hideTitle: false,
 });
 
 const container = ref<HTMLDivElement | null>(null);
@@ -24,14 +24,32 @@ const collapse = (value: boolean) => {
   collapsed.value = value;
   container.value?.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' });
 };
+
+const tongueExceptionPanelHeight = window.innerHeight * 0.8;
+const openTongueExceptionAnalysis = ref(false);
+const tongueExceptionPanelKeys = ref<string[]>([]);
+const tongueExceptionData = computed(() => {
+  const keys = [ 'tongueColor', 'tongueCoatingColor', 'tongueShape', 'tongueCoating', 'bodyFluid', 'sublingualVein' ];
+  const data: Record<string, string>[] = [];
+  for ( const key of keys ) {
+    const list = props.dataset?.[ key ]?.actualList ?? [];
+    for ( const item of list ) if ( item?.contrast && item.contrast !== 's' ) data.push(item);
+  }
+  tongueExceptionPanelKeys.value = data.map(t => t.actualValue);
+  return data;
+});
+
+defineExpose({
+  elementTarget: container,
+});
 </script>
 <template>
   <div class="card report-card" ref="container">
-    <div class="card__header sticky flex justify-between items-center">
+    <div v-if="!props.hideTitle" class="card__header sticky flex justify-between items-center">
       <div class="card__title">
         <span>{{ props.title }}</span>
         <a-spin v-if="props.loading" size="small" style="margin-left: 4px;" />
-        <a-space style="margin-left: 8px">
+        <a-space style="margin-left: 8px" v-if="collapsible">
           <a-tooltip :title="collapsed ? '展开' : '收起'">
             <a-button
               shape="circle" size="small" :icon="collapsed ? h(FullscreenOutlined) : h(FullscreenExitOutlined)"
@@ -45,14 +63,16 @@ const collapse = (value: boolean) => {
       </div>
     </div>
     <div class="card__content" v-if="props.dataset.id">
-      <a-card class="card no-bordered" size="small" style="background-color: #f7f7f7">
+      <div style="padding: 8px 0;background-color:#fff;" class="sticky top-0 z-10">
+        <span>报告日期:</span>
+        <slot name="select">
+          <span style="margin-right: 8px;">{{ props.dataset.time }}</span>
+        </slot>
+        <slot name="analysis"></slot>
+        <a-spin v-if="props.hideTitle && props.loading" size="small" style="margin-left: 4px;" />
+      </div>
+      <a-card class="card no-bordered background" size="small">
         <a-descriptions :column="3">
-          <a-descriptions-item v-if="props.dataset.time" label="报告日期" :span="3">
-            <slot name="select">
-              <span style="margin-right: 8px;">{{ props.dataset.time }}</span>
-            </slot>
-            <slot name="analysis"></slot>
-          </a-descriptions-item>
           <a-descriptions-item v-if="props.dataset.willillStateName" label="健康状态">
             {{ props.dataset.willillStateName }}
           </a-descriptions-item>
@@ -67,8 +87,8 @@ const collapse = (value: boolean) => {
           </a-descriptions-item>
         </a-descriptions>
       </a-card>
-      <template v-if="!collapsed">
-        <a-card class="card" size="small">
+      <template v-if="!props.collapsible || !collapsed">
+        <a-card class="card background symptom-card" size="small">
           <p v-if="props.dataset.constitutionGroupDefinition">体质: {{ props.dataset.constitutionGroupDefinition }}</p>
           <a-descriptions :column="1" bordered size="small" :label-style="{width: '120px'}">
             <a-descriptions-item v-if="props.dataset.constitutionGroupGeneralCharacteristics" label="总体特征">
@@ -91,18 +111,132 @@ const collapse = (value: boolean) => {
             </a-descriptions-item>
           </a-descriptions>
         </a-card>
-        <ReportAnalysisWidgetTongue v-bind="props" title="舌象分析" />
-        <ReportAnalysisWidgetFace v-bind="props" title="面象分析" />
-        <a-card class="card symptom-card" size="small" title="中医证素" v-if="props.dataset.factorItems?.length">
+        <a-card class="card background" size="small" title="舌象分析" :loading="props.loading">
+          <template #extra v-if="tongueExceptionData.length">
+            <a-button type="dashed" size="small" danger @click="openTongueExceptionAnalysis=true">异常舌象分析
+            </a-button>
+            <vxe-modal
+              title="异常舌象分析" v-model="openTongueExceptionAnalysis"
+              resize show-maximize :width="600" :height="tongueExceptionPanelHeight"
+            >
+              <a-collapse :bordered="false" v-model:activeKey="tongueExceptionPanelKeys">
+                <a-collapse-panel
+                  v-for="(panel, index) in tongueExceptionData" :key="panel.actualValue"
+                  :header="panel.actualValue"
+                >
+                  <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="panel.splitImage" :src="panel.splitImage" :width="200" :preview="false" />
+                        <a-space v-if="panel.attrs" direction="vertical">
+                          <a-tag v-for="v in panel.attrs" color="#cd201f" style="margin-left: 8px;margin-right: 0;">
+                            {{ v }}
+                          </a-tag>
+                        </a-space>
+                      </div>
+                    </a-descriptions-item>
+                    <a-descriptions-item label="特征">{{ panel.features }}</a-descriptions-item>
+                    <a-descriptions-item label="临床意义">{{ panel.clinicalSignificance }}</a-descriptions-item>
+                  </a-descriptions>
+                </a-collapse-panel>
+              </a-collapse>
+            </vxe-modal>
+          </template>
+          <a-space class="w-full analysis-wrapper">
+            <a-descriptions :column="3" bordered size="small">
+              <a-descriptions-item style="font-size: 14px;font-weight: 500;">舌象维度</a-descriptions-item>
+              <a-descriptions-item style="font-size: 14px;font-weight: 500;">检测结果</a-descriptions-item>
+              <a-descriptions-item style="font-size: 14px;font-weight: 500;">标准值</a-descriptions-item>
+              <template v-if="props.dataset.tongueColor?.actualList">
+                <a-descriptions-item>舌色</a-descriptions-item>
+                <a-descriptions-item>
+            <span class="tongue-value" v-for="item in props.dataset.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>{{ props.dataset.tongueColor.standardValue }}</a-descriptions-item>
+              </template>
+              <template v-if="props.dataset.tongueCoatingColor?.actualList">
+                <a-descriptions-item>苔色</a-descriptions-item>
+                <a-descriptions-item>
+            <span class="tongue-value" v-for="item in props.dataset.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>{{ props.dataset.tongueCoatingColor.standardValue }}</a-descriptions-item>
+              </template>
+              <template v-if="props.dataset.tongueShape?.actualList">
+                <a-descriptions-item>舌形</a-descriptions-item>
+                <a-descriptions-item>
+            <span class="tongue-value" v-for="item in props.dataset.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>{{ props.dataset.tongueShape.standardValue }}</a-descriptions-item>
+              </template>
+              <template v-if="props.dataset.tongueCoating?.actualList">
+                <a-descriptions-item>苔质</a-descriptions-item>
+                <a-descriptions-item>
+            <span class="tongue-value" v-for="item in props.dataset.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>{{ props.dataset.tongueCoating.standardValue }}</a-descriptions-item>
+              </template>
+              <template v-if="props.dataset.bodyFluid?.actualList">
+                <a-descriptions-item>津液</a-descriptions-item>
+                <a-descriptions-item>
+            <span class="tongue-value" v-for="item in props.dataset.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>{{ props.dataset.bodyFluid.standardValue }}</a-descriptions-item>
+              </template>
+              <template v-if="props.dataset.sublingualVein?.actualList">
+                <a-descriptions-item>舌下</a-descriptions-item>
+                <a-descriptions-item>
+            <span class="" v-for="item in props.dataset.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>{{ props.dataset.sublingualVein.standardValue }}</a-descriptions-item>
+              </template>
+            </a-descriptions>
+            <a-image :width="200" :height="200" :src="props.dataset.upImg" :preview="false" />
+            <a-image :width="200" :height="200" :src="props.dataset.downImg" :preview="false" />
+          </a-space>
+        </a-card>
+        <template v-if="props.dataset?.faceAnalysisResult">
+          <a-card class="card background" size="small" title="面象分析" :loading="props.loading">
+            <a-space align="start" class="w-full analysis-wrapper">
+              <div>{{ props.dataset.faceAnalysisResult }}</div>
+              <a-image :width="200" :height="200" :src="props.dataset.faceImg" :preview="false" />
+            </a-space>
+          </a-card>
+        </template>
+        <a-card class="card background symptom-card" size="small" title="中医证素"
+                v-if="props.dataset.factorItems?.length"
+        >
           <a-descriptions :column="1" bordered size="small">
-            <a-descriptions-item v-for="item in props.dataset.factorItems" :key="item.factorItemName"
-                                 :label="item.factorItemName"
+            <a-descriptions-item
+              v-for="item in props.dataset.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="props.dataset.diagnoseSyndromes?.length">
+        <a-card class="card background symptom-card" size="small" title="中医证型"
+                v-if="props.dataset.diagnoseSyndromes?.length"
+        >
           <a-descriptions :column="1" bordered size="small">
             <a-descriptions-item v-for="item in props.dataset.diagnoseSyndromes" :key="item.diagnoseSyndromeName"
                                  :label="item.diagnoseSyndromeName"
@@ -111,6 +245,7 @@ const collapse = (value: boolean) => {
             </a-descriptions-item>
           </a-descriptions>
         </a-card>
+        <slot></slot>
       </template>
     </div>
     <div v-else style="height: 40px;"></div>
@@ -147,5 +282,17 @@ const collapse = (value: boolean) => {
     width: 120px;
     text-align: center;
   }
+
+  :deep(.ant-descriptions-item-content) {
+    background-color: #fff;
+  }
+}
+
+.analysis-wrapper {
+  :deep(.ant-space-item) {
+    &:first-of-type {
+      flex: auto;
+    }
+  }
 }
 </style>

+ 4 - 4
src/widgets/ReportIndicatorChartWidget.vue

@@ -46,7 +46,7 @@ const option = ref({
   title: { text: '', top: 12, left: 'center' } as TitleComponentOption,
   tooltip: { trigger: 'axis' } as TooltipComponentOption,
   legend: { show: false, top: 12, right: 24 } as LegendComponentOption,
-  grid: { top: 60, bottom: 24, left: 40, right: 60, containLabel: true,  } as GridComponentOption,
+  grid: { top: 60, bottom: 24, left: 40, right: 60, containLabel: true } as GridComponentOption,
   xAxis: { type: 'category' } as any,
   yAxis: { type: 'value', scale: true, splitNumber: 10 } as any,
   visualMap: {
@@ -60,10 +60,10 @@ const option = ref({
 watchEffect(() => update(props.dataset));
 
 function update(data: ReportIndicatorModel) {
-  console.log(data.range, 'ReportIndicatorModel-->');
   const range = data.range.map(t => +t);
   let min = range[ 0 ], max = range[ 1 ], source = [];
-  for ( const { time, value } of data.records ) {
+  const records = [ ...data.records ].reverse();
+  for ( const { time, value } of records ) {
     source.push(time.format('YYYY/MM/DD'), value);
     min = Math.min(min, value);
     max = Math.max(max, value);
@@ -81,7 +81,7 @@ function update(data: ReportIndicatorModel) {
   option.value.series = [
     {
       name: data.name, smooth: true, type: 'line',
-      data: data.records.map((record) => [ record.time.format('YYYY/MM/DD'), record.value ]),
+      data: records.map((record) => [ record.time.format('YYYY/MM/DD'), record.value ]),
       markLine: {
         data: data.range.map(value => (
           { yAxis: value }

+ 4 - 1
src/widgets/ReportIndicatorWidget.vue

@@ -1,12 +1,15 @@
 <script setup lang="ts">
 import type { ReportIndicatorModel }          from '@/model';
-import { updateIndicatorByPatientIdMethod }   from '@/request/api/report.api';;
+import { updateIndicatorByPatientIdMethod }   from '@/request/api/report.api';
 import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons-vue';
 import { useRequest }                         from 'alova/client';
 import { Form, message as Message }           from 'ant-design-vue';
 import type { Rule }                          from 'ant-design-vue/es/form';
 
 
+
+
+
 const props = withDefaults(defineProps<{
   dataset: ReportIndicatorModel[];
   loading?: boolean;

+ 42 - 123
src/widgets/ReportSchemeCardWidget.vue

@@ -4,13 +4,7 @@ import ReportSchemePreview                               from '@/components/Repo
 import type { ReportSchemeItemModel, ReportSchemeModel } from '@/model';
 import { deleteSchemeMethod }                            from '@/request/api/report.api';
 
-import {
-  ArrowsAltOutlined,
-  CaretRightOutlined,
-  ExclamationCircleOutlined,
-  PlusOutlined,
-  ShrinkOutlined,
-} from '@ant-design/icons-vue';
+import { ExclamationCircleOutlined, PlusOutlined } from '@ant-design/icons-vue';
 
 import { useRequest }                                          from 'alova/client';
 import { Button, Modal, notification as Notification, Result } from 'ant-design-vue';
@@ -24,31 +18,15 @@ const props = withDefaults(defineProps<{
   title?: string;
 
   editable?: boolean;
+  hideTitle?: boolean;
 }>(), {
   title: '调理方案',
+  hideTitle: false,
 });
 const emits = defineEmits<{
   refresh: [];
 }>();
 
-const panelKeys = ref<string[]>([]);
-
-const collapsed = computed(() => panelKeys.value?.length !== props.dataset?.children?.length);
-const container = ref<HTMLDivElement | null>(null);
-const expandAll = (value: boolean) => {
-  panelKeys.value = value ? [] : props.dataset.children.map(item => item.id);
-  container.value?.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' });
-};
-
-const expand = (key: string, active: boolean) => {
-  if ( active ) {
-    panelKeys.value.push(key);
-  } else {
-    const index = panelKeys.value.indexOf(key);
-    panelKeys.value.splice(index, 1);
-  }
-};
-
 const { send: deleteScheme } = useRequest(
   (item) => deleteSchemeMethod(props.dataset.id, item),
   { immediate: false },
@@ -90,7 +68,7 @@ function openSchemeHandle(mode: 'edit' | 'preview', item?: ReportSchemeItemModel
       title() {
         switch ( openMode.value ) {
           case 'preview':
-            return h('div', {}, [ openModel.value?.category, openModel.value?.name ].filter(Boolean).join(': '));
+            return h('div', {}, [ openModel.value?.category, openModel.value?.name ].filter(Boolean).join(' '));
           case 'edit':
             return h('div', {}, openModel.value?.id ? `编辑调理方案` : `添加调理方案`);
           default:
@@ -120,7 +98,7 @@ function openSchemeHandle(mode: 'edit' | 'preview', item?: ReportSchemeItemModel
         }
       },
       corner() {
-        return props.editable && openModel.value?.id &&  h('div', { class: `space-container` }, [
+        return props.editable && openModel.value?.id && h('div', { class: `space-container` }, [
           h(
             Button,
             {
@@ -152,17 +130,12 @@ function openSchemeHandle(mode: 'edit' | 'preview', item?: ReportSchemeItemModel
 }
 </script>
 <template>
-  <div class="card report-card flex flex-col" ref="container">
-    <div class="card__header flex-none sticky flex justify-between items-center">
+  <div class="card report-card">
+    <div v-if="!props.hideTitle" class="card__header sticky flex justify-between items-center">
       <div class="card__title">
         <span>{{ props.title }}</span>
         <a-spin v-if="props.loading" size="small" style="margin-left: 4px;" />
         <a-space style="margin-left: 8px;">
-          <a-tooltip :title="collapsed ? '展开' : '收起'">
-            <a-button shape="circle" size="small" :icon="collapsed ? h(ArrowsAltOutlined) : h(ShrinkOutlined)"
-                      @click="expandAll(!collapsed)"
-            />
-          </a-tooltip>
           <a-tooltip title="添加调理方案" v-if="props.editable">
             <a-button
               type="primary" shape="circle" size="small" :icon="h(PlusOutlined)"
@@ -173,46 +146,25 @@ function openSchemeHandle(mode: 'edit' | 'preview', item?: ReportSchemeItemModel
       </div>
     </div>
     <div class="card__content flex-auto">
-      <a-collapse
-        v-model:activeKey="panelKeys"
-        :bordered="false"
-        class="scheme-dataset-collapse"
-      >
-        <template #expandIcon="{isActive,panelKey}">
-          <CaretRightOutlined class="collapse-expand-icon" :rotate="isActive ? 90 : 0"
-                              @click="expand(panelKey, !isActive)"
-          />
+      <a-card class="card background descriptions-card" size="small" v-for="(panel, index) in props.dataset.children">
+        <template #title>
+          <div @click="openSchemeHandle('preview', panel,index)">
+            <span class="header">{{ panel.category }}</span>
+            <span class="header" v-if="panel.name">{{ panel.name }}</span>
+          </div>
         </template>
-        <template v-for="(panel, index) in props.dataset.children" :key="panel.id">
-          <a-collapse-panel :collapsible="panel.descriptions?.length ? 'icon' : 'disabled'">
-            <template #header>
-              <div
-                class="cursor-pointer" style="width: max-content;"
-                @click.stop="openSchemeHandle('preview', panel, index);"
-              >
-                <span class="header">{{ panel.category }}</span>
-                <span class="header" v-if="panel.name">{{ panel.name }}</span>
-              </div>
-            </template>
-            <template #extra v-if="props.editable">
-              <a-space class="m-l-8px">
-                <a-button size="small" @click.stop="openSchemeHandle('edit',panel, index)">编辑</a-button>
-                <a-button size="small" danger @click.stop="deleteSchemeItemHandle(panel, index)">删除</a-button>
-              </a-space>
-            </template>
-            <template v-if="panel.descriptions?.length">
-              <div class="row" v-for="row in panel.descriptions" :key="row.name">
-                <span class="description" v-if="row.name">{{ row.name }}</span>
-                <span class="description" v-if="row.description">{{ row.description }}</span>
-              </div>
-            </template>
-            <div v-else style="margin-bottom: -24px;"></div>
-          </a-collapse-panel>
+        <template #extra v-if="editable">
+          <a-space>
+            <a-button size="small" @click.stop="openSchemeHandle('edit',panel, index)">编辑</a-button>
+            <a-button size="small" danger @click.stop="deleteSchemeItemHandle(panel, index)">删除</a-button>
+          </a-space>
         </template>
-      </a-collapse>
-    </div>
-    <div class="cart__footer flex-none sticky bottom-0">
-      <slot name="footer"></slot>
+        <a-descriptions :column="1" bordered size="small" v-if="panel.descriptions?.length">
+          <a-descriptions-item v-for="row in panel.descriptions" :key="row.name" :label="row.name ">
+            {{ row.description }}
+          </a-descriptions-item>
+        </a-descriptions>
+      </a-card>
     </div>
   </div>
 </template>
@@ -221,78 +173,45 @@ function openSchemeHandle(mode: 'edit' | 'preview', item?: ReportSchemeItemModel
 
 .report-card {
   min-height: var(--page-main-container);
+
   :deep(.ant-descriptions-item-label) {
     text-align: center;
   }
 }
 
-.scheme-dataset-collapse {
-  background-color: #fff;
-
-  :deep(.ant-collapse-item) {
-    background-color: #f7f7f7;
-    border-radius: 4px;
-    margin-bottom: 16px;
-    border: 0;
-    overflow: hidden
-  }
-
-  :deep(.ant-collapse-header) {
-    position: relative;
-    padding: 8px 12px 8px 0;
-    //width: max-content;
-
-    .ant-collapse-expand-icon {
-      cursor: default;
-    }
-
-    .ant-collapse-header-text {
-      line-height: 24px;
-      margin-left: 36px - 12px;
-    }
+.card__content {
+  > .card {
+    transition: all 200ms ease;
 
-    .collapse-expand-icon {
-      padding: 12px;
-      position: absolute;
-      left: 0;
-      top: 2px;
-    }
-  }
-}
-
-.scheme-dataset-collapse {
-  .header + .header,
-  .description + .description {
-    &::before {
-      content: ":";
-      margin: 0 4px 0 2px;
-      color: #333;
+    &:not(:last-of-type) {
+      margin-bottom: 12px;
     }
   }
 
   .header {
     user-select: none;
+    cursor: pointer;
 
-    &:last-of-type {
+    &:nth-of-type(2) {
       color: #4284fe;
+      margin-left: 4px;
     }
 
     &:first-of-type {
       color: #333;
     }
   }
+}
 
-  .row {
-    padding: 0;
-    margin: 12px 0 12px 24px;
-
-    &:last-of-type {
-      margin-bottom: 0;
-    }
+.descriptions-card {
+  :deep(.ant-descriptions-item-label) {
+    padding: 8px 12px;
+    width: 120px;
+    text-align: center;
+  }
 
-    .description {
-      color: #333;
-    }
+  :deep(.ant-descriptions-item-content) {
+    background-color: #fff;
   }
 }
 </style>