|
@@ -1,20 +1,16 @@
|
|
|
<template>
|
|
<template>
|
|
|
- <div v-loading="loading" class="oa-page" element-loading-text="正在加载退住分析数据…">
|
|
|
|
|
|
|
+ <div v-loading="loading" class="oa-page" element-loading-text="正在加载退住原因统计…">
|
|
|
<ContentWrap>
|
|
<ContentWrap>
|
|
|
<div class="page-header">
|
|
<div class="page-header">
|
|
|
<div class="header-title">
|
|
<div class="header-title">
|
|
|
- <Icon icon="ep:document-remove" class="title-icon" />
|
|
|
|
|
- <span>退住原因与平均入住时长</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="header-extra">
|
|
|
|
|
- <span class="update-time">更新于 {{ updateTime }}</span>
|
|
|
|
|
- <el-button type="primary" :icon="Refresh" @click="loadAll">刷新数据</el-button>
|
|
|
|
|
|
|
+ <Icon icon="ep:switch-button" class="title-icon" />
|
|
|
|
|
+ <span>退住原因与平均入住周期</span>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<!-- 筛选区 -->
|
|
<!-- 筛选区 -->
|
|
|
<el-form :inline="true" class="filter-form" @submit.prevent>
|
|
<el-form :inline="true" class="filter-form" @submit.prevent>
|
|
|
- <el-form-item label="统计院区">
|
|
|
|
|
|
|
+ <el-form-item label="院区">
|
|
|
<el-select
|
|
<el-select
|
|
|
v-model="selectedCampusIds"
|
|
v-model="selectedCampusIds"
|
|
|
multiple
|
|
multiple
|
|
@@ -58,7 +54,7 @@
|
|
|
type="warning"
|
|
type="warning"
|
|
|
:closable="false"
|
|
:closable="false"
|
|
|
show-icon
|
|
show-icon
|
|
|
- title="退住分析数据加载失败,请稍后刷新重试"
|
|
|
|
|
|
|
+ title="退住原因统计加载失败,请稍后刷新重试"
|
|
|
class="api-tip"
|
|
class="api-tip"
|
|
|
/>
|
|
/>
|
|
|
|
|
|
|
@@ -72,6 +68,7 @@
|
|
|
<div class="kpi-value">
|
|
<div class="kpi-value">
|
|
|
{{ card.value }}
|
|
{{ card.value }}
|
|
|
<span class="kpi-unit">{{ card.unit }}</span>
|
|
<span class="kpi-unit">{{ card.unit }}</span>
|
|
|
|
|
+ <span v-if="card.sub" class="kpi-sub">{{ card.sub }}</span>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
@@ -80,63 +77,118 @@
|
|
|
<el-row :gutter="12" class="chart-row">
|
|
<el-row :gutter="12" class="chart-row">
|
|
|
<el-col :xs="24" :lg="10">
|
|
<el-col :xs="24" :lg="10">
|
|
|
<ContentWrap>
|
|
<ContentWrap>
|
|
|
- <div class="panel-title">退住原因分布</div>
|
|
|
|
|
- <div class="donut-wrap">
|
|
|
|
|
- <Echart v-if="hasData" :options="reasonPieOption" height="360px" />
|
|
|
|
|
- <el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
|
|
|
|
|
|
|
+ <div class="panel-title">
|
|
|
|
|
+ 退住原因分布
|
|
|
|
|
+ <span class="title-tip">按所选院区合计</span>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ <Echart
|
|
|
|
|
+ v-if="hasData && summary.reasonDistribution.length"
|
|
|
|
|
+ :options="reasonChartOption"
|
|
|
|
|
+ height="400px"
|
|
|
|
|
+ />
|
|
|
|
|
+ <el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
|
|
|
</ContentWrap>
|
|
</ContentWrap>
|
|
|
</el-col>
|
|
</el-col>
|
|
|
<el-col :xs="24" :lg="14">
|
|
<el-col :xs="24" :lg="14">
|
|
|
<ContentWrap>
|
|
<ContentWrap>
|
|
|
- <div class="panel-title">各原因平均入住时长</div>
|
|
|
|
|
- <Echart v-if="hasData" :options="avgStayBarOption" height="360px" />
|
|
|
|
|
|
|
+ <div class="panel-title">
|
|
|
|
|
+ 月度退住趋势(按原因拆分)
|
|
|
|
|
+ <span class="title-tip">堆叠柱为各原因人数,折线为当月退住总数</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <Echart
|
|
|
|
|
+ v-if="hasData && summary.monthlyTrend.length"
|
|
|
|
|
+ :options="trendChartOption"
|
|
|
|
|
+ height="400px"
|
|
|
|
|
+ />
|
|
|
<el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
|
|
<el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
|
|
|
</ContentWrap>
|
|
</ContentWrap>
|
|
|
</el-col>
|
|
</el-col>
|
|
|
</el-row>
|
|
</el-row>
|
|
|
|
|
|
|
|
- <ContentWrap class="chart-wrap">
|
|
|
|
|
|
|
+ <ContentWrap class="table-wrap">
|
|
|
<div class="panel-title">
|
|
<div class="panel-title">
|
|
|
- 月度退住趋势
|
|
|
|
|
- <span class="title-tip">按退住原因统计</span>
|
|
|
|
|
|
|
+ 各院区汇总
|
|
|
|
|
+ <span class="title-tip">平均入住周期按退住长者入住时长计算</span>
|
|
|
</div>
|
|
</div>
|
|
|
- <Echart v-if="hasData" :options="monthTrendOption" height="360px" />
|
|
|
|
|
- <el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
|
|
|
|
|
- </ContentWrap>
|
|
|
|
|
-
|
|
|
|
|
- <ContentWrap class="table-wrap">
|
|
|
|
|
- <div class="panel-title">各院区退住明细</div>
|
|
|
|
|
- <el-table :data="campusRows" border stripe>
|
|
|
|
|
|
|
+ <el-table :data="stats" border stripe>
|
|
|
<el-table-column type="index" label="序号" width="60" align="center" />
|
|
<el-table-column type="index" label="序号" width="60" align="center" />
|
|
|
<el-table-column prop="tenantName" label="机构" min-width="160" show-overflow-tooltip />
|
|
<el-table-column prop="tenantName" label="机构" min-width="160" show-overflow-tooltip />
|
|
|
- <el-table-column prop="total" label="退住人数" min-width="100" align="center" sortable />
|
|
|
|
|
- <el-table-column prop="avgStayDays" label="平均入住时长(天)" min-width="150" align="center">
|
|
|
|
|
- <template #default="{ row }">{{ row.avgStayDays ?? '-' }}</template>
|
|
|
|
|
|
|
+ <el-table-column label="统计区间" min-width="180" align="center">
|
|
|
|
|
+ <template #default="{ row }">{{ row.startDate }} 至 {{ row.endDate }}</template>
|
|
|
</el-table-column>
|
|
</el-table-column>
|
|
|
<el-table-column
|
|
<el-table-column
|
|
|
- v-for="col in reasonColumns"
|
|
|
|
|
- :key="col.key"
|
|
|
|
|
- :prop="col.key"
|
|
|
|
|
- :label="col.label"
|
|
|
|
|
|
|
+ prop="retreatTotalCount"
|
|
|
|
|
+ label="退住总人数"
|
|
|
min-width="100"
|
|
min-width="100"
|
|
|
align="center"
|
|
align="center"
|
|
|
|
|
+ sortable
|
|
|
/>
|
|
/>
|
|
|
|
|
+ <el-table-column
|
|
|
|
|
+ label="平均入住天数"
|
|
|
|
|
+ min-width="110"
|
|
|
|
|
+ align="center"
|
|
|
|
|
+ sortable
|
|
|
|
|
+ :sort-method="sortBy('avgStayDays')"
|
|
|
|
|
+ >
|
|
|
|
|
+ <template #default="{ row }">{{ formatNum(row.avgStayDays) }}</template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column
|
|
|
|
|
+ label="平均入住月数"
|
|
|
|
|
+ min-width="110"
|
|
|
|
|
+ align="center"
|
|
|
|
|
+ sortable
|
|
|
|
|
+ :sort-method="sortBy('avgStayMonths')"
|
|
|
|
|
+ >
|
|
|
|
|
+ <template #default="{ row }">{{ formatNum(row.avgStayMonths) }}</template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="TOP1 退住原因" min-width="200" align="center">
|
|
|
|
|
+ <template #default="{ row }">
|
|
|
|
|
+ <span v-if="row.topReason">{{ reasonText(row.topReason) }}</span>
|
|
|
|
|
+ <span v-else class="num-zero">-</span>
|
|
|
|
|
+ <el-tag v-if="row.topReason" type="warning" size="small" effect="plain" class="ml5">
|
|
|
|
|
+ {{ row.topReasonCount }} 人 / {{ formatRate(row.topReasonRate) }}
|
|
|
|
|
+ </el-tag>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ </el-table>
|
|
|
|
|
+ </ContentWrap>
|
|
|
|
|
+
|
|
|
|
|
+ <ContentWrap class="table-wrap">
|
|
|
|
|
+ <div class="panel-title">
|
|
|
|
|
+ 退住原因明细
|
|
|
|
|
+ <span class="title-tip">机构 × 原因,平均入住时长为该原因退住长者的均值</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <el-table :data="reasonRows" border stripe max-height="520">
|
|
|
|
|
+ <el-table-column type="index" label="序号" width="60" align="center" />
|
|
|
|
|
+ <el-table-column prop="tenantName" label="机构" min-width="160" show-overflow-tooltip />
|
|
|
|
|
+ <el-table-column prop="reason" label="退住原因" min-width="140" show-overflow-tooltip>
|
|
|
|
|
+ <template #default="{ row }">{{ reasonText(row.reason) }}</template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column prop="count" label="退住人数" min-width="90" align="center" sortable />
|
|
|
|
|
+ <el-table-column label="占比" min-width="90" align="center">
|
|
|
|
|
+ <template #default="{ row }">{{ formatRate(row.rate) }}</template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="平均入住天数" min-width="110" align="center">
|
|
|
|
|
+ <template #default="{ row }">{{ formatNum(row.avgStayDays) }}</template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="平均入住月数" min-width="110" align="center">
|
|
|
|
|
+ <template #default="{ row }">{{ formatNum(row.avgStayMonths) }}</template>
|
|
|
|
|
+ </el-table-column>
|
|
|
</el-table>
|
|
</el-table>
|
|
|
</ContentWrap>
|
|
</ContentWrap>
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script lang="ts" setup>
|
|
<script lang="ts" setup>
|
|
|
-import { Download, Refresh, RefreshLeft, Search } from '@element-plus/icons-vue'
|
|
|
|
|
|
|
+import { Download, RefreshLeft, Search } from '@element-plus/icons-vue'
|
|
|
import Echart from '@/components/Echart/src/Echart.vue'
|
|
import Echart from '@/components/Echart/src/Echart.vue'
|
|
|
import { exportWithCustomHeaders } from '@/utils/excel-export'
|
|
import { exportWithCustomHeaders } from '@/utils/excel-export'
|
|
|
-import { getDictLabel, DICT_TYPE } from '@/utils/dict'
|
|
|
|
|
|
|
+import { DICT_TYPE, getDictLabel } from '@/utils/dict'
|
|
|
import { getCampusOptions } from '../../overview/campus-data'
|
|
import { getCampusOptions } from '../../overview/campus-data'
|
|
|
import {
|
|
import {
|
|
|
- fetchMoveoutAnalysis,
|
|
|
|
|
- type MoveoutAnalysisData,
|
|
|
|
|
- type MoveoutReasonBucket
|
|
|
|
|
|
|
+ fetchRetreatReasonStats,
|
|
|
|
|
+ type RetreatReasonCampusStat,
|
|
|
|
|
+ type RetreatReasonStat
|
|
|
} from '../occupancy-api'
|
|
} from '../occupancy-api'
|
|
|
|
|
|
|
|
defineOptions({ name: 'OaOccupancyMoveoutReason' })
|
|
defineOptions({ name: 'OaOccupancyMoveoutReason' })
|
|
@@ -146,53 +198,202 @@ const campusOptions = getCampusOptions()
|
|
|
|
|
|
|
|
const loading = ref(false)
|
|
const loading = ref(false)
|
|
|
const loadFailed = ref(false)
|
|
const loadFailed = ref(false)
|
|
|
-const updateTime = ref('')
|
|
|
|
|
const selectedCampusIds = ref<number[]>(campusOptions.map((item) => item.value))
|
|
const selectedCampusIds = ref<number[]>(campusOptions.map((item) => item.value))
|
|
|
const dateRange = ref<string[]>([])
|
|
const dateRange = ref<string[]>([])
|
|
|
-const data = ref<MoveoutAnalysisData | null>(null)
|
|
|
|
|
-const hasData = computed(() => (data.value?.totalMoveOut ?? 0) > 0)
|
|
|
|
|
|
|
+
|
|
|
|
|
+/** 各机构原始返回 */
|
|
|
|
|
+const stats = ref<RetreatReasonCampusStat[]>([])
|
|
|
|
|
|
|
|
const pad2 = (n: number) => String(n).padStart(2, '0')
|
|
const pad2 = (n: number) => String(n).padStart(2, '0')
|
|
|
const fmtDate = (d: Date) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
|
|
const fmtDate = (d: Date) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
|
|
|
|
|
|
|
|
|
|
+/** 默认近 12 个自然月(本月 1 号 ~ 今天) */
|
|
|
const setDefaultRange = () => {
|
|
const setDefaultRange = () => {
|
|
|
const now = new Date()
|
|
const now = new Date()
|
|
|
const start = new Date(now.getFullYear(), now.getMonth() - 11, 1)
|
|
const start = new Date(now.getFullYear(), now.getMonth() - 11, 1)
|
|
|
dateRange.value = [fmtDate(start), fmtDate(now)]
|
|
dateRange.value = [fmtDate(start), fmtDate(now)]
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-const reasonLabel = (bucket: MoveoutReasonBucket) =>
|
|
|
|
|
- bucket.reasonValue === null || bucket.reasonValue === undefined || bucket.reasonValue === ''
|
|
|
|
|
- ? '未设置'
|
|
|
|
|
- : getDictLabel(DICT_TYPE.BACK_CAUSE_ARR, bucket.reasonValue) || bucket.reasonLabel || '其他'
|
|
|
|
|
|
|
+const formatNum = (val: number | null | undefined) =>
|
|
|
|
|
+ val === null || val === undefined ? '-' : Number(val).toFixed(1)
|
|
|
|
|
+const formatRate = (val: number | null | undefined) =>
|
|
|
|
|
+ val === null || val === undefined ? '-' : `${Number(val).toFixed(1)}%`
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 退住原因翻译:后端 reason 返回 back_cause_arr 字典原值(如 "1"),
|
|
|
|
|
+ * 文档也兼容直接返回中文文本;字典查不到时原样展示,空值归「未填写」。
|
|
|
|
|
+ */
|
|
|
|
|
+const reasonText = (reason: string | null | undefined) => {
|
|
|
|
|
+ if (reason === null || reason === undefined || reason === '') return '未填写'
|
|
|
|
|
+ return getDictLabel(DICT_TYPE.BACK_CAUSE_ARR, reason) || String(reason)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------------- 多机构合计 ---------------- */
|
|
|
|
|
+
|
|
|
|
|
+interface Summary {
|
|
|
|
|
+ total: number
|
|
|
|
|
+ avgStayDays: number | null
|
|
|
|
|
+ avgStayMonths: number | null
|
|
|
|
|
+ topReason: string | null
|
|
|
|
|
+ topReasonCount: number | null
|
|
|
|
|
+ topReasonRate: number | null
|
|
|
|
|
+ reasonDistribution: RetreatReasonStat[]
|
|
|
|
|
+ months: string[]
|
|
|
|
|
+ /** month -> reason -> count */
|
|
|
|
|
+ monthlyMatrix: Record<string, Record<string, number>>
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const emptySummary = (): Summary => ({
|
|
|
|
|
+ total: 0,
|
|
|
|
|
+ avgStayDays: null,
|
|
|
|
|
+ avgStayMonths: null,
|
|
|
|
|
+ topReason: null,
|
|
|
|
|
+ topReasonCount: null,
|
|
|
|
|
+ topReasonRate: null,
|
|
|
|
|
+ reasonDistribution: [],
|
|
|
|
|
+ months: [],
|
|
|
|
|
+ monthlyMatrix: {}
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+/** 加权平均:权重为对应人数;全为空返回 null */
|
|
|
|
|
+const weightedAvg = (pairs: { value: number | null; weight: number }[]) => {
|
|
|
|
|
+ let sum = 0
|
|
|
|
|
+ let w = 0
|
|
|
|
|
+ pairs.forEach((p) => {
|
|
|
|
|
+ if (p.value !== null && p.value !== undefined && p.weight > 0) {
|
|
|
|
|
+ sum += p.value * p.weight
|
|
|
|
|
+ w += p.weight
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ return w > 0 ? Number((sum / w).toFixed(1)) : null
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 将所选机构数据合计为一份(单机构时即其自身) */
|
|
|
|
|
+const aggregate = (list: RetreatReasonCampusStat[]): Summary => {
|
|
|
|
|
+ if (!list.length) return emptySummary()
|
|
|
|
|
+ const total = list.reduce((s, i) => s + (Number(i.retreatTotalCount) || 0), 0)
|
|
|
|
|
+ const avgStayDays = weightedAvg(
|
|
|
|
|
+ list.map((i) => ({ value: i.avgStayDays, weight: i.retreatTotalCount }))
|
|
|
|
|
+ )
|
|
|
|
|
+ const avgStayMonths = weightedAvg(
|
|
|
|
|
+ list.map((i) => ({ value: i.avgStayMonths, weight: i.retreatTotalCount }))
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ // 原因分布:同名原因人数相加,平均时长按原因人数加权
|
|
|
|
|
+ const reasonMap = new Map<
|
|
|
|
|
+ string,
|
|
|
|
|
+ {
|
|
|
|
|
+ count: number
|
|
|
|
|
+ dayPairs: { value: number | null; weight: number }[]
|
|
|
|
|
+ monthPairs: { value: number | null; weight: number }[]
|
|
|
|
|
+ }
|
|
|
|
|
+ >()
|
|
|
|
|
+ // 月份并集(后端已按原因补 0)
|
|
|
|
|
+ const monthSet = new Set<string>()
|
|
|
|
|
+ const monthlyMatrix: Record<string, Record<string, number>> = {}
|
|
|
|
|
+
|
|
|
|
|
+ list.forEach((campus) => {
|
|
|
|
|
+ ;(campus.reasonDistribution || []).forEach((r) => {
|
|
|
|
|
+ const key = r.reason || '未填写'
|
|
|
|
|
+ const cur = reasonMap.get(key) || { count: 0, dayPairs: [], monthPairs: [] }
|
|
|
|
|
+ cur.count += Number(r.count) || 0
|
|
|
|
|
+ cur.dayPairs.push({ value: r.avgStayDays, weight: r.count })
|
|
|
|
|
+ cur.monthPairs.push({ value: r.avgStayMonths, weight: r.count })
|
|
|
|
|
+ reasonMap.set(key, cur)
|
|
|
|
|
+ })
|
|
|
|
|
+ ;(campus.monthlyTrend || []).forEach((m) => {
|
|
|
|
|
+ monthSet.add(m.month)
|
|
|
|
|
+ if (!monthlyMatrix[m.month]) monthlyMatrix[m.month] = {}
|
|
|
|
|
+ ;(m.reasons || []).forEach((rc) => {
|
|
|
|
|
+ const key = rc.reason || '未填写'
|
|
|
|
|
+ monthlyMatrix[m.month][key] = (monthlyMatrix[m.month][key] || 0) + (Number(rc.count) || 0)
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ const reasonDistribution: RetreatReasonStat[] = Array.from(reasonMap.entries())
|
|
|
|
|
+ .map(([reason, v]) => {
|
|
|
|
|
+ const avgDays = weightedAvg(v.dayPairs)
|
|
|
|
|
+ const avgMonths = weightedAvg(v.monthPairs)
|
|
|
|
|
+ return {
|
|
|
|
|
+ reason,
|
|
|
|
|
+ count: v.count,
|
|
|
|
|
+ rate: total > 0 ? Number(((v.count / total) * 100).toFixed(1)) : 0,
|
|
|
|
|
+ avgStayDays: avgDays,
|
|
|
|
|
+ avgStayMonths: avgMonths
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ .sort((a, b) => b.count - a.count)
|
|
|
|
|
+
|
|
|
|
|
+ const months = Array.from(monthSet).sort()
|
|
|
|
|
+ const top = reasonDistribution[0] || null
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ total,
|
|
|
|
|
+ avgStayDays,
|
|
|
|
|
+ avgStayMonths,
|
|
|
|
|
+ topReason: top?.reason ?? null,
|
|
|
|
|
+ topReasonCount: top?.count ?? null,
|
|
|
|
|
+ topReasonRate: top?.rate ?? null,
|
|
|
|
|
+ reasonDistribution,
|
|
|
|
|
+ months,
|
|
|
|
|
+ monthlyMatrix
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const summary = computed<Summary>(() => aggregate(stats.value))
|
|
|
|
|
+const hasData = computed(() => stats.value.length > 0 && summary.value.total > 0)
|
|
|
|
|
+
|
|
|
|
|
+/** 机构 × 原因明细行(导出复用) */
|
|
|
|
|
+const reasonRows = computed(() =>
|
|
|
|
|
+ stats.value.flatMap((campus) =>
|
|
|
|
|
+ (campus.reasonDistribution || []).map((r) => ({
|
|
|
|
|
+ tenantName: campus.tenantName,
|
|
|
|
|
+ reason: r.reason || '未填写',
|
|
|
|
|
+ reasonLabel: reasonText(r.reason),
|
|
|
|
|
+ count: r.count,
|
|
|
|
|
+ rate: r.rate,
|
|
|
|
|
+ avgStayDays: r.avgStayDays,
|
|
|
|
|
+ avgStayMonths: r.avgStayMonths
|
|
|
|
|
+ }))
|
|
|
|
|
+ )
|
|
|
|
|
+)
|
|
|
|
|
|
|
|
/* ---------------- KPI ---------------- */
|
|
/* ---------------- KPI ---------------- */
|
|
|
|
|
|
|
|
const kpiCards = computed(() => {
|
|
const kpiCards = computed(() => {
|
|
|
- const d = data.value
|
|
|
|
|
- const topReason = d?.reasonBuckets?.[0]
|
|
|
|
|
- const topRate = d?.totalMoveOut && topReason ? (topReason.count / d.totalMoveOut) * 100 : 0
|
|
|
|
|
|
|
+ const s = summary.value
|
|
|
return [
|
|
return [
|
|
|
- { label: '退住总人数', value: d?.totalMoveOut || 0, unit: '人', icon: 'ep:user', theme: 'blue' },
|
|
|
|
|
{
|
|
{
|
|
|
- label: '平均入住时长',
|
|
|
|
|
- value: d?.avgStayDays ?? '-',
|
|
|
|
|
- unit: d?.avgStayDays == null ? '' : '天',
|
|
|
|
|
- icon: 'ep:timer',
|
|
|
|
|
|
|
+ label: '退住总人数',
|
|
|
|
|
+ value: hasData.value ? s.total : '-',
|
|
|
|
|
+ unit: '人',
|
|
|
|
|
+ icon: 'ep:minus',
|
|
|
|
|
+ theme: 'orange'
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ label: '平均入住天数',
|
|
|
|
|
+ value: hasData.value ? formatNum(s.avgStayDays) : '-',
|
|
|
|
|
+ unit: hasData.value && s.avgStayDays !== null ? '天' : '',
|
|
|
|
|
+ icon: 'ep:calendar',
|
|
|
|
|
+ theme: 'blue'
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ label: '平均入住月数',
|
|
|
|
|
+ value: hasData.value ? formatNum(s.avgStayMonths) : '-',
|
|
|
|
|
+ unit: hasData.value && s.avgStayMonths !== null ? '月' : '',
|
|
|
|
|
+ icon: 'ep:date',
|
|
|
theme: 'cyan'
|
|
theme: 'cyan'
|
|
|
},
|
|
},
|
|
|
{
|
|
{
|
|
|
label: 'TOP1 退住原因',
|
|
label: 'TOP1 退住原因',
|
|
|
- value: topReason ? reasonLabel(topReason) : '-',
|
|
|
|
|
|
|
+ value: hasData.value ? reasonText(s.topReason) : '-',
|
|
|
unit: '',
|
|
unit: '',
|
|
|
- icon: 'ep:warning',
|
|
|
|
|
- theme: 'orange'
|
|
|
|
|
- },
|
|
|
|
|
- {
|
|
|
|
|
- label: 'TOP1 原因占比',
|
|
|
|
|
- value: topReason ? topRate.toFixed(1) : '-',
|
|
|
|
|
- unit: topReason ? '%' : '',
|
|
|
|
|
- icon: 'ep:pie-chart',
|
|
|
|
|
|
|
+ sub:
|
|
|
|
|
+ hasData.value && s.topReason
|
|
|
|
|
+ ? `${s.topReasonCount} 人 · 占比 ${formatRate(s.topReasonRate)}`
|
|
|
|
|
+ : '',
|
|
|
|
|
+ icon: 'ep:trophy',
|
|
|
theme: 'purple'
|
|
theme: 'purple'
|
|
|
}
|
|
}
|
|
|
]
|
|
]
|
|
@@ -200,189 +401,171 @@ const kpiCards = computed(() => {
|
|
|
|
|
|
|
|
/* ---------------- 图表 ---------------- */
|
|
/* ---------------- 图表 ---------------- */
|
|
|
|
|
|
|
|
-const reasonBuckets = computed(() =>
|
|
|
|
|
- (data.value?.reasonBuckets || [])
|
|
|
|
|
- .filter((b) => b.count > 0)
|
|
|
|
|
- .sort((a, b) => b.count - a.count)
|
|
|
|
|
- .map((b) => ({ ...b, displayLabel: reasonLabel(b) }))
|
|
|
|
|
-)
|
|
|
|
|
-
|
|
|
|
|
-const reasonPieOption = computed(() => ({
|
|
|
|
|
- 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: reasonBuckets.value.map((b, i) => ({
|
|
|
|
|
- name: b.displayLabel,
|
|
|
|
|
- value: b.count,
|
|
|
|
|
- itemStyle: { color: PIE_COLORS[i % PIE_COLORS.length] }
|
|
|
|
|
- }))
|
|
|
|
|
- }
|
|
|
|
|
- ]
|
|
|
|
|
-}))
|
|
|
|
|
-
|
|
|
|
|
-const avgStayBarOption = computed(() => {
|
|
|
|
|
- const list = reasonBuckets.value.filter((b) => b.avgStayDays != null)
|
|
|
|
|
|
|
+/** 图表配色(原因超过 10 个时循环取色) */
|
|
|
|
|
+const CHART_COLORS = [
|
|
|
|
|
+ '#409eff',
|
|
|
|
|
+ '#67c23a',
|
|
|
|
|
+ '#e6a23c',
|
|
|
|
|
+ '#f56c6c',
|
|
|
|
|
+ '#909399',
|
|
|
|
|
+ '#8b5cf6',
|
|
|
|
|
+ '#13c2c2',
|
|
|
|
|
+ '#f59e0b',
|
|
|
|
|
+ '#ec4899',
|
|
|
|
|
+ '#10b981'
|
|
|
|
|
+]
|
|
|
|
|
+const colorOf = (index: number) => CHART_COLORS[index % CHART_COLORS.length]
|
|
|
|
|
+
|
|
|
|
|
+/** 退住原因分布:横向条形图 */
|
|
|
|
|
+const reasonChartOption = computed(() => {
|
|
|
|
|
+ const list = [...summary.value.reasonDistribution].sort((a, b) => a.count - b.count)
|
|
|
return {
|
|
return {
|
|
|
tooltip: {
|
|
tooltip: {
|
|
|
trigger: 'axis',
|
|
trigger: 'axis',
|
|
|
axisPointer: { type: 'shadow' },
|
|
axisPointer: { type: 'shadow' },
|
|
|
- formatter: '{b}:{c} 天'
|
|
|
|
|
|
|
+ formatter: (params: any[]) => {
|
|
|
|
|
+ const item = list[params[0]?.dataIndex ?? 0]
|
|
|
|
|
+ if (!item) return ''
|
|
|
|
|
+ return `${reasonText(item.reason)}<br/>退住 ${item.count} 人(${formatRate(
|
|
|
|
|
+ item.rate
|
|
|
|
|
+ )})<br/>平均入住 ${formatNum(item.avgStayDays)} 天 / ${formatNum(item.avgStayMonths)} 月`
|
|
|
|
|
+ }
|
|
|
},
|
|
},
|
|
|
- grid: { left: 110, right: 48, top: 16, bottom: 16, containLabel: true },
|
|
|
|
|
- xAxis: { type: 'value', name: '天' },
|
|
|
|
|
|
|
+ grid: { left: 8, right: 56, top: 16, bottom: 16, containLabel: true },
|
|
|
|
|
+ xAxis: { type: 'value', minInterval: 1, name: '人' },
|
|
|
yAxis: {
|
|
yAxis: {
|
|
|
type: 'category',
|
|
type: 'category',
|
|
|
- data: list.map((b) => b.displayLabel),
|
|
|
|
|
- axisLabel: { width: 100, overflow: 'truncate' }
|
|
|
|
|
|
|
+ data: list.map((i) => reasonText(i.reason)),
|
|
|
|
|
+ axisLabel: { width: 110, overflow: 'truncate' }
|
|
|
},
|
|
},
|
|
|
series: [
|
|
series: [
|
|
|
{
|
|
{
|
|
|
|
|
+ name: '退住人数',
|
|
|
type: 'bar',
|
|
type: 'bar',
|
|
|
- barWidth: 18,
|
|
|
|
|
- data: list.map((b) => b.avgStayDays),
|
|
|
|
|
- itemStyle: { borderRadius: [0, 4, 4, 0], color: '#409eff' },
|
|
|
|
|
- label: { show: true, position: 'right', formatter: '{c} 天' }
|
|
|
|
|
|
|
+ barMaxWidth: 22,
|
|
|
|
|
+ data: list.map((i, idx) => ({
|
|
|
|
|
+ value: i.count,
|
|
|
|
|
+ itemStyle: { color: colorOf(list.length - 1 - idx), borderRadius: [0, 4, 4, 0] }
|
|
|
|
|
+ })),
|
|
|
|
|
+ label: {
|
|
|
|
|
+ show: true,
|
|
|
|
|
+ position: 'right',
|
|
|
|
|
+ formatter: (p: any) => `${p.value} 人`
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
]
|
|
]
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
-const monthTrendOption = computed(() => {
|
|
|
|
|
- const items = data.value?.monthTrend || []
|
|
|
|
|
- const reasons = reasonBuckets.value.slice(0, 6).map((b) => ({
|
|
|
|
|
- key: String(b.reasonValue ?? '未设置'),
|
|
|
|
|
- label: b.displayLabel,
|
|
|
|
|
- color: PIE_COLORS[reasonBuckets.value.indexOf(b) % PIE_COLORS.length]
|
|
|
|
|
- }))
|
|
|
|
|
|
|
+/** 月度趋势:原因堆叠柱 + 总数折线 */
|
|
|
|
|
+const trendChartOption = computed(() => {
|
|
|
|
|
+ const s = summary.value
|
|
|
|
|
+ const reasons = s.reasonDistribution.map((r) => r.reason)
|
|
|
|
|
+ const months = s.months.map((m) => m.slice(5))
|
|
|
return {
|
|
return {
|
|
|
tooltip: {
|
|
tooltip: {
|
|
|
trigger: 'axis',
|
|
trigger: 'axis',
|
|
|
- axisPointer: { type: 'shadow' }
|
|
|
|
|
|
|
+ axisPointer: { type: 'shadow' },
|
|
|
|
|
+ formatter: (params: any[]) => {
|
|
|
|
|
+ const idx = params[0]?.dataIndex ?? 0
|
|
|
|
|
+ const month = s.months[idx]
|
|
|
|
|
+ const total = (params.find((p) => p.seriesName === '当月总数')?.value as number) ?? 0
|
|
|
|
|
+ const lines = params
|
|
|
|
|
+ .filter((p) => p.seriesName !== '当月总数' && p.value)
|
|
|
|
|
+ .map((p) => `${p.marker}${p.seriesName}:${p.value} 人`)
|
|
|
|
|
+ return `${month}(共 ${total} 人)<br/>${lines.join('<br/>')}`
|
|
|
|
|
+ }
|
|
|
},
|
|
},
|
|
|
- legend: { data: [...reasons.map((r) => r.label), '当月合计'], top: 0 },
|
|
|
|
|
- grid: { left: 48, right: 48, top: 40, bottom: 30 },
|
|
|
|
|
- xAxis: { type: 'category', data: items.map((i) => i.month) },
|
|
|
|
|
- yAxis: { type: 'value', name: '人', minInterval: 1 },
|
|
|
|
|
|
|
+ legend: { data: [...reasons.map((r) => reasonText(r)), '当月总数'], top: 0, type: 'scroll' },
|
|
|
|
|
+ grid: { left: 48, right: 24, top: 36, bottom: 30 },
|
|
|
|
|
+ xAxis: { type: 'category', data: months },
|
|
|
|
|
+ yAxis: { type: 'value', minInterval: 1, name: '人' },
|
|
|
series: [
|
|
series: [
|
|
|
- ...reasons.map((r) => ({
|
|
|
|
|
- name: r.label,
|
|
|
|
|
|
|
+ ...reasons.map((reason, ri) => ({
|
|
|
|
|
+ name: reasonText(reason),
|
|
|
type: 'bar',
|
|
type: 'bar',
|
|
|
stack: 'reason',
|
|
stack: 'reason',
|
|
|
- barWidth: 18,
|
|
|
|
|
- data: items.map((i) => i.reasonMap?.[r.key] || 0),
|
|
|
|
|
- itemStyle: { color: r.color }
|
|
|
|
|
|
|
+ barMaxWidth: 30,
|
|
|
|
|
+ itemStyle: { color: colorOf(ri) },
|
|
|
|
|
+ emphasis: { focus: 'series' },
|
|
|
|
|
+ data: s.months.map((m) => s.monthlyMatrix[m]?.[reason] ?? 0)
|
|
|
})),
|
|
})),
|
|
|
{
|
|
{
|
|
|
- name: '当月合计',
|
|
|
|
|
|
|
+ name: '当月总数',
|
|
|
type: 'line',
|
|
type: 'line',
|
|
|
- data: items.map((i) => i.total),
|
|
|
|
|
smooth: true,
|
|
smooth: true,
|
|
|
- symbolSize: 6,
|
|
|
|
|
- lineStyle: { width: 2.5, color: '#e6a23c' },
|
|
|
|
|
- itemStyle: { color: '#e6a23c' }
|
|
|
|
|
|
|
+ symbolSize: 7,
|
|
|
|
|
+ lineStyle: { width: 2.5, color: '#303133' },
|
|
|
|
|
+ itemStyle: { color: '#303133' },
|
|
|
|
|
+ data: s.months.map((m) =>
|
|
|
|
|
+ Object.values(s.monthlyMatrix[m] || {}).reduce((sum, v) => sum + v, 0)
|
|
|
|
|
+ )
|
|
|
}
|
|
}
|
|
|
]
|
|
]
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
-const PIE_COLORS = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#8b5cf6', '#36a866', '#909399']
|
|
|
|
|
-
|
|
|
|
|
-/* ---------------- 院区明细表 ---------------- */
|
|
|
|
|
-
|
|
|
|
|
-const reasonColumns = computed(() =>
|
|
|
|
|
- reasonBuckets.value.map((b) => ({
|
|
|
|
|
- key: `reason_${b.reasonValue ?? 'null'}`,
|
|
|
|
|
- label: b.displayLabel
|
|
|
|
|
- }))
|
|
|
|
|
-)
|
|
|
|
|
-
|
|
|
|
|
-const campusRows = computed(() =>
|
|
|
|
|
- (data.value?.campusStats || [])
|
|
|
|
|
- .map((c) => {
|
|
|
|
|
- const row: Record<string, any> = {
|
|
|
|
|
- tenantId: c.tenantId,
|
|
|
|
|
- tenantName: c.tenantName || `院区${c.tenantId}`,
|
|
|
|
|
- total: c.total,
|
|
|
|
|
- avgStayDays: c.avgStayDays
|
|
|
|
|
- }
|
|
|
|
|
- c.reasonBuckets.forEach((b) => {
|
|
|
|
|
- row[`reason_${b.reasonValue ?? 'null'}`] = b.count || 0
|
|
|
|
|
- })
|
|
|
|
|
- return row
|
|
|
|
|
- })
|
|
|
|
|
- .sort((a, b) => b.total - a.total)
|
|
|
|
|
-)
|
|
|
|
|
-
|
|
|
|
|
-/* ---------------- 加载与导出 ---------------- */
|
|
|
|
|
|
|
+/* ---------------- 交互 ---------------- */
|
|
|
|
|
|
|
|
-const loadAll = async () => {
|
|
|
|
|
- const ids = selectedCampusIds.value.length
|
|
|
|
|
- ? selectedCampusIds.value
|
|
|
|
|
- : campusOptions.map((item) => item.value)
|
|
|
|
|
|
|
+const handleQuery = async () => {
|
|
|
loading.value = true
|
|
loading.value = true
|
|
|
loadFailed.value = false
|
|
loadFailed.value = false
|
|
|
try {
|
|
try {
|
|
|
- const params: any = { tenantIds: ids }
|
|
|
|
|
- if (dateRange.value && dateRange.value.length === 2) {
|
|
|
|
|
|
|
+ const params: { tenantIds?: number[]; startDate?: string; endDate?: string } = {}
|
|
|
|
|
+ const ids = selectedCampusIds.value.length
|
|
|
|
|
+ ? selectedCampusIds.value
|
|
|
|
|
+ : campusOptions.map((i) => i.value)
|
|
|
|
|
+ if (ids.length) params.tenantIds = ids
|
|
|
|
|
+ if (dateRange.value?.length === 2) {
|
|
|
params.startDate = dateRange.value[0]
|
|
params.startDate = dateRange.value[0]
|
|
|
params.endDate = dateRange.value[1]
|
|
params.endDate = dateRange.value[1]
|
|
|
}
|
|
}
|
|
|
- data.value = await fetchMoveoutAnalysis(params)
|
|
|
|
|
- const now = new Date()
|
|
|
|
|
- updateTime.value = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())} ${pad2(
|
|
|
|
|
- now.getHours()
|
|
|
|
|
- )}:${pad2(now.getMinutes())}`
|
|
|
|
|
- } catch {
|
|
|
|
|
|
|
+ stats.value = await fetchRetreatReasonStats(params)
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ console.error('[retreat-reason-stats] 加载失败', e)
|
|
|
|
|
+ stats.value = []
|
|
|
loadFailed.value = true
|
|
loadFailed.value = true
|
|
|
- data.value = null
|
|
|
|
|
} finally {
|
|
} finally {
|
|
|
loading.value = false
|
|
loading.value = false
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-const handleQuery = () => {
|
|
|
|
|
- loadAll()
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
const handleReset = () => {
|
|
const handleReset = () => {
|
|
|
selectedCampusIds.value = campusOptions.map((item) => item.value)
|
|
selectedCampusIds.value = campusOptions.map((item) => item.value)
|
|
|
setDefaultRange()
|
|
setDefaultRange()
|
|
|
- loadAll()
|
|
|
|
|
|
|
+ handleQuery()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+const sortBy = (key: 'avgStayDays' | 'avgStayMonths') => (a: any, b: any) =>
|
|
|
|
|
+ (a[key] ?? -Infinity) - (b[key] ?? -Infinity)
|
|
|
|
|
+
|
|
|
const handleExport = () => {
|
|
const handleExport = () => {
|
|
|
- const headers = [
|
|
|
|
|
- { key: 'tenantName', title: '机构', width: 20 },
|
|
|
|
|
- { key: 'total', title: '退住人数', width: 12 },
|
|
|
|
|
- { key: 'avgStayDays', title: '平均入住时长(天)', width: 16 },
|
|
|
|
|
- ...reasonColumns.value.map((c) => ({ key: c.key, title: c.label, width: 12 }))
|
|
|
|
|
- ]
|
|
|
|
|
|
|
+ if (!reasonRows.value.length) return
|
|
|
exportWithCustomHeaders(
|
|
exportWithCustomHeaders(
|
|
|
- campusRows.value,
|
|
|
|
|
- headers,
|
|
|
|
|
- `退住原因与平均入住时长_${new Date().toISOString().slice(0, 10)}.xlsx`,
|
|
|
|
|
- '院区退住明细'
|
|
|
|
|
|
|
+ reasonRows.value,
|
|
|
|
|
+ [
|
|
|
|
|
+ { key: 'tenantName', title: '机构', width: 20 },
|
|
|
|
|
+ { key: 'reasonLabel', title: '退住原因', width: 16 },
|
|
|
|
|
+ { key: 'count', title: '退住人数', width: 10 },
|
|
|
|
|
+ { key: 'rate', title: '占比(%)', width: 10 },
|
|
|
|
|
+ { key: 'avgStayDays', title: '平均入住天数', width: 14 },
|
|
|
|
|
+ { key: 'avgStayMonths', title: '平均入住月数', width: 14 }
|
|
|
|
|
+ ],
|
|
|
|
|
+ `退住原因与平均入住周期_${new Date().toISOString().slice(0, 10)}.xlsx`,
|
|
|
|
|
+ '退住原因明细'
|
|
|
)
|
|
)
|
|
|
message.success('导出成功')
|
|
message.success('导出成功')
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
onMounted(() => {
|
|
|
setDefaultRange()
|
|
setDefaultRange()
|
|
|
- loadAll()
|
|
|
|
|
|
|
+ handleQuery()
|
|
|
})
|
|
})
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
<style lang="scss" scoped>
|
|
|
.oa-page {
|
|
.oa-page {
|
|
|
.page-header {
|
|
.page-header {
|
|
|
- display: flex;
|
|
|
|
|
- align-items: center;
|
|
|
|
|
- justify-content: space-between;
|
|
|
|
|
margin-bottom: 14px;
|
|
margin-bottom: 14px;
|
|
|
|
|
|
|
|
.header-title {
|
|
.header-title {
|
|
@@ -398,17 +581,6 @@ onMounted(() => {
|
|
|
color: #409eff;
|
|
color: #409eff;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- .header-extra {
|
|
|
|
|
- display: flex;
|
|
|
|
|
- align-items: center;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- .update-time {
|
|
|
|
|
- margin-right: 14px;
|
|
|
|
|
- font-size: 13px;
|
|
|
|
|
- color: #909399;
|
|
|
|
|
- }
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
.filter-form {
|
|
.filter-form {
|
|
@@ -436,12 +608,9 @@ onMounted(() => {
|
|
|
@media (max-width: 1200px) {
|
|
@media (max-width: 1200px) {
|
|
|
grid-template-columns: repeat(2, 1fr);
|
|
grid-template-columns: repeat(2, 1fr);
|
|
|
}
|
|
}
|
|
|
- @media (max-width: 768px) {
|
|
|
|
|
- grid-template-columns: 1fr;
|
|
|
|
|
- }
|
|
|
|
|
|
|
|
|
|
.kpi-card {
|
|
.kpi-card {
|
|
|
- padding: 16px 18px;
|
|
|
|
|
|
|
+ padding: 15px 16px;
|
|
|
border-radius: 8px;
|
|
border-radius: 8px;
|
|
|
color: #fff;
|
|
color: #fff;
|
|
|
|
|
|
|
@@ -458,7 +627,7 @@ onMounted(() => {
|
|
|
|
|
|
|
|
.kpi-value {
|
|
.kpi-value {
|
|
|
margin-top: 8px;
|
|
margin-top: 8px;
|
|
|
- font-size: 28px;
|
|
|
|
|
|
|
+ font-size: 26px;
|
|
|
font-weight: 700;
|
|
font-weight: 700;
|
|
|
line-height: 1.15;
|
|
line-height: 1.15;
|
|
|
|
|
|
|
@@ -468,11 +637,22 @@ onMounted(() => {
|
|
|
font-weight: 400;
|
|
font-weight: 400;
|
|
|
opacity: 0.85;
|
|
opacity: 0.85;
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ .kpi-sub {
|
|
|
|
|
+ display: block;
|
|
|
|
|
+ margin-top: 4px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ font-weight: 400;
|
|
|
|
|
+ opacity: 0.9;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
&.blue {
|
|
&.blue {
|
|
|
background: linear-gradient(135deg, #5b9bff 0%, #2f7cf6 100%);
|
|
background: linear-gradient(135deg, #5b9bff 0%, #2f7cf6 100%);
|
|
|
}
|
|
}
|
|
|
|
|
+ &.green {
|
|
|
|
|
+ background: linear-gradient(135deg, #62d391 0%, #36a866 100%);
|
|
|
|
|
+ }
|
|
|
&.cyan {
|
|
&.cyan {
|
|
|
background: linear-gradient(135deg, #4fd0e0 0%, #1fa9bf 100%);
|
|
background: linear-gradient(135deg, #4fd0e0 0%, #1fa9bf 100%);
|
|
|
}
|
|
}
|
|
@@ -482,11 +662,13 @@ onMounted(() => {
|
|
|
&.purple {
|
|
&.purple {
|
|
|
background: linear-gradient(135deg, #b794f6 0%, #8b5cf6 100%);
|
|
background: linear-gradient(135deg, #b794f6 0%, #8b5cf6 100%);
|
|
|
}
|
|
}
|
|
|
|
|
+ &.grey {
|
|
|
|
|
+ background: linear-gradient(135deg, #a8abb2 0%, #7a7f8a 100%);
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
.chart-row,
|
|
.chart-row,
|
|
|
- .chart-wrap,
|
|
|
|
|
.table-wrap {
|
|
.table-wrap {
|
|
|
margin-top: 12px;
|
|
margin-top: 12px;
|
|
|
}
|
|
}
|
|
@@ -495,11 +677,7 @@ onMounted(() => {
|
|
|
display: flex;
|
|
display: flex;
|
|
|
align-items: center;
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
justify-content: center;
|
|
|
- height: 320px;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- .donut-wrap {
|
|
|
|
|
- position: relative;
|
|
|
|
|
|
|
+ height: 360px;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
.panel-title {
|
|
.panel-title {
|
|
@@ -528,5 +706,12 @@ onMounted(() => {
|
|
|
color: #a8abb2;
|
|
color: #a8abb2;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ .num-zero {
|
|
|
|
|
+ color: #909399;
|
|
|
|
|
+ }
|
|
|
|
|
+ .ml5 {
|
|
|
|
|
+ margin-left: 5px;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
</style>
|
|
</style>
|