Bläddra i källkod

同步更新电子合同优化功能

chenjun 1 dag sedan
förälder
incheckning
da848b5f47

+ 6 - 3
src/views/operation-analysis/occupancy/checkout-analysis/index.vue

@@ -1,7 +1,10 @@
 <template>
-  <ReportPlaceholder status="backend" desc="按退住原因(9类)、院区、月份统计退住量与平均/中位入住周期。" />
+  <!-- 菜单当前配置的组件路径为 moveout-reason/index,两处路由复用同一真实报表实现 -->
+  <MoveoutReason />
 </template>
 
-<script setup lang="ts">
-import ReportPlaceholder from '../../components/ReportPlaceholder.vue'
+<script lang="ts" setup>
+import MoveoutReason from '../moveout-reason/index.vue'
+
+defineOptions({ name: 'OaOccupancyCheckoutAnalysis' })
 </script>

+ 387 - 202
src/views/operation-analysis/occupancy/moveout-reason/index.vue

@@ -1,20 +1,16 @@
 <template>
-  <div v-loading="loading" class="oa-page" element-loading-text="正在加载退住分析数据…">
+  <div v-loading="loading" class="oa-page" element-loading-text="正在加载退住原因统计…">
     <ContentWrap>
       <div class="page-header">
         <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>
 
       <!-- 筛选区 -->
       <el-form :inline="true" class="filter-form" @submit.prevent>
-        <el-form-item label="统计院区">
+        <el-form-item label="院区">
           <el-select
             v-model="selectedCampusIds"
             multiple
@@ -58,7 +54,7 @@
         type="warning"
         :closable="false"
         show-icon
-        title="退住分析数据加载失败,请稍后刷新重试"
+        title="退住原因统计加载失败,请稍后刷新重试"
         class="api-tip"
       />
 
@@ -72,6 +68,7 @@
           <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>
@@ -80,63 +77,118 @@
     <el-row :gutter="12" class="chart-row">
       <el-col :xs="24" :lg="10">
         <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>
+          <Echart
+            v-if="hasData && summary.reasonDistribution.length"
+            :options="reasonChartOption"
+            height="400px"
+          />
+          <el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
         </ContentWrap>
       </el-col>
       <el-col :xs="24" :lg="14">
         <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" />
         </ContentWrap>
       </el-col>
     </el-row>
 
-    <ContentWrap class="chart-wrap">
+    <ContentWrap class="table-wrap">
       <div class="panel-title">
-        月度退住趋势
-        <span class="title-tip">按退住原因统计</span>
+        各院区汇总
+        <span class="title-tip">平均入住周期按退住长者入住时长计算</span>
       </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 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
