|
|
@@ -1,7 +1,708 @@
|
|
|
<template>
|
|
|
- <ReportPlaceholder status="backend" desc="在住长者的年龄、性别、护理等级、供养类型、文化程度等结构分布。" />
|
|
|
+ <div
|
|
|
+ v-loading="loading"
|
|
|
+ class="oa-page"
|
|
|
+ element-loading-text="正在加载在院长者档案,数据较多请稍候…"
|
|
|
+ >
|
|
|
+ <ContentWrap>
|
|
|
+ <div class="page-header">
|
|
|
+ <div class="header-title">
|
|
|
+ <Icon icon="ep:user" class="title-icon" />
|
|
|
+ <span>长者结构分析</span>
|
|
|
+ </div>
|
|
|
+ <div class="header-extra">
|
|
|
+ <span class="update-time"
|
|
|
+ >统计口径:当前在院长者(inStatus=1),更新于 {{ updateTime }}</span
|
|
|
+ >
|
|
|
+ <el-button type="primary" :icon="Refresh" @click="loadAll">刷新数据</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 筛选区 -->
|
|
|
+ <el-form :inline="true" class="filter-form" @submit.prevent>
|
|
|
+ <el-form-item label="统计院区">
|
|
|
+ <el-select
|
|
|
+ v-model="selectedCampusIds"
|
|
|
+ multiple
|
|
|
+ collapse-tags
|
|
|
+ collapse-tags-tooltip
|
|
|
+ clearable
|
|
|
+ filterable
|
|
|
+ placeholder="全部院区"
|
|
|
+ class="campus-select"
|
|
|
+ >
|
|
|
+ <el-option
|
|
|
+ v-for="item in campusOptions"
|
|
|
+ :key="item.value"
|
|
|
+ :label="item.label"
|
|
|
+ :value="item.value"
|
|
|
+ />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item>
|
|
|
+ <el-button type="primary" :icon="Search" @click="loadAll">查询</el-button>
|
|
|
+ <el-button :icon="RefreshLeft" @click="handleReset">重置</el-button>
|
|
|
+ <el-button
|
|
|
+ type="success"
|
|
|
+ :icon="Download"
|
|
|
+ :disabled="!elders.length"
|
|
|
+ @click="handleExport"
|
|
|
+ >
|
|
|
+ 导出汇总
|
|
|
+ </el-button>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+
|
|
|
+ <el-alert
|
|
|
+ v-if="loadFailed"
|
|
|
+ type="warning"
|
|
|
+ :closable="false"
|
|
|
+ show-icon
|
|
|
+ title="长者档案数据加载失败,请稍后刷新重试"
|
|
|
+ class="api-tip"
|
|
|
+ />
|
|
|
+
|
|
|
+ <!-- KPI -->
|
|
|
+ <div class="kpi-grid">
|
|
|
+ <div v-for="card in kpiCards" :key="card.label" class="kpi-card" :class="card.theme">
|
|
|
+ <div class="kpi-label">
|
|
|
+ <Icon :icon="card.icon" />
|
|
|
+ <span>{{ card.label }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="kpi-value">
|
|
|
+ {{ card.value }}
|
|
|
+ <span class="kpi-unit">{{ card.unit }}</span>
|
|
|
+ <span v-if="card.sub" class="kpi-sub">{{ card.sub }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </ContentWrap>
|
|
|
+
|
|
|
+ <el-row :gutter="12" class="chart-row">
|
|
|
+ <el-col :xs="24" :lg="9">
|
|
|
+ <ContentWrap>
|
|
|
+ <div class="panel-title">性别结构</div>
|
|
|
+ <div class="donut-wrap">
|
|
|
+ <Echart v-if="elders.length" :options="sexOption" height="340px" />
|
|
|
+ <el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
|
|
|
+ </div>
|
|
|
+ </ContentWrap>
|
|
|
+ </el-col>
|
|
|
+ <el-col :xs="24" :lg="15">
|
|
|
+ <ContentWrap>
|
|
|
+ <div class="panel-title">年龄段分布</div>
|
|
|
+ <Echart v-if="elders.length" :options="ageOption" height="340px" />
|
|
|
+ <el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
|
|
|
+ </ContentWrap>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+
|
|
|
+ <el-row :gutter="12" class="chart-row">
|
|
|
+ <el-col :xs="24" :lg="14">
|
|
|
+ <ContentWrap>
|
|
|
+ <div class="panel-title">
|
|
|
+ 护理等级分布 TOP10
|
|
|
+ <span class="title-tip">按在院长者当前护理等级名称统计</span>
|
|
|
+ </div>
|
|
|
+ <Echart v-if="elders.length" :options="nurseLevelOption" height="380px" />
|
|
|
+ <el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
|
|
|
+ </ContentWrap>
|
|
|
+ </el-col>
|
|
|
+ <el-col :xs="24" :lg="10">
|
|
|
+ <ContentWrap>
|
|
|
+ <div class="panel-title">入住时长分布</div>
|
|
|
+ <Echart v-if="elders.length" :options="stayOption" height="380px" />
|
|
|
+ <el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
|
|
|
+ </ContentWrap>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+
|
|
|
+ <el-row :gutter="12" class="chart-row">
|
|
|
+ <el-col :xs="24" :lg="12">
|
|
|
+ <ContentWrap>
|
|
|
+ <div class="panel-title">文化程度分布</div>
|
|
|
+ <Echart v-if="elders.length" :options="educationOption" height="340px" />
|
|
|
+ <el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
|
|
|
+ </ContentWrap>
|
|
|
+ </el-col>
|
|
|
+ <el-col :xs="24" :lg="12">
|
|
|
+ <ContentWrap>
|
|
|
+ <div class="panel-title">缴费方式分布</div>
|
|
|
+ <Echart v-if="elders.length" :options="paymentOption" height="340px" />
|
|
|
+ <el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
|
|
|
+ </ContentWrap>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+
|
|
|
+ <ContentWrap class="table-wrap">
|
|
|
+ <div class="panel-title">
|
|
|
+ 各院区长者结构明细
|
|
|
+ <span class="title-tip">平均入住时长按入住登记时间计算</span>
|
|
|
+ </div>
|
|
|
+ <el-table :data="campusRows" border stripe>
|
|
|
+ <el-table-column type="index" label="序号" width="60" align="center" />
|
|
|
+ <el-table-column prop="campusName" label="机构" min-width="160" show-overflow-tooltip />
|
|
|
+ <el-table-column prop="total" label="在院人数" min-width="90" align="center" sortable />
|
|
|
+ <el-table-column label="男 / 女" min-width="100" align="center">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <span class="num-male">{{ row.male }}</span>
|
|
|
+ <span class="sex-sep">/</span>
|
|
|
+ <span class="num-female">{{ row.female }}</span>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="maleRate" label="男性占比" min-width="90" align="center" />
|
|
|
+ <el-table-column prop="avgAge" label="平均年龄" min-width="90" align="center" sortable>
|
|
|
+ <template #default="{ row }">{{ row.avgAge ?? '-' }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="over80" label="80岁及以上" min-width="100" align="center" sortable />
|
|
|
+ <el-table-column
|
|
|
+ prop="avgStayMonths"
|
|
|
+ label="平均入住时长(月)"
|
|
|
+ min-width="140"
|
|
|
+ align="center"
|
|
|
+ sortable
|
|
|
+ >
|
|
|
+ <template #default="{ row }">{{ row.avgStayMonths ?? '-' }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column
|
|
|
+ prop="mainNurseLevel"
|
|
|
+ label="主要护理等级"
|
|
|
+ min-width="150"
|
|
|
+ show-overflow-tooltip
|
|
|
+ >
|
|
|
+ <template #default="{ row }">{{ row.mainNurseLevel || '-' }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+ </ContentWrap>
|
|
|
+ </div>
|
|
|
</template>
|
|
|
|
|
|
-<script setup lang="ts">
|
|
|
-import ReportPlaceholder from '../../components/ReportPlaceholder.vue'
|
|
|
+<script lang="ts" setup>
|
|
|
+import { Download, Refresh, RefreshLeft, Search } from '@element-plus/icons-vue'
|
|
|
+import Echart from '@/components/Echart/src/Echart.vue'
|
|
|
+import { exportWithCustomHeaders } from '@/utils/excel-export'
|
|
|
+import { getStrDictOptions, getDictLabel, DICT_TYPE } from '@/utils/dict'
|
|
|
+import { getCampusOptions } from '../../overview/campus-data'
|
|
|
+import { fetchInHospitalElders, type InHospitalElderVO } from '../occupancy-api'
|
|
|
+
|
|
|
+defineOptions({ name: 'OaOccupancyElderStructure' })
|
|
|
+
|
|
|
+const message = useMessage()
|
|
|
+const campusOptions = getCampusOptions()
|
|
|
+
|
|
|
+const loading = ref(false)
|
|
|
+const loadFailed = ref(false)
|
|
|
+const updateTime = ref('')
|
|
|
+const selectedCampusIds = ref<number[]>(campusOptions.map((item) => item.value))
|
|
|
+const elders = ref<InHospitalElderVO[]>([])
|
|
|
+
|
|
|
+const educationDict = getStrDictOptions(DICT_TYPE.DEGREE_EDUCATION_ARR)
|
|
|
+const paymentDict = getStrDictOptions(DICT_TYPE.PAYMENT_METHOD_ARR)
|
|
|
+
|
|
|
+const dictLabel = (dict: any[], value: number | null) =>
|
|
|
+ value === null || value === undefined ? '未设置' : getDictLabel(dict, String(value)) || '未设置'
|
|
|
+
|
|
|
+const pad2 = (n: number) => String(n).padStart(2, '0')
|
|
|
+
|
|
|
+/* ---------------- 聚合 ---------------- */
|
|
|
+
|
|
|
+const countBy = (fn: (e: InHospitalElderVO) => string) => {
|
|
|
+ const map = new Map<string, number>()
|
|
|
+ elders.value.forEach((e) => {
|
|
|
+ const key = fn(e)
|
|
|
+ map.set(key, (map.get(key) || 0) + 1)
|
|
|
+ })
|
|
|
+ return map
|
|
|
+}
|
|
|
+
|
|
|
+const average = (nums: number[]) =>
|
|
|
+ nums.length ? Math.round((nums.reduce((a, b) => a + b, 0) / nums.length) * 10) / 10 : null
|
|
|
+
|
|
|
+const stats = computed(() => {
|
|
|
+ const list = elders.value
|
|
|
+ const total = list.length
|
|
|
+ const male = list.filter((e) => Number(e.elderSex) === 1).length
|
|
|
+ const ages = list.map((e) => Number(e.elderAge)).filter((v) => !Number.isNaN(v) && v > 0)
|
|
|
+ const now = Date.now()
|
|
|
+ const stayMonthsList = list
|
|
|
+ .filter((e) => e.checkInTime && e.checkInTime > 0)
|
|
|
+ .map((e) => (now - (e.checkInTime as number)) / 1000 / 60 / 60 / 24 / 30.4375)
|
|
|
+ return {
|
|
|
+ total,
|
|
|
+ male,
|
|
|
+ female: list.filter((e) => Number(e.elderSex) === 2).length,
|
|
|
+ sexUnknown: total - male - list.filter((e) => Number(e.elderSex) === 2).length,
|
|
|
+ avgAge: average(ages),
|
|
|
+ over90: list.filter((e) => Number(e.elderAge) >= 90).length,
|
|
|
+ avgStayMonths: average(stayMonthsList)
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+const kpiCards = computed(() => [
|
|
|
+ { label: '在院长者', value: stats.value.total, unit: '人', icon: 'ep:user', theme: 'blue' },
|
|
|
+ {
|
|
|
+ label: '平均年龄',
|
|
|
+ value: stats.value.avgAge ?? '-',
|
|
|
+ unit: '岁',
|
|
|
+ icon: 'ep:calendar',
|
|
|
+ theme: 'cyan'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: '男性',
|
|
|
+ value: stats.value.male,
|
|
|
+ unit: '人',
|
|
|
+ sub: stats.value.total
|
|
|
+ ? `占比 ${((stats.value.male / stats.value.total) * 100).toFixed(1)}%`
|
|
|
+ : '',
|
|
|
+ icon: 'ep:male',
|
|
|
+ theme: 'green'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: '女性',
|
|
|
+ value: stats.value.female,
|
|
|
+ unit: '人',
|
|
|
+ sub: stats.value.total
|
|
|
+ ? `占比 ${((stats.value.female / stats.value.total) * 100).toFixed(1)}%`
|
|
|
+ : '',
|
|
|
+ icon: 'ep:female',
|
|
|
+ theme: 'pink'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: '90岁及以上',
|
|
|
+ value: stats.value.over90,
|
|
|
+ unit: '人',
|
|
|
+ icon: 'ep:histogram',
|
|
|
+ theme: 'orange'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: '平均入住时长',
|
|
|
+ value: stats.value.avgStayMonths ?? '-',
|
|
|
+ unit: '月',
|
|
|
+ icon: 'ep:timer',
|
|
|
+ theme: 'purple'
|
|
|
+ }
|
|
|
+])
|
|
|
+
|
|
|
+/** 年龄段(养老行业通用分段) */
|
|
|
+const AGE_BUCKETS = [
|
|
|
+ { label: '60岁以下', test: (a: number) => a < 60 },
|
|
|
+ { label: '60-69岁', test: (a: number) => a >= 60 && a < 70 },
|
|
|
+ { label: '70-79岁', test: (a: number) => a >= 70 && a < 80 },
|
|
|
+ { label: '80-89岁', test: (a: number) => a >= 80 && a < 90 },
|
|
|
+ { label: '90-99岁', test: (a: number) => a >= 90 && a < 100 },
|
|
|
+ { label: '100岁及以上', test: (a: number) => a >= 100 }
|
|
|
+]
|
|
|
+
|
|
|
+/** 入住时长分段(年) */
|
|
|
+const STAY_BUCKETS = [
|
|
|
+ { label: '1年以内', test: (m: number) => m < 12 },
|
|
|
+ { label: '1-3年', test: (m: number) => m >= 12 && m < 36 },
|
|
|
+ { label: '3-5年', test: (m: number) => m >= 36 && m < 60 },
|
|
|
+ { label: '5-10年', test: (m: number) => m >= 60 && m < 120 },
|
|
|
+ { label: '10年及以上', test: (m: number) => m >= 120 }
|
|
|
+]
|
|
|
+
|
|
|
+/* ---------------- 图表 ---------------- */
|
|
|
+
|
|
|
+const sexOption = computed(() => {
|
|
|
+ const s = stats.value
|
|
|
+ const data = [
|
|
|
+ { name: '男', value: s.male, itemStyle: { color: '#409eff' } },
|
|
|
+ { name: '女', value: s.female, itemStyle: { color: '#ec5f86' } }
|
|
|
+ ].filter((item) => item.value > 0)
|
|
|
+ return {
|
|
|
+ tooltip: { trigger: 'item', formatter: '{b}:{c} 人({d}%)' },
|
|
|
+ legend: { bottom: 0, icon: 'circle' },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ type: 'pie',
|
|
|
+ radius: ['50%', '70%'],
|
|
|
+ center: ['50%', '46%'],
|
|
|
+ label: { formatter: '{b}\n{c} 人' },
|
|
|
+ data
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+const ageOption = computed(() => {
|
|
|
+ const counts = AGE_BUCKETS.map(
|
|
|
+ (b) => elders.value.filter((e) => Number(e.elderAge) > 0 && b.test(Number(e.elderAge))).length
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: '{b}:{c} 人' },
|
|
|
+ grid: { left: 48, right: 24, top: 24, bottom: 30 },
|
|
|
+ xAxis: { type: 'category', data: AGE_BUCKETS.map((b) => b.label) },
|
|
|
+ yAxis: { type: 'value', name: '人', minInterval: 1 },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ type: 'bar',
|
|
|
+ barWidth: 28,
|
|
|
+ data: counts,
|
|
|
+ itemStyle: {
|
|
|
+ borderRadius: [4, 4, 0, 0],
|
|
|
+ color: (p: any) =>
|
|
|
+ ['#95d4ff', '#67b8ff', '#409eff', '#36a866', '#e6a23c', '#f56c6c'][p.dataIndex]
|
|
|
+ },
|
|
|
+ label: { show: true, position: 'top' }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+const nurseLevelOption = computed(() => {
|
|
|
+ const map = countBy((e) => e.nurseLevelName || '未设置')
|
|
|
+ const list = Array.from(map.entries())
|
|
|
+ .map(([name, count]) => ({ name, count }))
|
|
|
+ .sort((a, b) => a.count - b.count)
|
|
|
+ .slice(-10)
|
|
|
+ return {
|
|
|
+ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: '{b}:{c} 人' },
|
|
|
+ grid: { left: 130, right: 48, top: 10, bottom: 10, containLabel: true },
|
|
|
+ xAxis: { type: 'value', minInterval: 1 },
|
|
|
+ yAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: list.map((i) => i.name),
|
|
|
+ axisLabel: { width: 120, overflow: 'truncate' }
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ type: 'bar',
|
|
|
+ barWidth: 14,
|
|
|
+ data: list.map((i) => i.count),
|
|
|
+ itemStyle: { borderRadius: [0, 4, 4, 0], color: '#8b5cf6' },
|
|
|
+ label: { show: true, position: 'right' }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+const stayOption = computed(() => {
|
|
|
+ const now = Date.now()
|
|
|
+ const monthsOf = (e: InHospitalElderVO) =>
|
|
|
+ e.checkInTime ? (now - e.checkInTime) / 1000 / 60 / 60 / 24 / 30.4375 : null
|
|
|
+ const counts = STAY_BUCKETS.map(
|
|
|
+ (b) =>
|
|
|
+ elders.value.filter((e) => {
|
|
|
+ const m = monthsOf(e)
|
|
|
+ return m !== null && b.test(m)
|
|
|
+ }).length
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: '{b}:{c} 人' },
|
|
|
+ grid: { left: 48, right: 24, top: 24, bottom: 30 },
|
|
|
+ xAxis: { type: 'category', data: STAY_BUCKETS.map((b) => b.label) },
|
|
|
+ yAxis: { type: 'value', name: '人', minInterval: 1 },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ type: 'bar',
|
|
|
+ barWidth: 24,
|
|
|
+ data: counts,
|
|
|
+ itemStyle: { borderRadius: [4, 4, 0, 0], color: '#1fa9bf' },
|
|
|
+ label: { show: true, position: 'top' }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+const distributionOption = (
|
|
|
+ dict: any[],
|
|
|
+ valueOf: (e: InHospitalElderVO) => number | null,
|
|
|
+ color: string
|
|
|
+) => {
|
|
|
+ const map = countBy((e) => dictLabel(dict, valueOf(e)))
|
|
|
+ const list = Array.from(map.entries())
|
|
|
+ .map(([name, count]) => ({ name, count }))
|
|
|
+ .sort((a, b) => b.count - a.count)
|
|
|
+ return {
|
|
|
+ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: '{b}:{c} 人' },
|
|
|
+ grid: { left: 48, right: 24, top: 24, bottom: 60 },
|
|
|
+ xAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: list.map((i) => i.name),
|
|
|
+ axisLabel: { interval: 0, rotate: list.length > 5 ? 30 : 0, width: 90, overflow: 'truncate' }
|
|
|
+ },
|
|
|
+ yAxis: { type: 'value', name: '人', minInterval: 1 },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ type: 'bar',
|
|
|
+ barWidth: 24,
|
|
|
+ data: list.map((i) => i.count),
|
|
|
+ itemStyle: { borderRadius: [4, 4, 0, 0], color },
|
|
|
+ label: { show: true, position: 'top' }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const educationOption = computed(() =>
|
|
|
+ distributionOption(educationDict, (e) => e.degreeEducation, '#36a866')
|
|
|
+)
|
|
|
+const paymentOption = computed(() =>
|
|
|
+ distributionOption(paymentDict, (e) => e.paymentMethod, '#f5851d')
|
|
|
+)
|
|
|
+
|
|
|
+/* ---------------- 院区明细表 ---------------- */
|
|
|
+
|
|
|
+const campusNameOf = (e: InHospitalElderVO) => {
|
|
|
+ const opt = campusOptions.find((item) => item.value === Number(e.tenantId))
|
|
|
+ return opt?.label || e.tenantName || `院区${e.tenantId ?? '-'}`
|
|
|
+}
|
|
|
+
|
|
|
+const campusRows = computed(() => {
|
|
|
+ const map = new Map<string, InHospitalElderVO[]>()
|
|
|
+ elders.value.forEach((e) => {
|
|
|
+ const name = campusNameOf(e)
|
|
|
+ const arr = map.get(name) || []
|
|
|
+ arr.push(e)
|
|
|
+ map.set(name, arr)
|
|
|
+ })
|
|
|
+ return Array.from(map.entries())
|
|
|
+ .map(([campusName, list]) => {
|
|
|
+ const male = list.filter((e) => Number(e.elderSex) === 1).length
|
|
|
+ const ages = list.map((e) => Number(e.elderAge)).filter((v) => v > 0)
|
|
|
+ const now = Date.now()
|
|
|
+ const stays = list
|
|
|
+ .filter((e) => e.checkInTime)
|
|
|
+ .map((e) => (now - (e.checkInTime as number)) / 1000 / 60 / 60 / 24 / 30.4375)
|
|
|
+ const nurseMap = new Map<string, number>()
|
|
|
+ list.forEach((e) => {
|
|
|
+ const name = e.nurseLevelName || '未设置'
|
|
|
+ nurseMap.set(name, (nurseMap.get(name) || 0) + 1)
|
|
|
+ })
|
|
|
+ const main = Array.from(nurseMap.entries()).sort((a, b) => b[1] - a[1])[0]
|
|
|
+ return {
|
|
|
+ campusName,
|
|
|
+ total: list.length,
|
|
|
+ male,
|
|
|
+ female: list.length - male,
|
|
|
+ maleRate: list.length ? ((male / list.length) * 100).toFixed(1) + '%' : '-',
|
|
|
+ avgAge: average(ages),
|
|
|
+ over80: list.filter((e) => Number(e.elderAge) >= 80).length,
|
|
|
+ avgStayMonths: average(stays),
|
|
|
+ mainNurseLevel: main ? `${main[0]}(${main[1]}人)` : ''
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .sort((a, b) => b.total - a.total)
|
|
|
+})
|
|
|
+
|
|
|
+/* ---------------- 加载与导出 ---------------- */
|
|
|
+
|
|
|
+const loadAll = async () => {
|
|
|
+ const ids = selectedCampusIds.value.length
|
|
|
+ ? selectedCampusIds.value
|
|
|
+ : campusOptions.map((item) => item.value)
|
|
|
+ if (!ids.length) {
|
|
|
+ elders.value = []
|
|
|
+ return
|
|
|
+ }
|
|
|
+ loading.value = true
|
|
|
+ loadFailed.value = false
|
|
|
+ try {
|
|
|
+ elders.value = await fetchInHospitalElders(ids)
|
|
|
+ const now = new Date()
|
|
|
+ updateTime.value = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())} ${pad2(
|
|
|
+ now.getHours()
|
|
|
+ )}:${pad2(now.getMinutes())}`
|
|
|
+ } catch {
|
|
|
+ loadFailed.value = true
|
|
|
+ elders.value = []
|
|
|
+ } finally {
|
|
|
+ loading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const handleReset = () => {
|
|
|
+ selectedCampusIds.value = campusOptions.map((item) => item.value)
|
|
|
+ loadAll()
|
|
|
+}
|
|
|
+
|
|
|
+const handleExport = () => {
|
|
|
+ exportWithCustomHeaders(
|
|
|
+ campusRows.value,
|
|
|
+ [
|
|
|
+ { key: 'campusName', title: '机构', width: 20 },
|
|
|
+ { key: 'total', title: '在院人数', width: 10 },
|
|
|
+ { key: 'male', title: '男', width: 8 },
|
|
|
+ { key: 'female', title: '女', width: 8 },
|
|
|
+ { key: 'maleRate', title: '男性占比', width: 10 },
|
|
|
+ { key: 'avgAge', title: '平均年龄', width: 10 },
|
|
|
+ { key: 'over80', title: '80岁及以上', width: 12 },
|
|
|
+ { key: 'avgStayMonths', title: '平均入住时长(月)', width: 16 },
|
|
|
+ { key: 'mainNurseLevel', title: '主要护理等级', width: 20 }
|
|
|
+ ],
|
|
|
+ `长者结构分析_${new Date().toISOString().slice(0, 10)}.xlsx`,
|
|
|
+ '院区结构明细'
|
|
|
+ )
|
|
|
+ message.success('导出成功')
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(loadAll)
|
|
|
</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.oa-page {
|
|
|
+ .page-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ margin-bottom: 14px;
|
|
|
+
|
|
|
+ .header-title {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ font-size: 18px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+
|
|
|
+ .title-icon {
|
|
|
+ margin-right: 8px;
|
|
|
+ font-size: 22px;
|
|
|
+ color: #409eff;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .update-time {
|
|
|
+ margin-right: 14px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #909399;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .filter-form {
|
|
|
+ margin-top: 4px;
|
|
|
+
|
|
|
+ .campus-select {
|
|
|
+ width: 360px;
|
|
|
+ max-width: 60vw;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .api-tip {
|
|
|
+ margin: 4px 0 14px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .kpi-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(6, 1fr);
|
|
|
+ gap: 12px;
|
|
|
+
|
|
|
+ @media (max-width: 1400px) {
|
|
|
+ grid-template-columns: repeat(3, 1fr);
|
|
|
+ }
|
|
|
+ @media (max-width: 768px) {
|
|
|
+ grid-template-columns: repeat(2, 1fr);
|
|
|
+ }
|
|
|
+
|
|
|
+ .kpi-card {
|
|
|
+ padding: 15px 16px;
|
|
|
+ border-radius: 8px;
|
|
|
+ color: #fff;
|
|
|
+
|
|
|
+ .kpi-label {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ font-size: 13px;
|
|
|
+
|
|
|
+ .el-icon {
|
|
|
+ margin-right: 6px;
|
|
|
+ font-size: 16px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .kpi-value {
|
|
|
+ margin-top: 8px;
|
|
|
+ font-size: 28px;
|
|
|
+ font-weight: 700;
|
|
|
+ line-height: 1.15;
|
|
|
+
|
|
|
+ .kpi-unit {
|
|
|
+ margin-left: 4px;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 400;
|
|
|
+ opacity: 0.85;
|
|
|
+ }
|
|
|
+
|
|
|
+ .kpi-sub {
|
|
|
+ display: block;
|
|
|
+ margin-top: 2px;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 400;
|
|
|
+ opacity: 0.85;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &.blue {
|
|
|
+ background: linear-gradient(135deg, #5b9bff 0%, #2f7cf6 100%);
|
|
|
+ }
|
|
|
+ &.green {
|
|
|
+ background: linear-gradient(135deg, #62d391 0%, #36a866 100%);
|
|
|
+ }
|
|
|
+ &.cyan {
|
|
|
+ background: linear-gradient(135deg, #4fd0e0 0%, #1fa9bf 100%);
|
|
|
+ }
|
|
|
+ &.orange {
|
|
|
+ background: linear-gradient(135deg, #ffb066 0%, #f5851d 100%);
|
|
|
+ }
|
|
|
+ &.purple {
|
|
|
+ background: linear-gradient(135deg, #b794f6 0%, #8b5cf6 100%);
|
|
|
+ }
|
|
|
+ &.pink {
|
|
|
+ background: linear-gradient(135deg, #ff9ebd 0%, #ec5f86 100%);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .chart-row,
|
|
|
+ .table-wrap {
|
|
|
+ margin-top: 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .chart-empty {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ height: 300px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .panel-title {
|
|
|
+ position: relative;
|
|
|
+ padding-left: 10px;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ font-size: 15px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+
|
|
|
+ &::before {
|
|
|
+ content: '';
|
|
|
+ position: absolute;
|
|
|
+ left: 0;
|
|
|
+ top: 2px;
|
|
|
+ width: 4px;
|
|
|
+ height: 16px;
|
|
|
+ background: #409eff;
|
|
|
+ border-radius: 2px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .title-tip {
|
|
|
+ margin-left: 10px;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 400;
|
|
|
+ color: #a8abb2;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .num-male {
|
|
|
+ color: #409eff;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+ .num-female {
|
|
|
+ color: #ec5f86;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+ .sex-sep {
|
|
|
+ margin: 0 6px;
|
|
|
+ color: #dcdfe6;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|