-          v-for="col in reasonColumns"
-          :key="col.key"
-          :prop="col.key"
-          :label="col.label"
+          prop="retreatTotalCount"
+          label="退住总人数"
           min-width="100"
           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>
     </ContentWrap>
   </div>
 </template>
 
 <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 { 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 {
-  fetchMoveoutAnalysis,
-  type MoveoutAnalysisData,
-  type MoveoutReasonBucket
+  fetchRetreatReasonStats,
+  type RetreatReasonCampusStat,
+  type RetreatReasonStat
 } from '../occupancy-api'
 
 defineOptions({ name: 'OaOccupancyMoveoutReason' })
@@ -146,53 +198,202 @@ const campusOptions = getCampusOptions()
 
 const loading = ref(false)
 const loadFailed = ref(false)
-const updateTime = ref('')
 const selectedCampusIds = ref<number[]>(campusOptions.map((item) => item.value))
 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 fmtDate = (d: Date) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
 
+/** 默认近 12 个自然月(本月 1 号 ~ 今天) */
 const setDefaultRange = () => {
   const now = new Date()
   const start = new Date(now.getFullYear(), now.getMonth() - 11, 1)
   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 ---------------- */
 
 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 [
-    { 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'
     },
     {
       label: 'TOP1 退住原因',
-      value: topReason ? reasonLabel(topReason) : '-',
+      value: hasData.value ? reasonText(s.topReason) : '-',
       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'
     }
   ]
@@ -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 {
     tooltip: {
       trigger: 'axis',
       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: {
       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: [
       {
+        name: '退住人数',
         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 {
     tooltip: {
       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: [
-      ...reasons.map((r) => ({
-        name: r.label,
+      ...reasons.map((reason, ri) => ({
+        name: reasonText(reason),
         type: 'bar',
         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',
-        data: items.map((i) => i.total),
         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
   loadFailed.value = false
   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.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
-    data.value = null
   } finally {
     loading.value = false
   }
 }
 
-const handleQuery = () => {
-  loadAll()
-}
-
 const handleReset = () => {
   selectedCampusIds.value = campusOptions.map((item) => item.value)
   setDefaultRange()
-  loadAll()
+  handleQuery()
 }
 
+const sortBy = (key: 'avgStayDays' | 'avgStayMonths') => (a: any, b: any) =>
+  (a[key] ?? -Infinity) - (b[key] ?? -Infinity)
+
 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(
-    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('导出成功')
 }
 
 onMounted(() => {
   setDefaultRange()
-  loadAll()
+  handleQuery()
 })
 </script>
 
 <style lang="scss" scoped>
 .oa-page {
   .page-header {
-    display: flex;
-    align-items: center;
-    justify-content: space-between;
     margin-bottom: 14px;
 
     .header-title {
@@ -398,17 +581,6 @@ onMounted(() => {
         color: #409eff;
       }
     }
-
-    .header-extra {
-      display: flex;
-      align-items: center;
-    }
-
-    .update-time {
-      margin-right: 14px;
-      font-size: 13px;
-      color: #909399;
-    }
   }
 
   .filter-form {
@@ -436,12 +608,9 @@ onMounted(() => {
     @media (max-width: 1200px) {
       grid-template-columns: repeat(2, 1fr);
     }
-    @media (max-width: 768px) {
-      grid-template-columns: 1fr;
-    }
 
     .kpi-card {
-      padding: 16px 18px;
+      padding: 15px 16px;
       border-radius: 8px;
       color: #fff;
 
@@ -458,7 +627,7 @@ onMounted(() => {
 
       .kpi-value {
         margin-top: 8px;
-        font-size: 28px;
+        font-size: 26px;
         font-weight: 700;
         line-height: 1.15;
 
@@ -468,11 +637,22 @@ onMounted(() => {
           font-weight: 400;
           opacity: 0.85;
         }
+
+        .kpi-sub {
+          display: block;
+          margin-top: 4px;
+          font-size: 12px;
+          font-weight: 400;
+          opacity: 0.9;
+        }
       }
 
       &.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%);
       }
@@ -482,11 +662,13 @@ onMounted(() => {
       &.purple {
         background: linear-gradient(135deg, #b794f6 0%, #8b5cf6 100%);
       }
+      &.grey {
+        background: linear-gradient(135deg, #a8abb2 0%, #7a7f8a 100%);
+      }
     }
   }
 
   .chart-row,
-  .chart-wrap,
   .table-wrap {
     margin-top: 12px;
   }
@@ -495,11 +677,7 @@ onMounted(() => {
     display: flex;
     align-items: center;
     justify-content: center;
-    height: 320px;
-  }
-
-  .donut-wrap {
-    position: relative;
+    height: 360px;
   }
 
   .panel-title {
@@ -528,5 +706,12 @@ onMounted(() => {
       color: #a8abb2;
     }
   }
+
+  .num-zero {
+    color: #909399;
+  }
+  .ml5 {
+    margin-left: 5px;
+  }
 }
 </style>

+ 147 - 10
src/views/operation-analysis/occupancy/occupancy-api.ts

@@ -543,6 +543,81 @@ export const fetchContractExpiry = async (
   }
 }
 
+/* ============================== 三期:退住原因与平均入住时长(按机构分组) ============================== */
+
+/** 单个退住原因的统计(含该原因平均入住时长) */
+export interface RetreatReasonStat {
+  /** 退住原因,未填写时为「未填写」 */
+  reason: string
+  /** 退住人数 */
+  count: number
+  /** 占比(百分比,保留 1 位小数) */
+  rate: number | null
+  /** 该原因平均入住天数(保留 1 位小数) */
+  avgStayDays: number | null
+  /** 该原因平均入住月数(保留 1 位小数) */
+  avgStayMonths: number | null
+}
+
+/** 月度趋势中的原因人数项 */
+export interface RetreatReasonCount {
+  reason: string
+  count: number
+}
+
+/** 月度退住趋势(按原因拆分,无数据月份后端已按原因补 0) */
+export interface RetreatMonthlyTrend {
+  /** yyyy-MM */
+  month: string
+  /** 当月退住总人数 */
+  count: number
+  /** 当月各退住原因人数(顺序与 reasonDistribution 一致) */
+  reasons: RetreatReasonCount[]
+}
+
+/** 单个机构(院区)的退住原因统计 */
+export interface RetreatReasonCampusStat {
+  tenantId: number
+  tenantName: string
+  startDate: string
+  endDate: string
+  /** 退住总人数 */
+  retreatTotalCount: number
+  /** 平均入住天数(无有效入住时间时为 null) */
+  avgStayDays: number | null
+  /** 平均入住月数(无有效入住时间时为 null) */
+  avgStayMonths: number | null
+  topReason: string | null
+  topReasonCount: number | null
+  topReasonRate: number | null
+  /** 退住原因分布(按人数降序) */
+  reasonDistribution: RetreatReasonStat[]
+  /** 月度退住趋势 */
+  monthlyTrend: RetreatMonthlyTrend[]
+}
+
+export interface RetreatReasonStatsParams {
+  /** 机构 ID 数组,不传取当前登录机构范围 */
+  tenantIds?: number[]
+  /** yyyy-MM-dd,含;不传取结束日期往前 11 个月的 1 号 */
+  startDate?: string
+  /** yyyy-MM-dd,含;不传取今天 */
+  endDate?: string
+}
+
+/**
+ * 退住原因与平均入住时长(按机构分组返回)
+ * GET /operation-analysis/retreat-reason/stats
+ */
+export const fetchRetreatReasonStats = async (
+  params: RetreatReasonStatsParams = {}
+): Promise<RetreatReasonCampusStat[]> => {
+  const data = await request.get({
+    url: `/operation-analysis/retreat-reason/stats${buildQuery({ orgType: 1, ...params })}`
+  })
+  return Array.isArray(data) ? data : []
+}
+
 /* ============================== 二期演示数据(后端接口上线后删除) ============================== */
 
 const getMockMoveoutAnalysisData = (): MoveoutAnalysisData => {
@@ -576,9 +651,27 @@ const getMockMoveoutAnalysisData = (): MoveoutAnalysisData => {
       { month: '2026-09', total: 6, reasonMap: { '1': 3, '2': 2, '4': 1 } }
     ],
     campusStats: [
-      { tenantId: 196, tenantName: '颐年东涌养老院', total: 24, avgStayDays: 320, reasonBuckets: reasonBuckets.map((r) => ({ ...r, count: Math.round(r.count / 4) })) },
-      { tenantId: 197, tenantName: '颐年五心养老院', total: 18, avgStayDays: 410, reasonBuckets: reasonBuckets.map((r) => ({ ...r, count: Math.round(r.count / 5) })) },
-      { tenantId: 198, tenantName: '其他院区', total: 52, avgStayDays: 365, reasonBuckets: reasonBuckets.map((r) => ({ ...r, count: Math.round(r.count / 2) })) }
+      {
+        tenantId: 196,
+        tenantName: '颐年东涌养老院',
+        total: 24,
+        avgStayDays: 320,
+        reasonBuckets: reasonBuckets.map((r) => ({ ...r, count: Math.round(r.count / 4) }))
+      },
+      {
+        tenantId: 197,
+        tenantName: '颐年五心养老院',
+        total: 18,
+        avgStayDays: 410,
+        reasonBuckets: reasonBuckets.map((r) => ({ ...r, count: Math.round(r.count / 5) }))
+      },
+      {
+        tenantId: 198,
+        tenantName: '其他院区',
+        total: 52,
+        avgStayDays: 365,
+        reasonBuckets: reasonBuckets.map((r) => ({ ...r, count: Math.round(r.count / 2) }))
+      }
     ]
   }
 }
@@ -588,7 +681,9 @@ const getMockTrialConversionData = (): TrialConversionData => {
   const convertedCount = 58
   const notConvertedCount = 20
   const inTrialCount = 8
-  const conversionRate = Number(((convertedCount / (convertedCount + notConvertedCount)) * 100).toFixed(1))
+  const conversionRate = Number(
+    ((convertedCount / (convertedCount + notConvertedCount)) * 100).toFixed(1)
+  )
   return {
     totalTrial,
     convertedCount,
@@ -616,9 +711,33 @@ const getMockTrialConversionData = (): TrialConversionData => {
       { month: '2026-09', trialCount: 3, convertedCount: 1, notConvertedCount: 2 }
     ],
     campusStats: [
-      { tenantId: 196, tenantName: '颐年东涌养老院', totalTrial: 20, convertedCount: 15, notConvertedCount: 3, conversionRate: 83.3, avgTrialDays: 16 },
-      { tenantId: 197, tenantName: '颐年五心养老院', totalTrial: 18, convertedCount: 12, notConvertedCount: 4, conversionRate: 75.0, avgTrialDays: 21 },
-      { tenantId: 198, tenantName: '其他院区', totalTrial: 48, convertedCount: 31, notConvertedCount: 13, conversionRate: 70.5, avgTrialDays: 19 }
+      {
+        tenantId: 196,
+        tenantName: '颐年东涌养老院',
+        totalTrial: 20,
+        convertedCount: 15,
+        notConvertedCount: 3,
+        conversionRate: 83.3,
+        avgTrialDays: 16
+      },
+      {
+        tenantId: 197,
+        tenantName: '颐年五心养老院',
+        totalTrial: 18,
+        convertedCount: 12,
+        notConvertedCount: 4,
+        conversionRate: 75.0,
+        avgTrialDays: 21
+      },
+      {
+        tenantId: 198,
+        tenantName: '其他院区',
+        totalTrial: 48,
+        convertedCount: 31,
+        notConvertedCount: 13,
+        conversionRate: 70.5,
+        avgTrialDays: 19
+      }
     ],
     list: Array.from({ length: 12 }).map((_, i) => ({
       elderId: 1000 + i,
@@ -668,9 +787,27 @@ const getMockWaitingAnalysisData = (): WaitingAnalysisData => {
       { month: '2026-09', newCount: 6, convertedCount: 4, canceledCount: 0 }
     ],
     campusStats: [
-      { tenantId: 196, tenantName: '颐年东涌养老院', totalWaiting: 18, avgWaitingDays: 20, maxWaitingDays: 78 },
-      { tenantId: 197, tenantName: '颐年五心养老院', totalWaiting: 15, avgWaitingDays: 28, maxWaitingDays: 96 },
-      { tenantId: 198, tenantName: '其他院区', totalWaiting: 22, avgWaitingDays: 22, maxWaitingDays: 85 }
+      {
+        tenantId: 196,
+        tenantName: '颐年东涌养老院',
+        totalWaiting: 18,
+        avgWaitingDays: 20,
+        maxWaitingDays: 78
+      },
+      {
+        tenantId: 197,
+        tenantName: '颐年五心养老院',
+        totalWaiting: 15,
+        avgWaitingDays: 28,
+        maxWaitingDays: 96
+      },
+      {
+        tenantId: 198,
+        tenantName: '其他院区',
+        totalWaiting: 22,
+        avgWaitingDays: 22,
+        maxWaitingDays: 85
+      }
     ],
     list: Array.from({ length: 12 }).map((_, i) => ({
       id: 2000 + i,