Browse Source

Merge branch 'master' of http://47.107.245.0:3000/xiongxing/kyj-yanglao-web-new

xiongxing 1 day ago
parent
commit
a4285cf667

+ 47 - 0
.workbuddy/memory/2026-09-29.md

@@ -0,0 +1,47 @@
+# 2026-09-29 工作日志
+
+## 入住运营二期:4 个新报表页面实现
+
+完成用户红框指定的 4 个「入住运营」报表页面(参考同级菜单页 + 自设计后端接口):
+
+1. **退住原因与平均入住时长** `occupancy/moveout-reason/index.vue`(上一轮已完成)
+   - KPI + 退住原因饼图 + 各原因平均时长条形 + 月度趋势堆叠柱+线 + 院区明细表 + 导出
+2. **试住转化分析** `occupancy/trial-conversion/index.vue`
+   - KPI(试住总/已转化/转化率/平均试住天数) + 状态饼图 + 各院区转化率条形 + 月度趋势柱+转化率线 + 试住长者明细(分页) + 导出
+3. **轮候长者分析** `occupancy/waiting-analysis/index.vue`
+   - 仅院区多选过滤(接口无日期参数);KPI(轮候总/平均/最长/院区数) + 护理等级饼 + 轮候时长条形 + 各院区人数条形 + 月度趋势堆叠 + 轮候长者明细(分页) + 导出
+4. **合同到期预警** `occupancy/contract-expiry/index.vue`
+   - 院区多选 + 预警阈值(30/60/90/180天, 走 API 的 `days[]`);KPI(30/60/90天内到期 + 已逾期) + 到期分布饼 + 未来月度趋势线 + 合同到期明细(分页, 剩余天数彩色 tag) + 导出
+
+### 配套改动
+- `occupancy/occupancy-api.ts`:新增 4 个接口函数 `fetchMoveoutAnalysis/fetchTrialConversion/fetchWaitingAnalysis/fetchContractExpiry` + 全部 TS 接口 + mock 数据兜底(catch 返回演示数据并 console.warn,后端上线后删除)。统一 `buildQuery` 手动拼重复键(tenantIds=1&tenantIds=2),因后端 List 不接受 qs indices。
+- 设计文档:`occupancy/入住运营二期-设计与后端接口需求.md`(4 个接口契约 + 权限建议)。
+- 路由无需手动注册:`utils/routerHelper.ts` 用 `import.meta.glob('../views/**/*.{vue,tsx}')` 自动加载,后端菜单配组件路径即可。
+
+### 验证
+- 全量 `vue-tsc --noEmit` 在本沙箱因 OOM(退出134/崩溃) 跑不完(项目大);改用 esbuild transform 对 5 个文件逐一对 `<script>` 做 TS 语法校验,发现并修复 1 个真实 bug:
+  - `moveout-reason/index.vue:156` `fmtDate` 模板字符串**缺少闭合反引号**(上一轮创建时漏写),导致该反引号一路吞掉后续代码直到 line 299 的下一个反引号、整段脚本解析失败。已补上 `\`}`。
+- 修复后 5 个文件全部 `ALL SYNTAX OK`。新页面其余沿用已验证的同款 KPI/图表/导出模式。本地可用 `npm run ts:check` 做完整类型复核。
+
+## 入住运营二期页面仍显示 ReportPlaceholder 的根因
+
+用户 09-29 截图反馈「退住原因与平均入住周期」「轮候长者分析」仍显示占位组件。经排查:
+
+- 4 个新页面文件确实存在且内容完整,esbuild 语法校验通过。
+- 占位图出现的原因是**后端菜单表的 `component` 字段仍指向 `operation-analysis/components/ReportPlaceholder`**,未切换到新页面组件。
+- 前端路由由 `src/utils/routerHelper.ts` 的 `import.meta.glob('../views/**/*.{vue,tsx}')` 自动扫描;只要后端菜单更新 `component` 为新页面路径即可渲染。
+
+建议后端菜单更新:
+- 退住原因与平均入住时长 → `operation-analysis/occupancy/moveout-reason/index`
+- 试住转化分析 → `operation-analysis/occupancy/trial-conversion/index`
+- 轮候长者分析 → `operation-analysis/occupancy/waiting-analysis/index`
+- 合同到期预警 → `operation-analysis/occupancy/contract-expiry/index`
+
+若后端菜单不填 `component`,前端会按 `route.path` 自动匹配文件路径。
+
+### 09-29 后续:用户反馈另外 2 个页面已能展示
+用户反馈 4 个新页面中另外 2 个已能正常展示,但退住/轮候仍显示占位图。结合 `generateRoute` 逻辑,原因大概率是:
+- 能展示的 2 个页面:后端菜单 `component` 为空,URL `path` 恰好与文件目录同名,前端自动通过 path 找到组件。
+- 仍显示占位的 2 个页面:后端菜单 `component` 被显式填成了 `operation-analysis/components/ReportPlaceholder`(或其他不匹配路径),导致强制渲染占位组件。
+
+最稳做法:把 4 个页面的 `component` 统一改成文件对应路径(见上方表格),不依赖 path 自动匹配,避免命名不一致导致部分失败。

+ 32 - 0
src/api/elderly/elder/item-change/index.ts

@@ -0,0 +1,32 @@
+import request from '@/config/axios'
+
+// 获取费用项目变更流程实例分页
+export const getFeeItemChangePage = (query) => {
+  return request.get({
+    url: 'bpm/process-instance/fee-item-change-page',
+    params: query
+  })
+}
+
+// 导出费用项目变更流程实例 Excel(内容为分页的全量数据)
+export const exportFeeItemChange = (params) => {
+  return request.download({
+    url: 'bpm/process-instance/fee-item-change-export-excel',
+    params
+  })
+}
+
+// 创建费用项目变更
+export const feeItemChangeCreate = (data) => {
+  return request.post({
+    url: 'bpm/elderly/feeItemChangeCreate',
+    data
+  })
+}
+
+// 获取费用项目变更记录详情
+export const getFeeItemChangeDetail = (id, status, isDetail) => {
+  return request.get({
+    url: `elderly/feeItemChangeRecord/detail?id=${id}&status=${status}&isDetail=${isDetail}`
+  })
+}

+ 17 - 18
src/views/elderly/elder/column.ts

@@ -170,13 +170,13 @@ export const ElderFileColumns = reactive([
     field: 'checkInTime',
     format: true,
     width: 150,
-    sortable:'custom'
+    sortable: 'custom'
   },
   {
     label: '退住日期',
     field: 'checkOutTime',
     format: true,
-    width: 150,
+    width: 150
   },
   {
     label: '床位号',
@@ -213,13 +213,13 @@ export const ElderFileColumns = reactive([
     label: '合同号',
     field: 'contractNumber',
     width: 150,
-    sortable:'custom'
+    sortable: 'custom'
   },
   {
     label: '档案号',
     field: 'fileNumber',
     width: 150
-  },
+  }
 ])
 
 // ================价格变更===================
@@ -341,7 +341,7 @@ export const ElderMedicineColumns = reactive([
   {
     label: '床位号',
     field: 'bedName'
-  },
+  }
 ])
 
 // ================长者相册================
@@ -373,10 +373,9 @@ export const EldeAlbumnColumns = reactive([
     label: '最后更新时间',
     field: 'photoLastUpdateDate',
     format: true
-  },
+  }
 ])
 
-
 // ================优惠项目===================
 export const DiscountFees = reactive([
   {
@@ -390,38 +389,38 @@ export const DiscountFees = reactive([
   },
   {
     label: '优惠活动名称',
-    field: 'ruleName',
+    field: 'ruleName'
   },
   {
     label: '优惠项目',
-    field: 'itemName',
+    field: 'itemName'
   },
   {
     label: '原价',
-    field: 'price',
+    field: 'price'
   },
   {
     label: '优惠后金额',
-    field: 'discountedAmount',
+    field: 'discountedAmount'
   },
   {
     label: '优惠开始时间',
     field: 'startTime',
     format: true,
-    format2: true,
+    format2: true
   },
   {
     label: '优惠结束时间',
     field: 'endTime',
     format: true,
-    format2: true,
+    format2: true
   },
   {
     label: '创建时间',
     field: 'createTime',
     format: true,
-    format2: true,
-  },
+    format2: true
+  }
   // {
   //   label: '审批状态',
   //   field: 'status',
@@ -439,15 +438,15 @@ export const WaitElderColumns = reactive([
   },
   {
     label: '长者姓名',
-    field: 'elderName',
+    field: 'elderName'
   },
   {
     label: '证件号码',
-    field: 'idCard',
+    field: 'idCard'
   },
   {
     label: '当前审批人',
-    field: 'assigneeUser.nickname',
+    field: 'assigneeUser.nickname'
   },
   {
     label: '审批状态',

+ 38 - 0
src/views/elderly/elder/item-change/Detail.vue

@@ -0,0 +1,38 @@
+<template>
+  <Dialog v-model="dialogVisible" title="详情" width="60%" scroll class="form-tag-dialog">
+    <ProcessForm ref="processRef" />
+    <BusinessFormComponent :id="businessKey" v-if="statusType != 5" />
+    <template #footer>
+      <el-button @click="handleClosed">关闭</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue'
+import { registerComponent } from '@/utils/routerHelper'
+import ProcessForm from './ProcessForm.vue'
+defineOptions({ name: 'ItemChangeDetail' })
+
+const dialogVisible = ref(false) // 弹窗
+const BusinessFormComponent = ref() // 审批流程异步组件
+const businessKey = ref('')
+const statusType = ref()
+const processRef = ref()
+
+/** 打开弹窗 */
+const open = async (id, processInstanceId, status) => {
+  dialogVisible.value = true
+  businessKey.value = processInstanceId
+  statusType.value = status
+  BusinessFormComponent.value = registerComponent('/bpm/task/process-list/process')
+  await nextTick()
+  processRef.value.init(id, true, status)
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+// 关闭弹窗
+const handleClosed = () => {
+  dialogVisible.value = false
+}
+</script>

+ 84 - 0
src/views/elderly/elder/item-change/Form.vue

@@ -0,0 +1,84 @@
+<template>
+  <Dialog
+    v-model="dialogVisible"
+    :title="dialogTitle"
+    width="60%"
+    class="item-change-form form-tag-dialog"
+    @close="handleClosed"
+  >
+    <ProcessForm ref="processFormRef" />
+    <template #footer>
+      <el-button @click="handleClosed">关闭</el-button>
+      <el-button v-loading="formLoading" type="primary" @click="submitForm">提交审核</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import ProcessForm from './ProcessForm.vue'
+import { feeItemChangeCreate } from '@/api/elderly/elder/item-change'
+defineOptions({ name: 'ItemChangeForm' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+const dialogVisible = ref(false) // 弹窗
+const processFormRef = ref()
+const formLoading = ref(false) // 表单的加载中
+const dialogTitle = ref('新增费用项目变更')
+/** 打开弹窗:tId 机构id;id 为空新增,有 id 为重新发起回填 */
+const open = async (
+  tId: number | string,
+  id?: string | number,
+  detail: boolean = false,
+  status?
+) => {
+  dialogTitle.value = id ? '重新发起费用项目变更' : '新增费用项目变更'
+  dialogVisible.value = true
+  nextTick(() => {
+    processFormRef.value.setTenantId(tId)
+    processFormRef.value.init(id, detail, status)
+  })
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+const handleClosed = () => {
+  processFormRef.value && processFormRef.value.resetForm()
+  dialogVisible.value = false
+}
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  if (formLoading.value) {
+    return
+  }
+
+  // 二次确认
+  try {
+    await message.confirm(
+      '请认真核对变更生效日期,以及原费用项目和新费用项目!费用项目变更会影响长者费用!',
+      '提示'
+    )
+  } catch {
+    return
+  }
+
+  formLoading.value = true
+  try {
+    const { valid, dataForm } = await processFormRef.value.submitForm()
+    if (!valid) {
+      return
+    }
+    const res = await feeItemChangeCreate(dataForm)
+    if (res) {
+      message.success(t('common.updateSuccess'))
+      handleClosed()
+      // 发送操作成功的事件
+      emit('success')
+    }
+  } finally {
+    setTimeout(() => {
+      formLoading.value = false
+    }, 500)
+  }
+}
+</script>

+ 558 - 0
src/views/elderly/elder/item-change/ProcessForm.vue

@@ -0,0 +1,558 @@
+<template>
+  <el-form
+    ref="formRef"
+    :model="dataForm"
+    label-width="110px"
+    :rules="isDetail ? {} : dataRule"
+    :toggleType="isDetail"
+  >
+    <!-- ============ 新增 / 重新发起 ============ -->
+    <div class="info-wrap" v-if="!isDetail">
+      <el-row :gutter="20">
+        <el-col :span="12" :xs="24">
+          <el-form-item label="长者姓名" prop="elderlyId">
+            <SelectElder
+              v-model="dataForm.elderlyId"
+              type="2"
+              @elder="handleSelectElder"
+              :tId="dataForm.tenantId"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12" :xs="24">
+          <el-form-item label="身份证号" prop="idCard">
+            <TgInput v-model="dataForm.idCard" disabled />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12" :xs="24">
+          <el-form-item label="床位号" prop="bedName">
+            <TgInput v-model="dataForm.bedName" disabled />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12" :xs="24">
+          <el-form-item label="变更生效日期" prop="changeDate">
+            <TgDatePicker v-model="dataForm.changeDate" type="date" placeholder="变更生效日期" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12" :xs="24">
+          <el-form-item label="原费用项目" prop="originalId">
+            <el-select
+              v-model="dataForm.originalId"
+              placeholder="请选择"
+              clearable
+              filterable
+              :disabled="!dataForm.elderlyId"
+              style="width: 100%"
+              @change="handleOriginalChange"
+            >
+              <el-option
+                v-for="t in elderItemList"
+                :key="(t.id ?? t.overheadChargeId) + '_' + t.chargeName"
+                :label="t.chargeName"
+                :value="t.id ?? t.overheadChargeId"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+        <el-col :span="12" :xs="24">
+          <el-form-item label="原费用价格">
+            <span class="price">¥{{ formatNum(dataForm.originalAmount) }}元/月</span>
+          </el-form-item>
+        </el-col>
+        <el-col :span="12" :xs="24">
+          <el-form-item label="新费用项目" prop="expectId">
+            <el-select
+              v-model="dataForm.expectId"
+              placeholder="请选择"
+              clearable
+              filterable
+              :disabled="!dataForm.originalId"
+              style="width: 100%"
+              @change="handleExpectChange"
+            >
+              <el-option v-for="t in newItemList" :key="t.id" :label="t.chargeName" :value="t.id" />
+            </el-select>
+          </el-form-item>
+        </el-col>
+        <el-col :span="12" :xs="24">
+          <el-form-item label="新费用原价">
+            <span class="price">¥{{ formatNum(dataForm.amount) }}元/月</span>
+          </el-form-item>
+        </el-col>
+        <el-col :span="12" :xs="24">
+          <el-form-item label="是否打折">
+            <el-switch
+              v-model="dataForm.isDiscount"
+              inline-prompt
+              active-text="是"
+              inactive-text="否"
+              :active-value="1"
+              :inactive-value="0"
+              @change="handleSwicth"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12" :xs="24">
+          <el-form-item label="是否滞后">
+            <el-switch
+              v-model="dataForm.isHysteresis"
+              inline-prompt
+              active-text="是"
+              inactive-text="否"
+              :active-value="1"
+              :inactive-value="0"
+              @change="handleHysteresisSwitch"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12" :xs="24" v-if="dataForm.isHysteresis == 1">
+          <el-form-item label="差额滞后至" prop="hysteresisMonth" required>
+            <TgDatePicker
+              v-model="dataForm.hysteresisMonth"
+              type="month"
+              placeholder="请选择差额滞后月份"
+            />
+          </el-form-item>
+        </el-col>
+        <template v-if="dataForm.isDiscount == 1">
+          <el-col :span="12" :xs="24">
+            <el-form-item label="折扣金额(元)" prop="discountAmount">
+              <TgInput
+                v-model="discountAmount"
+                append-text="¥"
+                @blur="handleBlur(dataForm, 1)"
+                style="width: 10vw"
+              />
+              <el-text style="color: red; margin-left: 2vw"
+                >折后金额:{{ dataForm.amount - (Number(discountAmount) || 0) }}</el-text
+              >
+            </el-form-item>
+          </el-col>
+          <el-col :span="12" :xs="24">
+            <el-form-item label="折扣率" prop="discount">
+              <TgInputNumber v-model="discount" @blur="handleBlur(dataForm, 2)" disabled />
+            </el-form-item>
+          </el-col>
+        </template>
+        <el-col :span="24">
+          <el-form-item prop="reason" label="变更原因">
+            <TgTextarea v-model="dataForm.reason" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="24">
+          <el-form-item prop="changeFiles" label="附件">
+            <SelectUpload
+              v-model="dataForm.changeFiles"
+              fun-name="附件"
+              :elder="{ elderId: dataForm.elderlyId, elderName: dataForm.elderName }"
+            />
+          </el-form-item>
+        </el-col>
+      </el-row>
+    </div>
+
+    <!-- ============ 详情 ============ -->
+    <div v-else>
+      <div class="info-title">长者信息</div>
+      <div class="info-wrap">
+        <el-row :gutter="20">
+          <el-col :xs="24" :sm="24" :md="24" :lg="8">
+            <el-form-item label="长者姓名">
+              <SelectElder v-model="dataForm.elderlyId" type="1" />
+            </el-form-item>
+          </el-col>
+          <el-col :xs="24" :sm="24" :md="24" :lg="8">
+            <el-form-item label="证件号码">
+              <TgInput v-model="dataForm.idCard" disabled />
+            </el-form-item>
+          </el-col>
+          <el-col :xs="24" :sm="24" :md="24" :lg="8">
+            <el-form-item label="床位号">
+              <TgInput v-model="dataForm.bedName" disabled />
+            </el-form-item>
+          </el-col>
+          <el-col :xs="24" :sm="24" :md="24" :lg="8">
+            <el-form-item label="变更生效日期">
+              <TgInput v-model="dataForm.changeDate" disabled />
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </div>
+      <div class="info-title">变更前</div>
+      <div class="info-wrap">
+        <el-row :gutter="20">
+          <el-col :xs="24" :sm="24" :md="24" :lg="8">
+            <el-form-item label="费用项目">
+              <TgInput v-model="dataForm.originalName" disabled />
+            </el-form-item>
+          </el-col>
+          <el-col :xs="24" :sm="24" :md="24" :lg="8">
+            <el-form-item label="变更前金额">
+              <TgInput v-model="dataForm.originalAmount" disabled />
+            </el-form-item>
+          </el-col>
+          <el-col :xs="24" :sm="24" :md="24" :lg="8">
+            <el-form-item label="是否打折">
+              <TgSwitch :model-value="dataForm.originalIsDiscount == 1 ? '1' : '0'" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </div>
+      <div class="info-title">变更后</div>
+      <div class="info-wrap">
+        <el-row :gutter="20">
+          <el-col :xs="24" :sm="24" :md="24" :lg="8">
+            <el-form-item label="费用项目">
+              <TgInput v-model="dataForm.expectName" disabled />
+            </el-form-item>
+          </el-col>
+          <el-col :xs="24" :sm="24" :md="24" :lg="8">
+            <el-form-item label="收费项目">
+              <TgInput v-model="dataForm.categoryName" disabled />
+            </el-form-item>
+          </el-col>
+          <el-col :xs="24" :sm="24" :md="24" :lg="8">
+            <el-form-item label="标准价格">
+              <TgInput v-model="dataForm.amount" disabled />
+            </el-form-item>
+          </el-col>
+          <el-col :xs="24" :sm="24" :md="24" :lg="8">
+            <el-form-item label="是否打折">
+              <TgSwitch :model-value="dataForm.isDiscount == 1 ? '1' : '0'" />
+            </el-form-item>
+          </el-col>
+          <el-col :xs="24" :sm="24" :md="24" :lg="8" v-if="dataForm.isDiscount == 1">
+            <el-form-item label="折扣金额">
+              <TgInput v-model="dataForm.discountAmount" disabled />
+            </el-form-item>
+          </el-col>
+          <el-col :xs="24" :sm="24" :md="24" :lg="8" v-if="dataForm.isDiscount == 1">
+            <el-form-item label="折后价格">
+              <TgInput v-model="dataForm.actualAmount" disabled />
+            </el-form-item>
+          </el-col>
+          <el-col :xs="24" :sm="24" :md="24" :lg="8">
+            <el-form-item label="是否滞后">
+              <TgSwitch :model-value="dataForm.hysteresisMonth ? '1' : '0'" />
+            </el-form-item>
+          </el-col>
+          <el-col :xs="24" :sm="24" :md="24" :lg="8" v-if="dataForm.hysteresisMonth">
+            <el-form-item label="差额滞后至">
+              <TgInput v-model="dataForm.hysteresisMonth" disabled />
+            </el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="变更原因">
+              <TgTextarea v-model="dataForm.reason" :disabled="true" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="附件">
+              <SelectUpload
+                v-model="dataForm.changeFiles"
+                fun-name="附件"
+                :elder="{ elderId: dataForm.elderlyId, elderName: dataForm.elderName }"
+                :is-detail="isDetail"
+              />
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </div>
+    </div>
+  </el-form>
+</template>
+<script setup lang="ts">
+import { getOverheadPage } from '@/api/elderly/fee/overheadCharge'
+import { getListByElderlyId } from '@/api/elderly/fee/chargeCategory'
+import { formatTime } from '@/utils'
+import { formatNum } from '@/utils/formatter'
+import { getFeeItemChangeDetail } from '@/api/elderly/elder/item-change'
+import { feeItemChangeFormType } from '../types'
+
+defineOptions({ name: 'ItemChangeProcessForm' })
+
+const formRef = ref() // 表单 Ref
+const state = reactive<feeItemChangeFormType>({
+  dataForm: {
+    id: '',
+    elderlyId: '',
+    elderName: '',
+    idCard: '',
+    bedName: '',
+    originalId: '',
+    originalName: '',
+    overheadChargeId: '',
+    originalAmount: undefined,
+    chargeCategoryId: '',
+    expectId: '',
+    expectName: '',
+    expectOverheadChargeId: '',
+    categoryName: '',
+    changeDate: formatTime(Date.now(), 'yyyy-MM-dd'),
+    amount: 0,
+    isDiscount: 0,
+    isHysteresis: 0,
+    hysteresisMonth: '',
+    discountAmount: '',
+    discount: undefined,
+    actualAmount: 0,
+    originalIsDiscount: 0,
+    reason: '',
+    changeFiles: [],
+    startTenantId: '',
+    tenantId: undefined
+  },
+  dataRule: {
+    elderlyId: [{ required: true, message: '长者不能为空', trigger: 'blur' }],
+    changeDate: [{ required: true, message: '变更生效日期不能为空', trigger: 'blur' }],
+    originalId: [{ required: true, message: '原费用项目不能为空', trigger: 'change' }],
+    expectId: [{ required: true, message: '新费用项目不能为空', trigger: 'change' }],
+    discountAmount: [{ required: true, message: '折扣金额不能为空', trigger: 'blur' }],
+    discount: [{ required: true, message: '折扣率不能为空', trigger: 'blur' }],
+    hysteresisMonth: [
+      {
+        validator: (_rule, value, callback) => {
+          if (state.dataForm.isHysteresis == 1) {
+            if (value === '' || value == null) {
+              callback(new Error('请选择差额滞后月份'))
+            } else {
+              callback()
+            }
+          } else {
+            callback()
+          }
+        },
+        trigger: 'change'
+      }
+    ]
+  }
+})
+const { dataForm, dataRule } = toRefs(state)
+const resetFormField = reactive({ ...dataForm.value })
+
+const discountAmount = ref()
+const discount = ref()
+const isDetail = ref(false)
+
+/** 长者名下已有的费用项目(getListByElderlyId 返回分类树,叶子节点携带 overheadChargeId) */
+const elderItemList = ref<any[]>([])
+const originalNode = ref<any>(null)
+/** 同分类下可选的新费用项目 */
+const newItemList = ref<any[]>([])
+
+/** 递归提取长者已开通的费用项目节点 */
+const flattenChargeNodes = (nodes: any[]): any[] => {
+  const arr: any[] = []
+  const walk = (list: any[]) => {
+    ;(list || []).forEach((n) => {
+      if (n.overheadChargeId != null && n.overheadChargeId !== '') arr.push(n)
+      if (n.childrenList?.length) walk(n.childrenList)
+    })
+  }
+  walk(nodes)
+  return arr
+}
+
+/** 加载长者已开通的费用项目 */
+const loadElderItems = async (elderId) => {
+  if (!elderId) {
+    elderItemList.value = []
+    return
+  }
+  const res = await getListByElderlyId(elderId)
+  elderItemList.value = flattenChargeNodes(res || [])
+}
+
+/** 选择长者 */
+const handleSelectElder = async (item) => {
+  dataForm.value.idCard = item.idCard
+  dataForm.value.bedName = item.bedName
+  dataForm.value.elderName = item.elderName
+  resetItemFields()
+  await loadElderItems(item.id)
+}
+
+/** 清空变更项相关字段 */
+const resetItemFields = () => {
+  newItemList.value = []
+  originalNode.value = null
+  dataForm.value.originalId = ''
+  dataForm.value.originalName = ''
+  dataForm.value.overheadChargeId = ''
+  dataForm.value.originalAmount = undefined
+  dataForm.value.chargeCategoryId = ''
+  dataForm.value.expectId = ''
+  dataForm.value.expectName = ''
+  dataForm.value.expectOverheadChargeId = ''
+  dataForm.value.categoryName = ''
+  dataForm.value.amount = 0
+  dataForm.value.actualAmount = 0
+  handleSwicth()
+}
+
+/** 选择原费用项目 */
+const handleOriginalChange = async (val) => {
+  const node = elderItemList.value.find((n) => (n.id ?? n.overheadChargeId) == val)
+  if (!node) {
+    resetItemFields()
+    return
+  }
+  originalNode.value = node
+  dataForm.value.originalId = node.id ?? node.overheadChargeId
+  dataForm.value.overheadChargeId = node.overheadChargeId
+  dataForm.value.originalName = node.chargeName
+  dataForm.value.originalAmount = Number(node.amount) || 0
+  dataForm.value.chargeCategoryId = node.chargeCategoryId
+  // 重置新费用项目
+  dataForm.value.expectId = ''
+  dataForm.value.expectName = ''
+  dataForm.value.expectOverheadChargeId = ''
+  dataForm.value.amount = 0
+  dataForm.value.actualAmount = 0
+  dataForm.value.categoryName = ''
+  handleSwicth()
+  await getNewItemList(node)
+}
+
+/** 查询同分类下可选的收费标准 */
+const getNewItemList = async (node) => {
+  if (!node?.chargeCategoryId) {
+    newItemList.value = []
+    return
+  }
+  try {
+    const data = await getOverheadPage({
+      pageNo: 1,
+      pageSize: 999,
+      categoryId: node.chargeCategoryId
+    })
+    newItemList.value = data.list || []
+  } catch {
+    newItemList.value = []
+  }
+}
+
+/** 选择新费用项目 */
+const handleExpectChange = (val) => {
+  const item = newItemList.value.find((i) => i.id == val)
+  if (!item) return
+  dataForm.value.expectId = item.id
+  dataForm.value.expectOverheadChargeId = item.id
+  dataForm.value.expectName = item.chargeName
+  dataForm.value.amount = Number(item.price) || 0
+  dataForm.value.categoryName = originalNode.value?.name
+    ? `${originalNode.value.name}-${item.chargeName}`
+    : item.chargeName
+  // 未打折时实际金额=标准金额;打折时按已填折扣重算
+  if (dataForm.value.isDiscount != 1) {
+    dataForm.value.actualAmount = dataForm.value.amount
+  } else {
+    handleBlur(dataForm.value, 1)
+  }
+}
+
+// 折扣金额和折扣率换算(与护理变更口径一致)
+const handleBlur = (item, type = 0) => {
+  if (type == 1) {
+    // 折扣金额
+    item.discountAmount = discountAmount.value
+    item.actualAmount = item.amount - (Number(discountAmount.value) || 0)
+    item.discount = item.discountAmount
+      ? ((1000 - (item.discountAmount / item.amount) * 1000) * 10) / 1000
+      : ''
+    discount.value = item.discount
+  } else if (type == 2) {
+    item.discount = discount.value
+    item.actualAmount = item.amount * (((item.discount / 100) * 10 * 100) / 100)
+    item.discountAmount = ((item.amount * (1 - item.discount / 10)) / 10) * 10
+    discountAmount.value = formatNum(((item.amount * (1 - item.discount / 10)) / 10) * 10)
+  }
+}
+
+/** 是否打折 */
+const handleSwicth = () => {
+  dataForm.value.discountAmount = ''
+  dataForm.value.actualAmount = 0
+  dataForm.value.discount = undefined
+  discount.value = undefined
+  discountAmount.value = ''
+}
+
+/** 是否滞后 */
+const handleHysteresisSwitch = () => {
+  if (dataForm.value.isHysteresis != 1) {
+    dataForm.value.hysteresisMonth = ''
+    formRef.value?.clearValidate(['hysteresisMonth'])
+  }
+}
+
+/** 初始化:无 id 为新增;有 id + detail=true 为详情;有 id + detail=false 为重新发起 */
+const init = async (id?: string | number, detail = false, status?) => {
+  isDetail.value = detail
+  resetForm()
+  dataForm.value.changeDate = formatTime(Date.now(), 'yyyy-MM-dd')
+  elderItemList.value = []
+  newItemList.value = []
+  originalNode.value = null
+  if (!id) return
+
+  const res = await getFeeItemChangeDetail(id, status, detail)
+  dataForm.value = {
+    ...dataForm.value,
+    ...res,
+    // 详情 VO 未单独返回新收费项目id,与新费用项目id一致
+    expectOverheadChargeId: res.expectOverheadChargeId ?? res.expectId,
+    changeFiles: res.changeFiles ? JSON.parse(res.changeFiles) : []
+  }
+  dataForm.value.isHysteresis = res.hysteresisMonth ? 1 : 0
+  discount.value = res.discount
+  discountAmount.value = res.discountAmount
+  if (res.startTenantId) {
+    dataForm.value.startTenantId = res.startTenantId
+    dataForm.value.tenantId = res.startTenantId
+  }
+
+  // 重新发起:需要可编辑的下拉选项
+  if (!detail) {
+    await loadElderItems(res.elderlyId)
+    const node = elderItemList.value.find((n) => (n.id ?? n.overheadChargeId) == res.originalId)
+    if (node) {
+      originalNode.value = node
+      dataForm.value.chargeCategoryId = node.chargeCategoryId
+      await getNewItemList(node)
+    }
+  }
+}
+
+/** 提交表单 */
+const submitForm = async () => {
+  if (!formRef.value) return { valid: false }
+  const valid = await formRef.value.validate().catch(() => false)
+  if (!valid) return { valid: false }
+  // isHysteresis、chargeCategoryId 仅前端使用,不传后端
+  const { isHysteresis: _isHysteresis, chargeCategoryId: _category, ...rest } = dataForm.value
+  return {
+    valid: true,
+    dataForm: {
+      ...rest,
+      changeFiles: JSON.stringify(dataForm.value.changeFiles),
+      type: 11, // 费用项目变更
+      hysteresisMonth:
+        dataForm.value.isHysteresis == 1 ? (dataForm.value.hysteresisMonth ?? '') : ''
+    }
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  dataForm.value = { ...resetFormField }
+  formRef.value?.resetFields()
+}
+
+const setTenantId = (tId) => {
+  dataForm.value.tenantId = tId
+  dataForm.value.startTenantId = tId
+}
+
+defineExpose({ init, submitForm, resetForm, setTenantId })
+</script>

+ 292 - 0
src/views/elderly/elder/item-change/index.vue

@@ -0,0 +1,292 @@
+<template>
+  <!-- 搜索 -->
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="90px"
+    >
+      <TenantSelect v-model="queryParams.tenantIds" placeholder="请选择机构名称" prop="tenantIds" />
+      <el-form-item @keyup.enter="handleQuery" label="长者姓名" prop="elderName">
+        <el-input v-model="queryParams.elderName" placeholder="请输入长者姓名" class="!w-200px" />
+      </el-form-item>
+      <el-form-item label="床位号" prop="bedName">
+        <el-input
+          v-model="queryParams.bedName"
+          placeholder="请输入床位号"
+          class="!w-200px"
+          @keyup.enter="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="原费用项目" prop="originalName">
+        <el-input
+          v-model="queryParams.originalName"
+          placeholder="请输入原费用项目名称"
+          class="!w-200px"
+          @keyup.enter="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="变更后项目" prop="expectName">
+        <el-input
+          v-model="queryParams.expectName"
+          placeholder="请输入变更后项目名称"
+          class="!w-200px"
+          @keyup.enter="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="变更日期" prop="changeDate">
+        <el-date-picker
+          v-model="queryParams.changeDate"
+          :clearable="true"
+          type="daterange"
+          placeholder="选择变更日期"
+          value-format="YYYY-MM-DD"
+          format="YYYY-MM-DD"
+          date-format="YYYY-MM-DD"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="审批状态" prop="status">
+        <el-select v-model="queryParams.status" placeholder="请选择" clearable class="!w-200px">
+          <el-option
+            v-for="(dict, index) in getIntDictOptions(DICT_TYPE.APPROVAL_STATUS)"
+            :key="index"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <div class="mb-10px">
+      <el-button type="primary" plain @click="openForm()" v-hasPermi="['item-change:add']">
+        <Icon icon="ep:plus" class="mr-5px" />
+        新增
+      </el-button>
+      <el-button :loading="exportLoading" plain type="success" @click="handleExport">
+        <Icon class="mr-5px" icon="ep:download" />
+        导出
+      </el-button>
+    </div>
+    <el-table v-loading="loading" :data="list" :header-cell-style="tableHeaderColor">
+      <el-table-column header-align="center" align="center" label="编号" width="62" fixed>
+        <template #default="scope">
+          {{
+            scope.$index + (queryParams.pageNo * queryParams.pageSize - queryParams.pageSize) + 1
+          }}
+        </template>
+      </el-table-column>
+      <el-table-column
+        v-if="userStore.getOrgTenant.multiple"
+        prop="tenantName"
+        header-align="center"
+        align="center"
+        label="所属机构"
+      />
+      <el-table-column prop="elderlyName" header-align="center" align="center" label="长者姓名" />
+      <el-table-column
+        prop="originalName"
+        header-align="center"
+        align="center"
+        label="原费用项目"
+        show-overflow-tooltip
+      />
+      <el-table-column
+        prop="expectName"
+        header-align="center"
+        align="center"
+        label="变更后费用项目"
+        show-overflow-tooltip
+      />
+      <el-table-column
+        prop="categoryName"
+        header-align="center"
+        align="center"
+        label="收费项目"
+        show-overflow-tooltip
+      />
+      <el-table-column
+        prop="changeDate"
+        header-align="center"
+        align="center"
+        label="变更生效日期"
+        width="120"
+      />
+      <el-table-column
+        prop="status"
+        header-align="center"
+        align="center"
+        label="审批状态"
+        width="100"
+      >
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.APPROVAL_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" width="200" fixed="right">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openForm(scope.row)"
+            v-hasPermi="['item-change:relaunch']"
+            v-show="scope.row.status == 3 || scope.row.status == 4"
+          >
+            重新发起
+          </el-button>
+          <el-button
+            link
+            type="primary"
+            @click="openDetail(scope.row)"
+            v-hasPermi="['item-change:detail']"
+          >
+            详情
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row)"
+            v-hasPermi="['item-change:revocation']"
+            v-show="scope.row.status == 1"
+          >
+            撤销
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <Form ref="formRef" @success="getList" />
+  <Detail ref="detailRef" />
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { getFeeItemChangePage, exportFeeItemChange } from '@/api/elderly/elder/item-change'
+import { cancelprocessInstance } from '@/api/elderly/common'
+import { useUserStore } from '@/store/modules/user'
+import { tableHeaderColor } from '@/utils/table'
+import Form from './Form.vue'
+import Detail from './Detail.vue'
+import download from '@/utils/download'
+
+defineOptions({ name: 'ItemChange' })
+const userStore = useUserStore()
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+const exportLoading = ref(false) // 导出的加载中
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总条数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  elderName: undefined,
+  bedName: undefined,
+  originalName: undefined,
+  expectName: undefined,
+  changeDate: [] as string[],
+  status: undefined,
+  tenantIds: userStore.orgTenantId
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await getFeeItemChangePage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = async () => {
+  queryParams.pageNo = 1
+  await getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  queryParams.changeDate = []
+  handleQuery()
+}
+
+/** 导出按钮操作 */
+const handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    // 发起导出(与列表使用相同的查询参数)
+    exportLoading.value = true
+    const data = await exportFeeItemChange(queryParams)
+    download.excel(data, '费用项目变更.xls')
+  } catch {
+  } finally {
+    exportLoading.value = false
+  }
+}
+
+/** 新增 / 重新发起 */
+const formRef = ref()
+const openForm = (row: any = {}) => {
+  if (queryParams.tenantIds.length == 0 || (queryParams.tenantIds.length > 1 && !row.tenantId)) {
+    message.error('新增只能选择一个机构')
+    return
+  }
+  formRef.value.open(row.tenantId || queryParams.tenantIds[0], row.id, false, row.status)
+}
+
+// 详情
+const detailRef = ref()
+const openDetail = (row: any = {}) => {
+  detailRef.value.open(row.id, row.processInstanceId, row.status)
+}
+
+/** 撤销按钮操作 */
+const handleDelete = async (row: any) => {
+  try {
+    const res = await message.prompt('请输入撤销原因', '提示')
+    if (res.action == 'confirm') {
+      // 发起撤销(传流程实例编号)
+      await cancelprocessInstance({
+        id: row.processInstanceId,
+        reason: res.value
+      })
+      message.success(t('common.cancelSuccess'))
+    }
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+const route = useRoute()
+/** 初始化 **/
+onMounted(() => {
+  if (route.query && route.query.elderName) {
+    queryParams.elderName = route.query.elderName as string
+  }
+  getList()
+})
+</script>

+ 73 - 19
src/views/elderly/elder/types.ts

@@ -87,7 +87,7 @@ export interface hospitalizationHistoryType {
 }
 export interface attachmentArrayType {
   fileName: string
-  fileUrl: {fileUrl: string, fileName: string}[]
+  fileUrl: { fileUrl: string; fileName: string }[]
   key: number
 }
 
@@ -209,8 +209,8 @@ export interface medicalRecordFormType {
   otherFileName: string // 其他附件名称
 }
 
-export interface fileType{
-  fileUrl: string,
+export interface fileType {
+  fileUrl: string
   fileName: string
 }
 
@@ -509,6 +509,61 @@ export interface bedChangeFormType {
   dataRule: FormRules
 }
 
+// =================费用项目变更==========================
+export interface feeItemChangeFormType {
+  dataForm: {
+    id: string
+    elderlyId: string
+    elderName: string
+    idCard: string
+    bedName: string
+    /** 原费用项目id */
+    originalId: string | number
+    /** 原费用项目名称 */
+    originalName: string
+    /** 原收费项目id */
+    overheadChargeId: string | number
+    /** 原项目价格 */
+    originalAmount: number | undefined
+    /** 仅前端:原项目所属收费分类id,用于联动查询同分类下可选的新项目 */
+    chargeCategoryId: string | number
+    /** 新费用项目id */
+    expectId: string | number
+    /** 新费用项目名称 */
+    expectName: string
+    /** 新收费项目id */
+    expectOverheadChargeId: string | number
+    /** 收费项目名称 */
+    categoryName: string
+    changeDate: string
+    /** 新项目标准价格 */
+    amount: number
+    isDiscount: number | string
+    /** 仅前端:与 el-switch 一致;提交不传,回显由 hysteresisMonth 推导 */
+    isHysteresis?: number | string
+    /** 滞后年月(YYYY-MM) */
+    hysteresisMonth?: string
+    /** 折扣金额 */
+    discountAmount: number | string
+    /** 折扣率 */
+    discount: number | string | undefined
+    /** 实际(折后)金额 */
+    actualAmount: number
+    /** 变更前是否打折(详情回显) */
+    originalIsDiscount?: number | string
+    /** 变更原因 */
+    reason: string
+    /** 变更附件 */
+    changeFiles: {
+      fileUrl: string
+      fileName: string
+    }[]
+    startTenantId: string
+    tenantId: undefined | number
+  }
+  dataRule: FormRules
+}
+
 // =======================合同管理===========================
 export interface ContractDetailType {
   elderName: string
@@ -528,21 +583,20 @@ export interface ContractDetailType {
 }
 
 export interface DiscountRuleSaveReqVOs {
-  id: string,
-  elderId: string,
-  elderName: string,
-  ruleName: string,
-  itemId: number,
-  itemName: string,
-  price: number, //原价
-  discount: number,//折扣值
-  discountAmount: number, //优惠金额
-  discountedAmount: number,//优惠后金额
-  startTime: string,
-  categoryId: string,
-  categoryName: string,
-  endTime: string,
-  status: number,
+  id: string
+  elderId: string
+  elderName: string
+  ruleName: string
+  itemId: number
+  itemName: string
+  price: number //原价
+  discount: number //折扣值
+  discountAmount: number //优惠金额
+  discountedAmount: number //优惠后金额
+  startTime: string
+  categoryId: string
+  categoryName: string
+  endTime: string
+  status: number
   remarks: string
 }
-

+ 444 - 3
src/views/operation-analysis/occupancy/contract-expiry/index.vue

@@ -1,7 +1,448 @@
 <template>
-  <ReportPlaceholder status="ready" desc="基于合同接口,展示一月内到期合同明细与院区分布,支持续签跟进。" />
+  <div v-loading="loading" class="oa-page" element-loading-text="正在加载合同到期预警数据…">
+    <ContentWrap>
+      <div class="page-header">
+        <div class="header-title">
+          <Icon icon="ep:alarm-clock" 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>
+        </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 label="预警阈值">
+          <el-select v-model="warningDays" class="days-select">
+            <el-option v-for="d in dayOptions" :key="d" :label="`${d} 天内`" :value="d" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
+          <el-button :icon="RefreshLeft" @click="handleReset">重置</el-button>
+          <el-button type="success" :icon="Download" :disabled="!hasData" @click="handleExport">
+            导出 Excel
+          </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>
+          </div>
+        </div>
+      </div>
+    </ContentWrap>
+
+    <el-row :gutter="12" class="chart-row">
+      <el-col :xs="24" :lg="12">
+        <ContentWrap>
+          <div class="panel-title">合同到期分布</div>
+          <Echart v-if="hasData" :options="distributionOption" 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="hasData" :options="monthTrendOption" 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">合同到期明细</div>
+      <el-table :data="pagedList" border stripe>
+        <el-table-column type="index" label="序号" width="60" align="center" />
+        <el-table-column prop="elderName" label="长者姓名" min-width="110" show-overflow-tooltip />
+        <el-table-column label="性别" width="70" align="center">
+          <template #default="{ row }">{{ row.gender === 1 ? '男' : row.gender === 2 ? '女' : '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="age" label="年龄" width="70" align="center">
+          <template #default="{ row }">{{ row.age ?? '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="tenantName" label="所属机构" min-width="150" show-overflow-tooltip />
+        <el-table-column prop="bedName" label="床位" min-width="110" align="center">
+          <template #default="{ row }">{{ row.bedName || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="contractStartDate" label="合同起期" min-width="120" align="center" />
+        <el-table-column prop="contractEndDate" label="合同止期" min-width="120" align="center" />
+        <el-table-column label="剩余天数" width="120" align="center" sortable>
+          <template #default="{ row }">
+            <el-tag :type="remainTag(row.remainDays)" size="small" effect="light">
+              {{ remainText(row.remainDays) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="contactName" label="联系人" min-width="100" align="center">
+          <template #default="{ row }">{{ row.contactName || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="contactPhone" label="联系电话" min-width="130" align="center">
+          <template #default="{ row }">{{ row.contactPhone || '-' }}</template>
+        </el-table-column>
+      </el-table>
+      <div class="pager">
+        <el-pagination
+          v-model:current-page="pageNo"
+          v-model:page-size="pageSize"
+          :total="list.length"
+          :page-sizes="[10, 20, 50]"
+          layout="total, sizes, prev, pager, next"
+          background
+        />
+      </div>
+    </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 { getCampusOptions } from '../../overview/campus-data'
+import { fetchContractExpiry, type ContractExpiryData } from '../occupancy-api'
+
+defineOptions({ name: 'OaOccupancyContractExpiry' })
+
+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 warningDays = ref<number>(90)
+const dayOptions = [30, 60, 90, 180]
+const data = ref<ContractExpiryData | null>(null)
+const hasData = computed(() => (list.value.length ?? 0) > 0 || (data.value?.summary?.length ?? 0) > 0)
+
+const pageNo = ref(1)
+const pageSize = ref(10)
+const list = computed(() => data.value?.list || [])
+const pagedList = computed(() => {
+  const start = (pageNo.value - 1) * pageSize.value
+  return list.value.slice(start, start + pageSize.value)
+})
+
+const pad2 = (n: number) => String(n).padStart(2, '0')
+
+const remainText = (days: number) => {
+  if (days < 0) return `逾期${-days}天`
+  if (days === 0) return '今日到期'
+  return `剩${days}天`
+}
+const remainTag = (days: number) => {
+  if (days < 0) return 'danger' as const
+  if (days <= 30) return 'danger' as const
+  if (days <= 60) return 'warning' as const
+  if (days <= 90) return 'primary' as const
+  return 'success' as const
+}
+
+/* ---------------- KPI ---------------- */
+
+const kpiCards = computed(() => {
+  const summary = data.value?.summary || []
+  const find = (d: number) => summary.find((s) => s.days === d)
+  const expired = summary.reduce((s, i) => s + (i.expiredCount || 0), 0)
+  return [
+    { label: `${dayOptions[0]}天内到期`, value: find(dayOptions[0])?.expiringCount || 0, unit: '份', icon: 'ep:timer', theme: 'orange' },
+    { label: `${dayOptions[1]}天内到期`, value: find(dayOptions[1])?.expiringCount || 0, unit: '份', icon: 'ep:timer', theme: 'cyan' },
+    { label: `${dayOptions[2]}天内到期`, value: find(dayOptions[2])?.expiringCount || 0, unit: '份', icon: 'ep:calendar', theme: 'blue' },
+    { label: '已逾期合同', value: expired || 0, unit: '份', icon: 'ep:warning', theme: 'purple' }
+  ]
+})
+
+/* ---------------- 图表 ---------------- */
+
+const PIE_COLORS = ['#f56c6c', '#e6a23c', '#f0a020', '#409eff', '#67c23a', '#8b5cf6', '#909399']
+
+const distributionOption = computed(() => {
+  const buckets = (data.value?.distributionBuckets || []).filter((b) => b.count > 0)
+  return {
+    tooltip: { trigger: 'item', formatter: '{b}:{c} 份({d}%)' },
+    legend: { bottom: 0, icon: 'circle' },
+    series: [
+      {
+        type: 'pie',
+        radius: ['45%', '70%'],
+        center: ['50%', '46%'],
+        label: { formatter: '{b}\n{c} 份' },
+        data: buckets.map((b, i) => ({
+          name: b.label,
+          value: b.count,
+          itemStyle: {
+            color: b.label.includes('逾期') || b.label === '已过期' ? '#f56c6c' : PIE_COLORS[i % PIE_COLORS.length]
+          }
+        }))
+      }
+    ]
+  }
+})
+
+const monthTrendOption = computed(() => {
+  const items = data.value?.monthTrend || []
+  return {
+    tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
+    grid: { left: 48, right: 48, top: 24, bottom: 30 },
+    xAxis: { type: 'category', data: items.map((i) => i.month), axisLabel: { rotate: 30 } },
+    yAxis: { type: 'value', name: '份' },
+    series: [
+      {
+        name: '到期份数',
+        type: 'line',
+        smooth: true,
+        symbolSize: 7,
+        data: items.map((i) => i.count),
+        lineStyle: { width: 2.5, color: '#e6a23c' },
+        itemStyle: { color: '#e6a23c' },
+        areaStyle: { color: 'rgba(230,162,60,0.12)' }
+      }
+    ]
+  }
+})
+
+/* ---------------- 加载与导出 ---------------- */
+
+const loadAll = async () => {
+  const ids = selectedCampusIds.value.length
+    ? selectedCampusIds.value
+    : campusOptions.map((item) => item.value)
+  loading.value = true
+  loadFailed.value = false
+  pageNo.value = 1
+  try {
+    data.value = await fetchContractExpiry({ tenantIds: ids, days: [warningDays.value] })
+    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
+    data.value = null
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleQuery = () => {
+  loadAll()
+}
+
+const handleReset = () => {
+  selectedCampusIds.value = campusOptions.map((item) => item.value)
+  warningDays.value = 90
+  loadAll()
+}
+
+const handleExport = () => {
+  const rows = list.value.map((r) => ({
+    ...r,
+    genderText: r.gender === 1 ? '男' : r.gender === 2 ? '女' : '-',
+    remainText: remainText(r.remainDays)
+  }))
+  const headers = [
+    { key: 'elderName', title: '长者姓名', width: 14 },
+    { key: 'genderText', title: '性别', width: 8 },
+    { key: 'age', title: '年龄', width: 8 },
+    { key: 'tenantName', title: '所属机构', width: 22 },
+    { key: 'bedName', title: '床位', width: 12 },
+    { key: 'contractStartDate', title: '合同起期', width: 14 },
+    { key: 'contractEndDate', title: '合同止期', width: 14 },
+    { key: 'remainText', title: '剩余天数', width: 12 },
+    { key: 'contactName', title: '联系人', width: 12 },
+    { key: 'contactPhone', title: '联系电话', width: 16 }
+  ]
+  exportWithCustomHeaders(rows, headers, `合同到期预警_${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;
+      }
+    }
+
+    .header-extra {
+      display: flex;
+      align-items: center;
+    }
+
+    .update-time {
+      margin-right: 14px;
+      font-size: 13px;
+      color: #909399;
+    }
+  }
+
+  .filter-form {
+    margin-top: 4px;
+
+    .campus-select {
+      width: 300px;
+      max-width: 56vw;
+    }
+    .days-select {
+      width: 140px;
+    }
+  }
+
+  .api-tip {
+    margin: 4px 0 14px;
+  }
+
+  .kpi-grid {
+    display: grid;
+    grid-template-columns: repeat(4, 1fr);
+    gap: 12px;
+
+    @media (max-width: 1200px) {
+      grid-template-columns: repeat(2, 1fr);
+    }
+    @media (max-width: 768px) {
+      grid-template-columns: 1fr;
+    }
+
+    .kpi-card {
+      padding: 16px 18px;
+      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;
+        }
+      }
+
+      &.blue {
+        background: linear-gradient(135deg, #5b9bff 0%, #2f7cf6 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%);
+      }
+    }
+  }
+
+  .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;
+    }
+  }
+
+  .pager {
+    display: flex;
+    justify-content: flex-end;
+    margin-top: 12px;
+  }
+}
+</style>

+ 532 - 0
src/views/operation-analysis/occupancy/moveout-reason/index.vue

@@ -0,0 +1,532 @@
+<template>
+  <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>
+        </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 label="时间范围">
+          <el-date-picker
+            v-model="dateRange"
+            type="daterange"
+            range-separator="至"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            value-format="YYYY-MM-DD"
+            class="range-picker"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
+          <el-button :icon="RefreshLeft" @click="handleReset">重置</el-button>
+          <el-button type="success" :icon="Download" :disabled="!hasData" @click="handleExport">
+            导出 Excel
+          </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>
+          </div>
+        </div>
+      </div>
+    </ContentWrap>
+
+    <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>
+        </ContentWrap>
+      </el-col>
+      <el-col :xs="24" :lg="14">
+        <ContentWrap>
+          <div class="panel-title">各原因平均入住时长</div>
+          <Echart v-if="hasData" :options="avgStayBarOption" height="360px" />
+          <el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
+        </ContentWrap>
+      </el-col>
+    </el-row>
+
+    <ContentWrap class="chart-wrap">
+      <div class="panel-title">
+        月度退住趋势
+        <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-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>
+        <el-table-column
+          v-for="col in reasonColumns"
+          :key="col.key"
+          :prop="col.key"
+          :label="col.label"
+          min-width="100"
+          align="center"
+        />
+      </el-table>
+    </ContentWrap>
+  </div>
+</template>
+
+<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 { getDictLabel, DICT_TYPE } from '@/utils/dict'
+import { getCampusOptions } from '../../overview/campus-data'
+import {
+  fetchMoveoutAnalysis,
+  type MoveoutAnalysisData,
+  type MoveoutReasonBucket
+} from '../occupancy-api'
+
+defineOptions({ name: 'OaOccupancyMoveoutReason' })
+
+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 dateRange = ref<string[]>([])
+const data = ref<MoveoutAnalysisData | null>(null)
+const hasData = computed(() => (data.value?.totalMoveOut ?? 0) > 0)
+
+const pad2 = (n: number) => String(n).padStart(2, '0')
+const fmtDate = (d: Date) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
+
+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 || '其他'
+
+/* ---------------- KPI ---------------- */
+
+const kpiCards = computed(() => {
+  const d = data.value
+  const topReason = d?.reasonBuckets?.[0]
+  const topRate = d?.totalMoveOut && topReason ? (topReason.count / d.totalMoveOut) * 100 : 0
+  return [
+    { label: '退住总人数', value: d?.totalMoveOut || 0, unit: '人', icon: 'ep:user', theme: 'blue' },
+    {
+      label: '平均入住时长',
+      value: d?.avgStayDays ?? '-',
+      unit: d?.avgStayDays == null ? '' : '天',
+      icon: 'ep:timer',
+      theme: 'cyan'
+    },
+    {
+      label: 'TOP1 退住原因',
+      value: topReason ? reasonLabel(topReason) : '-',
+      unit: '',
+      icon: 'ep:warning',
+      theme: 'orange'
+    },
+    {
+      label: 'TOP1 原因占比',
+      value: topReason ? topRate.toFixed(1) : '-',
+      unit: topReason ? '%' : '',
+      icon: 'ep:pie-chart',
+      theme: 'purple'
+    }
+  ]
+})
+
+/* ---------------- 图表 ---------------- */
+
+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)
+  return {
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: { type: 'shadow' },
+      formatter: '{b}:{c} 天'
+    },
+    grid: { left: 110, right: 48, top: 16, bottom: 16, containLabel: true },
+    xAxis: { type: 'value', name: '天' },
+    yAxis: {
+      type: 'category',
+      data: list.map((b) => b.displayLabel),
+      axisLabel: { width: 100, overflow: 'truncate' }
+    },
+    series: [
+      {
+        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} 天' }
+      }
+    ]
+  }
+})
+
+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]
+  }))
+  return {
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: { type: 'shadow' }
+    },
+    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 },
+    series: [
+      ...reasons.map((r) => ({
+        name: r.label,
+        type: 'bar',
+        stack: 'reason',
+        barWidth: 18,
+        data: items.map((i) => i.reasonMap?.[r.key] || 0),
+        itemStyle: { color: r.color }
+      })),
+      {
+        name: '当月合计',
+        type: 'line',
+        data: items.map((i) => i.total),
+        smooth: true,
+        symbolSize: 6,
+        lineStyle: { width: 2.5, color: '#e6a23c' },
+        itemStyle: { color: '#e6a23c' }
+      }
+    ]
+  }
+})
+
+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)
+  loading.value = true
+  loadFailed.value = false
+  try {
+    const params: any = { tenantIds: ids }
+    if (dateRange.value && 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 {
+    loadFailed.value = true
+    data.value = null
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleQuery = () => {
+  loadAll()
+}
+
+const handleReset = () => {
+  selectedCampusIds.value = campusOptions.map((item) => item.value)
+  setDefaultRange()
+  loadAll()
+}
+
+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 }))
+  ]
+  exportWithCustomHeaders(
+    campusRows.value,
+    headers,
+    `退住原因与平均入住时长_${new Date().toISOString().slice(0, 10)}.xlsx`,
+    '院区退住明细'
+  )
+  message.success('导出成功')
+}
+
+onMounted(() => {
+  setDefaultRange()
+  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;
+      }
+    }
+
+    .header-extra {
+      display: flex;
+      align-items: center;
+    }
+
+    .update-time {
+      margin-right: 14px;
+      font-size: 13px;
+      color: #909399;
+    }
+  }
+
+  .filter-form {
+    margin-top: 4px;
+
+    .campus-select {
+      width: 300px;
+      max-width: 56vw;
+    }
+    .range-picker {
+      width: 260px;
+      max-width: 50vw;
+    }
+  }
+
+  .api-tip {
+    margin: 4px 0 14px;
+  }
+
+  .kpi-grid {
+    display: grid;
+    grid-template-columns: repeat(4, 1fr);
+    gap: 12px;
+
+    @media (max-width: 1200px) {
+      grid-template-columns: repeat(2, 1fr);
+    }
+    @media (max-width: 768px) {
+      grid-template-columns: 1fr;
+    }
+
+    .kpi-card {
+      padding: 16px 18px;
+      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;
+        }
+      }
+
+      &.blue {
+        background: linear-gradient(135deg, #5b9bff 0%, #2f7cf6 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%);
+      }
+    }
+  }
+
+  .chart-row,
+  .chart-wrap,
+  .table-wrap {
+    margin-top: 12px;
+  }
+
+  .chart-empty {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 320px;
+  }
+
+  .donut-wrap {
+    position: relative;
+  }
+
+  .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;
+    }
+  }
+}
+</style>

+ 484 - 0
src/views/operation-analysis/occupancy/occupancy-api.ts

@@ -252,3 +252,487 @@ export const fetchElderStructure = async (
     campusStats: Array.isArray(data?.campusStats) ? data.campusStats : []
   }
 }
+
+/* ============================== 入住运营二期:退住原因与平均入住时长 ============================== */
+
+export interface MoveoutReasonBucket {
+  reasonValue: string | number | null
+  reasonLabel: string
+  count: number
+  avgStayDays: number | null
+}
+
+export interface MoveoutMonthTrend {
+  month: string
+  total: number
+  reasonMap: Record<string, number>
+}
+
+export interface MoveoutCampusStat {
+  tenantId: number
+  tenantName: string
+  total: number
+  avgStayDays: number | null
+  reasonBuckets: MoveoutReasonBucket[]
+}
+
+export interface MoveoutAnalysisData {
+  totalMoveOut: number
+  avgStayDays: number | null
+  reasonBuckets: MoveoutReasonBucket[]
+  monthTrend: MoveoutMonthTrend[]
+  campusStats: MoveoutCampusStat[]
+}
+
+export interface MoveoutAnalysisParams {
+  tenantIds?: number[]
+  startDate?: string
+  endDate?: string
+}
+
+/** 接口 E:退住原因与平均入住时长聚合 */
+export const fetchMoveoutAnalysis = async (
+  params: MoveoutAnalysisParams = {}
+): Promise<MoveoutAnalysisData> => {
+  try {
+    const data = await request.get({
+      url: `/operation-analysis/overview/moveout-analysis${buildQuery({ orgType: 1, ...params })}`
+    })
+    return {
+      totalMoveOut: Number(data?.totalMoveOut) || 0,
+      avgStayDays: data?.avgStayDays ?? null,
+      reasonBuckets: Array.isArray(data?.reasonBuckets) ? data.reasonBuckets : [],
+      monthTrend: Array.isArray(data?.monthTrend) ? data.monthTrend : [],
+      campusStats: Array.isArray(data?.campusStats) ? data.campusStats : []
+    }
+  } catch (e) {
+    console.warn('[moveout-analysis] 后端接口尚未上线,返回演示数据', e)
+    // MOCK:后端接口上线后删除以下数据
+    return getMockMoveoutAnalysisData()
+  }
+}
+
+/* ============================== 入住运营二期:试住转化分析 ============================== */
+
+export type TrialStatus = 'converted' | 'not_converted' | 'in_trial'
+
+export interface TrialStatusBucket {
+  status: TrialStatus
+  label: string
+  count: number
+}
+
+export interface TrialMonthTrend {
+  month: string
+  trialCount: number
+  convertedCount: number
+  notConvertedCount: number
+}
+
+export interface TrialCampusStat {
+  tenantId: number
+  tenantName: string
+  totalTrial: number
+  convertedCount: number
+  notConvertedCount: number
+  conversionRate: number | null
+  avgTrialDays: number | null
+}
+
+export interface TrialElderItem {
+  elderId: number
+  elderName: string
+  gender: number | string
+  age: number | null
+  tenantId: number
+  tenantName: string
+  trialStartDate: string
+  convertedDate: string | null
+  status: TrialStatus
+  trialDays: number
+}
+
+export interface TrialConversionData {
+  totalTrial: number
+  convertedCount: number
+  notConvertedCount: number
+  inTrialCount: number
+  conversionRate: number | null
+  avgTrialDays: number | null
+  statusBuckets: TrialStatusBucket[]
+  monthTrend: TrialMonthTrend[]
+  campusStats: TrialCampusStat[]
+  list: TrialElderItem[]
+}
+
+export interface TrialConversionParams {
+  tenantIds?: number[]
+  startDate?: string
+  endDate?: string
+}
+
+/** 接口 F:试住转化分析聚合 */
+export const fetchTrialConversion = async (
+  params: TrialConversionParams = {}
+): Promise<TrialConversionData> => {
+  try {
+    const data = await request.get({
+      url: `/operation-analysis/overview/trial-conversion${buildQuery({ orgType: 1, ...params })}`
+    })
+    return {
+      totalTrial: Number(data?.totalTrial) || 0,
+      convertedCount: Number(data?.convertedCount) || 0,
+      notConvertedCount: Number(data?.notConvertedCount) || 0,
+      inTrialCount: Number(data?.inTrialCount) || 0,
+      conversionRate: data?.conversionRate ?? null,
+      avgTrialDays: data?.avgTrialDays ?? null,
+      statusBuckets: Array.isArray(data?.statusBuckets) ? data.statusBuckets : [],
+      monthTrend: Array.isArray(data?.monthTrend) ? data.monthTrend : [],
+      campusStats: Array.isArray(data?.campusStats) ? data.campusStats : [],
+      list: Array.isArray(data?.list) ? data.list : []
+    }
+  } catch (e) {
+    console.warn('[trial-conversion] 后端接口尚未上线,返回演示数据', e)
+    return getMockTrialConversionData()
+  }
+}
+
+/* ============================== 入住运营二期:轮候长者分析 ============================== */
+
+export interface WaitingNurseLevelBucket {
+  nurseLevelId: number
+  nurseLevelName: string
+  count: number
+}
+
+export interface WaitingDaysBucket {
+  label: string
+  count: number
+}
+
+export interface WaitingMonthTrend {
+  month: string
+  newCount: number
+  convertedCount: number
+  canceledCount: number
+}
+
+export interface WaitingCampusStat {
+  tenantId: number
+  tenantName: string
+  totalWaiting: number
+  avgWaitingDays: number | null
+  maxWaitingDays: number | null
+}
+
+export interface WaitingElderItem {
+  id: number
+  elderId: number
+  elderName: string
+  gender: number | string
+  age: number | null
+  tenantId: number
+  tenantName: string
+  nurseLevelId: number | null
+  nurseLevelName: string | null
+  waitingDays: number
+  registerDate: string
+  priority: string | null
+  status: string | null
+  contactName: string | null
+  contactPhone: string | null
+}
+
+export interface WaitingAnalysisData {
+  totalWaiting: number
+  avgWaitingDays: number | null
+  maxWaitingDays: number | null
+  nursingLevelBuckets: WaitingNurseLevelBucket[]
+  waitingDaysBuckets: WaitingDaysBucket[]
+  monthTrend: WaitingMonthTrend[]
+  campusStats: WaitingCampusStat[]
+  list: WaitingElderItem[]
+}
+
+/** 接口 G:轮候长者分析聚合 */
+export const fetchWaitingAnalysis = async (
+  tenantIds: number[] = []
+): Promise<WaitingAnalysisData> => {
+  try {
+    const data = await request.get({
+      url: `/operation-analysis/overview/waiting-analysis${buildQuery({ orgType: 1, tenantIds })}`
+    })
+    return {
+      totalWaiting: Number(data?.totalWaiting) || 0,
+      avgWaitingDays: data?.avgWaitingDays ?? null,
+      maxWaitingDays: data?.maxWaitingDays ?? null,
+      nursingLevelBuckets: Array.isArray(data?.nursingLevelBuckets) ? data.nursingLevelBuckets : [],
+      waitingDaysBuckets: Array.isArray(data?.waitingDaysBuckets) ? data.waitingDaysBuckets : [],
+      monthTrend: Array.isArray(data?.monthTrend) ? data.monthTrend : [],
+      campusStats: Array.isArray(data?.campusStats) ? data.campusStats : [],
+      list: Array.isArray(data?.list) ? data.list : []
+    }
+  } catch (e) {
+    console.warn('[waiting-analysis] 后端接口尚未上线,返回演示数据', e)
+    return getMockWaitingAnalysisData()
+  }
+}
+
+/* ============================== 入住运营二期:合同到期预警 ============================== */
+
+export interface ContractExpirySummary {
+  days: number
+  expiringCount: number
+  expiredCount: number
+}
+
+export interface ContractExpiryBucket {
+  label: string
+  count: number
+}
+
+export interface ContractExpiryMonthTrend {
+  month: string
+  count: number
+}
+
+export interface ContractExpiryItem {
+  elderId: number
+  elderName: string
+  gender: number | string
+  age: number | null
+  tenantId: number
+  tenantName: string
+  bedName: string | null
+  contractStartDate: string
+  contractEndDate: string
+  remainDays: number
+  contactName: string | null
+  contactPhone: string | null
+}
+
+export interface ContractExpiryData {
+  summary: ContractExpirySummary[]
+  distributionBuckets: ContractExpiryBucket[]
+  monthTrend: ContractExpiryMonthTrend[]
+  list: ContractExpiryItem[]
+}
+
+export interface ContractExpiryParams {
+  tenantIds?: number[]
+  days?: number[]
+}
+
+/** 接口 H:合同到期预警聚合 */
+export const fetchContractExpiry = async (
+  params: ContractExpiryParams = {}
+): Promise<ContractExpiryData> => {
+  try {
+    const data = await request.get({
+      url: `/operation-analysis/overview/contract-expiry${buildQuery({ orgType: 1, ...params })}`
+    })
+    return {
+      summary: Array.isArray(data?.summary) ? data.summary : [],
+      distributionBuckets: Array.isArray(data?.distributionBuckets) ? data.distributionBuckets : [],
+      monthTrend: Array.isArray(data?.monthTrend) ? data.monthTrend : [],
+      list: Array.isArray(data?.list) ? data.list : []
+    }
+  } catch (e) {
+    console.warn('[contract-expiry] 后端接口尚未上线,返回演示数据', e)
+    return getMockContractExpiryData()
+  }
+}
+
+/* ============================== 二期演示数据(后端接口上线后删除) ============================== */
+
+const getMockMoveoutAnalysisData = (): MoveoutAnalysisData => {
+  const reasonBuckets: MoveoutReasonBucket[] = [
+    { reasonValue: '1', reasonLabel: '正常退住', count: 42, avgStayDays: 386 },
+    { reasonValue: '2', reasonLabel: '死亡退住', count: 18, avgStayDays: 521 },
+    { reasonValue: '3', reasonLabel: '转院退住', count: 8, avgStayDays: 156 },
+    { reasonValue: '4', reasonLabel: '试住结束', count: 15, avgStayDays: 32 },
+    { reasonValue: '5', reasonLabel: '居家照护', count: 5, avgStayDays: 240 },
+    { reasonValue: '9', reasonLabel: '其他', count: 3, avgStayDays: 180 }
+  ]
+  const totalMoveOut = reasonBuckets.reduce((s, i) => s + i.count, 0)
+  const avgStayDays =
+    reasonBuckets.reduce((s, i) => s + i.count * (i.avgStayDays || 0), 0) / totalMoveOut
+  return {
+    totalMoveOut,
+    avgStayDays: Number(avgStayDays.toFixed(1)),
+    reasonBuckets,
+    monthTrend: [
+      { month: '2025-10', total: 8, reasonMap: { '1': 4, '2': 2, '4': 2 } },
+      { month: '2025-11', total: 6, reasonMap: { '1': 3, '2': 1, '3': 2 } },
+      { month: '2025-12', total: 9, reasonMap: { '1': 5, '2': 2, '4': 2 } },
+      { month: '2026-01', total: 7, reasonMap: { '1': 4, '2': 2, '9': 1 } },
+      { month: '2026-02', total: 5, reasonMap: { '1': 2, '2': 1, '3': 2 } },
+      { month: '2026-03', total: 10, reasonMap: { '1': 6, '2': 2, '4': 2 } },
+      { month: '2026-04', total: 8, reasonMap: { '1': 4, '2': 2, '5': 2 } },
+      { month: '2026-05', total: 12, reasonMap: { '1': 7, '2': 3, '4': 2 } },
+      { month: '2026-06', total: 9, reasonMap: { '1': 5, '2': 2, '3': 2 } },
+      { month: '2026-07', total: 11, reasonMap: { '1': 6, '2': 3, '4': 2 } },
+      { month: '2026-08', total: 7, reasonMap: { '1': 4, '2': 2, '9': 1 } },
+      { 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) })) }
+    ]
+  }
+}
+
+const getMockTrialConversionData = (): TrialConversionData => {
+  const totalTrial = 86
+  const convertedCount = 58
+  const notConvertedCount = 20
+  const inTrialCount = 8
+  const conversionRate = Number(((convertedCount / (convertedCount + notConvertedCount)) * 100).toFixed(1))
+  return {
+    totalTrial,
+    convertedCount,
+    notConvertedCount,
+    inTrialCount,
+    conversionRate,
+    avgTrialDays: 18.5,
+    statusBuckets: [
+      { status: 'converted', label: '已转化', count: convertedCount },
+      { status: 'not_converted', label: '未转化', count: notConvertedCount },
+      { status: 'in_trial', label: '试住中', count: inTrialCount }
+    ],
+    monthTrend: [
+      { month: '2025-10', trialCount: 6, convertedCount: 4, notConvertedCount: 2 },
+      { month: '2025-11', trialCount: 5, convertedCount: 3, notConvertedCount: 1 },
+      { month: '2025-12', trialCount: 8, convertedCount: 5, notConvertedCount: 2 },
+      { month: '2026-01', trialCount: 7, convertedCount: 5, notConvertedCount: 1 },
+      { month: '2026-02', trialCount: 6, convertedCount: 4, notConvertedCount: 1 },
+      { month: '2026-03', trialCount: 9, convertedCount: 6, notConvertedCount: 2 },
+      { month: '2026-04', trialCount: 8, convertedCount: 6, notConvertedCount: 2 },
+      { month: '2026-05', trialCount: 10, convertedCount: 7, notConvertedCount: 2 },
+      { month: '2026-06', trialCount: 7, convertedCount: 5, notConvertedCount: 2 },
+      { month: '2026-07', trialCount: 9, convertedCount: 6, notConvertedCount: 2 },
+      { month: '2026-08', trialCount: 8, convertedCount: 6, notConvertedCount: 1 },
+      { 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 }
+    ],
+    list: Array.from({ length: 12 }).map((_, i) => ({
+      elderId: 1000 + i,
+      elderName: `试住长者${i + 1}`,
+      gender: i % 2 === 0 ? 1 : 2,
+      age: 75 + (i % 15),
+      tenantId: 196 + (i % 3),
+      tenantName: ['颐年东涌养老院', '颐年五心养老院', '其他院区'][i % 3],
+      trialStartDate: `2026-09-${String(10 + i).padStart(2, '0')}`,
+      convertedDate: i < 8 ? `2026-09-${String(20 + i).padStart(2, '0')}` : null,
+      status: (i < 8 ? 'converted' : i < 10 ? 'in_trial' : 'not_converted') as TrialStatus,
+      trialDays: 12 + i * 2
+    }))
+  }
+}
+
+const getMockWaitingAnalysisData = (): WaitingAnalysisData => {
+  return {
+    totalWaiting: 55,
+    avgWaitingDays: 23.5,
+    maxWaitingDays: 96,
+    nursingLevelBuckets: [
+      { nurseLevelId: 1, nurseLevelName: '一级护理', count: 22 },
+      { nurseLevelId: 2, nurseLevelName: '二级护理', count: 18 },
+      { nurseLevelId: 3, nurseLevelName: '三级护理', count: 8 },
+      { nurseLevelId: 4, nurseLevelName: '特级护理', count: 5 },
+      { nurseLevelId: 0, nurseLevelName: '未设置', count: 2 }
+    ],
+    waitingDaysBuckets: [
+      { label: '0-7天', count: 12 },
+      { label: '8-30天', count: 20 },
+      { label: '31-90天', count: 15 },
+      { label: '90天以上', count: 8 }
+    ],
+    monthTrend: [
+      { month: '2025-10', newCount: 8, convertedCount: 5, canceledCount: 1 },
+      { month: '2025-11', newCount: 6, convertedCount: 4, canceledCount: 0 },
+      { month: '2025-12', newCount: 9, convertedCount: 6, canceledCount: 1 },
+      { month: '2026-01', newCount: 7, convertedCount: 5, canceledCount: 1 },
+      { month: '2026-02', newCount: 5, convertedCount: 3, canceledCount: 1 },
+      { month: '2026-03', newCount: 10, convertedCount: 7, canceledCount: 1 },
+      { month: '2026-04', newCount: 8, convertedCount: 5, canceledCount: 1 },
+      { month: '2026-05', newCount: 12, convertedCount: 8, canceledCount: 1 },
+      { month: '2026-06', newCount: 9, convertedCount: 6, canceledCount: 1 },
+      { month: '2026-07', newCount: 11, convertedCount: 7, canceledCount: 1 },
+      { month: '2026-08', newCount: 7, convertedCount: 5, canceledCount: 1 },
+      { 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 }
+    ],
+    list: Array.from({ length: 12 }).map((_, i) => ({
+      id: 2000 + i,
+      elderId: 3000 + i,
+      elderName: `轮候长者${i + 1}`,
+      gender: i % 2 === 0 ? 2 : 1,
+      age: 78 + (i % 12),
+      tenantId: 196 + (i % 3),
+      tenantName: ['颐年东涌养老院', '颐年五心养老院', '其他院区'][i % 3],
+      nurseLevelId: [1, 2, 3, 4, 0][i % 5],
+      nurseLevelName: ['一级护理', '二级护理', '三级护理', '特级护理', '未设置'][i % 5],
+      waitingDays: 5 + i * 7,
+      registerDate: `2026-09-${String(1 + i).padStart(2, '0')}`,
+      priority: ['高', '中', '低'][i % 3],
+      status: '排队中',
+      contactName: `家属${i + 1}`,
+      contactPhone: `1380013${String(1000 + i).slice(1)}`
+    }))
+  }
+}
+
+const getMockContractExpiryData = (): ContractExpiryData => {
+  return {
+    summary: [
+      { days: 30, expiringCount: 18, expiredCount: 3 },
+      { days: 60, expiringCount: 45, expiredCount: 3 },
+      { days: 90, expiringCount: 72, expiredCount: 3 }
+    ],
+    distributionBuckets: [
+      { label: '已过期', count: 3 },
+      { label: '0-30天', count: 15 },
+      { label: '31-60天', count: 27 },
+      { label: '61-90天', count: 27 },
+      { label: '90天以上', count: 18 }
+    ],
+    monthTrend: [
+      { month: '2026-10', count: 22 },
+      { month: '2026-11', count: 18 },
+      { month: '2026-12', count: 15 },
+      { month: '2027-01', count: 12 },
+      { month: '2027-02', count: 10 },
+      { month: '2027-03', count: 14 },
+      { month: '2027-04', count: 16 },
+      { month: '2027-05', count: 11 },
+      { month: '2027-06', count: 9 },
+      { month: '2027-07', count: 13 },
+      { month: '2027-08', count: 17 },
+      { month: '2027-09', count: 20 }
+    ],
+    list: Array.from({ length: 15 }).map((_, i) => ({
+      elderId: 4000 + i,
+      elderName: `合同长者${i + 1}`,
+      gender: i % 2 === 0 ? 1 : 2,
+      age: 80 + (i % 10),
+      tenantId: 196 + (i % 3),
+      tenantName: ['颐年东涌养老院', '颐年五心养老院', '其他院区'][i % 3],
+      bedName: `1栋-${101 + i}床`,
+      contractStartDate: '2025-10-01',
+      contractEndDate: `2026-${String(10 + (i % 3)).padStart(2, '0')}-${String(1 + (i % 28)).padStart(2, '0')}`,
+      remainDays: -5 + i * 8,
+      contactName: `家属${i + 1}`,
+      contactPhone: `1390013${String(1000 + i).slice(1)}`
+    }))
+  }
+}

+ 528 - 3
src/views/operation-analysis/occupancy/trial-conversion/index.vue

@@ -1,7 +1,532 @@
 <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:refresh" 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>
+        </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 label="时间范围">
+          <el-date-picker
+            v-model="dateRange"
+            type="daterange"
+            range-separator="至"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            value-format="YYYY-MM-DD"
+            class="range-picker"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
+          <el-button :icon="RefreshLeft" @click="handleReset">重置</el-button>
+          <el-button type="success" :icon="Download" :disabled="!hasData" @click="handleExport">
+            导出 Excel
+          </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>
+          </div>
+        </div>
+      </div>
+    </ContentWrap>
+
+    <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="statusPieOption" height="360px" />
+            <el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
+          </div>
+        </ContentWrap>
+      </el-col>
+      <el-col :xs="24" :lg="14">
+        <ContentWrap>
+          <div class="panel-title">各院区试住转化率</div>
+          <Echart v-if="hasData" :options="campusRateOption" height="360px" />
+          <el-empty v-else description="暂无数据" :image-size="80" class="chart-empty" />
+        </ContentWrap>
+      </el-col>
+    </el-row>
+
+    <ContentWrap class="chart-wrap">
+      <div class="panel-title">
+        月度试住趋势
+        <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="pagedList" border stripe>
+        <el-table-column type="index" label="序号" width="60" align="center" />
+        <el-table-column prop="elderName" label="长者姓名" min-width="110" show-overflow-tooltip />
+        <el-table-column label="性别" width="70" align="center">
+          <template #default="{ row }">{{ row.gender === 1 ? '男' : row.gender === 2 ? '女' : '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="age" label="年龄" width="70" align="center">
+          <template #default="{ row }">{{ row.age ?? '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="tenantName" label="所属机构" min-width="150" show-overflow-tooltip />
+        <el-table-column prop="trialStartDate" label="试住开始" min-width="120" align="center" />
+        <el-table-column prop="trialDays" label="试住天数" width="90" align="center" sortable />
+        <el-table-column label="当前状态" width="100" align="center">
+          <template #default="{ row }">
+            <el-tag :type="statusTag(row.status)" size="small" effect="light">
+              {{ statusLabel(row.status) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="convertedDate" label="转化日期" min-width="120" align="center">
+          <template #default="{ row }">{{ row.convertedDate || '-' }}</template>
+        </el-table-column>
+      </el-table>
+      <div class="pager">
+        <el-pagination
+          v-model:current-page="pageNo"
+          v-model:page-size="pageSize"
+          :total="list.length"
+          :page-sizes="[10, 20, 50]"
+          layout="total, sizes, prev, pager, next"
+          background
+        />
+      </div>
+    </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 { getCampusOptions } from '../../overview/campus-data'
+import {
+  fetchTrialConversion,
+  type TrialConversionData,
+  type TrialStatus
+} from '../occupancy-api'
+
+defineOptions({ name: 'OaOccupancyTrialConversion' })
+
+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 dateRange = ref<string[]>([])
+const data = ref<TrialConversionData | null>(null)
+const hasData = computed(() => (data.value?.totalTrial ?? 0) > 0)
+
+const pageNo = ref(1)
+const pageSize = ref(10)
+const list = computed(() => data.value?.list || [])
+const pagedList = computed(() => {
+  const start = (pageNo.value - 1) * pageSize.value
+  return list.value.slice(start, start + pageSize.value)
+})
+
+const pad2 = (n: number) => String(n).padStart(2, '0')
+const fmtDate = (d: Date) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
+
+const setDefaultRange = () => {
+  const now = new Date()
+  const start = new Date(now.getFullYear(), now.getMonth() - 11, 1)
+  dateRange.value = [fmtDate(start), fmtDate(now)]
+}
+
+const statusMeta: Record<TrialStatus, { label: string; tag: '' | 'success' | 'warning' | 'danger' | 'info' }> = {
+  converted: { label: '已转化', tag: 'success' },
+  not_converted: { label: '未转化', tag: 'danger' },
+  in_trial: { label: '试住中', tag: 'warning' }
+}
+const statusLabel = (s: TrialStatus) => statusMeta[s]?.label ?? s
+const statusTag = (s: TrialStatus) => statusMeta[s]?.tag ?? 'info'
+
+const PIE_COLORS = ['#67c23a', '#f56c6c', '#e6a23c', '#409eff', '#8b5cf6', '#36a866', '#909399']
+
+/* ---------------- KPI ---------------- */
+
+const kpiCards = computed(() => {
+  const d = data.value
+  return [
+    { label: '试住总人数', value: d?.totalTrial || 0, unit: '人', icon: 'ep:user', theme: 'blue' },
+    { label: '已转化人数', value: d?.convertedCount || 0, unit: '人', icon: 'ep:circle-check', theme: 'cyan' },
+    {
+      label: '整体转化率',
+      value: d?.conversionRate ?? '-',
+      unit: d?.conversionRate == null ? '' : '%',
+      icon: 'ep:pie-chart',
+      theme: 'orange'
+    },
+    {
+      label: '平均试住天数',
+      value: d?.avgTrialDays ?? '-',
+      unit: d?.avgTrialDays == null ? '' : '天',
+      icon: 'ep:timer',
+      theme: 'purple'
+    }
+  ]
+})
+
+/* ---------------- 图表 ---------------- */
+
+const statusPieOption = computed(() => {
+  const buckets = data.value?.statusBuckets || []
+  const order: TrialStatus[] = ['converted', 'in_trial', 'not_converted']
+  const sorted = [...buckets].sort((a, b) => order.indexOf(a.status) - order.indexOf(b.status))
+  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: sorted.map((b, i) => ({
+          name: statusLabel(b.status),
+          value: b.count,
+          itemStyle: { color: PIE_COLORS[i % PIE_COLORS.length] }
+        }))
+      }
+    ]
+  }
+})
+
+const campusList = computed(() =>
+  (data.value?.campusStats || []).filter((c) => c.conversionRate != null).sort((a, b) => (b.conversionRate || 0) - (a.conversionRate || 0))
+)
+
+const campusRateOption = computed(() => ({
+  tooltip: {
+    trigger: 'axis',
+    axisPointer: { type: 'shadow' },
+    formatter: (p: any[]) => {
+      const item = p[0]
+      return `${item.name}<br/>转化率:${item.value}%`
+    }
+  },
+  grid: { left: 120, right: 56, top: 16, bottom: 16, containLabel: true },
+  xAxis: { type: 'value', name: '%', max: 100 },
+  yAxis: {
+    type: 'category',
+    data: campusList.value.map((c) => c.tenantName || `院区${c.tenantId}`),
+    axisLabel: { width: 110, overflow: 'truncate' }
+  },
+  series: [
+    {
+      type: 'bar',
+      barWidth: 18,
+      data: campusList.value.map((c) => c.conversionRate),
+      itemStyle: { borderRadius: [0, 4, 4, 0], color: '#36a866' },
+      label: { show: true, position: 'right', formatter: '{c}%' }
+    }
+  ]
+}))
+
+const monthTrendOption = computed(() => {
+  const items = data.value?.monthTrend || []
+  return {
+    tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
+    legend: { data: ['试住人数', '已转化', '未转化', '转化率'], top: 0 },
+    grid: { left: 48, right: 56, top: 40, bottom: 30 },
+    xAxis: { type: 'category', data: items.map((i) => i.month) },
+    yAxis: [
+      { type: 'value', name: '人', minInterval: 1 },
+      { type: 'value', name: '转化率%', min: 0, max: 100, position: 'right', splitLine: { show: false } }
+    ],
+    series: [
+      { name: '试住人数', type: 'bar', barWidth: 16, data: items.map((i) => i.trialCount), itemStyle: { color: '#409eff' } },
+      { name: '已转化', type: 'bar', barWidth: 16, data: items.map((i) => i.convertedCount), itemStyle: { color: '#67c23a' } },
+      { name: '未转化', type: 'bar', barWidth: 16, data: items.map((i) => i.notConvertedCount), itemStyle: { color: '#f56c6c' } },
+      {
+        name: '转化率',
+        type: 'line',
+        yAxisIndex: 1,
+        data: items.map((i) => {
+          const denom = i.convertedCount + i.notConvertedCount
+          return denom ? Number(((i.convertedCount / denom) * 100).toFixed(1)) : 0
+        }),
+        smooth: true,
+        symbolSize: 6,
+        lineStyle: { width: 2.5, color: '#e6a23c' },
+        itemStyle: { color: '#e6a23c' }
+      }
+    ]
+  }
+})
+
+/* ---------------- 加载与导出 ---------------- */
+
+const loadAll = async () => {
+  const ids = selectedCampusIds.value.length
+    ? selectedCampusIds.value
+    : campusOptions.map((item) => item.value)
+  loading.value = true
+  loadFailed.value = false
+  pageNo.value = 1
+  try {
+    const params: any = { tenantIds: ids }
+    if (dateRange.value && dateRange.value.length === 2) {
+      params.startDate = dateRange.value[0]
+      params.endDate = dateRange.value[1]
+    }
+    data.value = await fetchTrialConversion(params)
+    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
+    data.value = null
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleQuery = () => {
+  loadAll()
+}
+
+const handleReset = () => {
+  selectedCampusIds.value = campusOptions.map((item) => item.value)
+  setDefaultRange()
+  loadAll()
+}
+
+const handleExport = () => {
+  const rows = list.value.map((r) => ({
+    ...r,
+    genderText: r.gender === 1 ? '男' : r.gender === 2 ? '女' : '-',
+    statusText: statusLabel(r.status as TrialStatus)
+  }))
+  const headers = [
+    { key: 'elderName', title: '长者姓名', width: 14 },
+    { key: 'genderText', title: '性别', width: 8 },
+    { key: 'age', title: '年龄', width: 8 },
+    { key: 'tenantName', title: '所属机构', width: 22 },
+    { key: 'trialStartDate', title: '试住开始', width: 14 },
+    { key: 'trialDays', title: '试住天数', width: 10 },
+    { key: 'statusText', title: '当前状态', width: 12 },
+    { key: 'convertedDate', title: '转化日期', width: 14 }
+  ]
+  exportWithCustomHeaders(rows, headers, `试住转化分析_${new Date().toISOString().slice(0, 10)}.xlsx`, '试住长者明细')
+  message.success('导出成功')
+}
+
+onMounted(() => {
+  setDefaultRange()
+  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;
+      }
+    }
+
+    .header-extra {
+      display: flex;
+      align-items: center;
+    }
+
+    .update-time {
+      margin-right: 14px;
+      font-size: 13px;
+      color: #909399;
+    }
+  }
+
+  .filter-form {
+    margin-top: 4px;
+
+    .campus-select {
+      width: 300px;
+      max-width: 56vw;
+    }
+    .range-picker {
+      width: 260px;
+      max-width: 50vw;
+    }
+  }
+
+  .api-tip {
+    margin: 4px 0 14px;
+  }
+
+  .kpi-grid {
+    display: grid;
+    grid-template-columns: repeat(4, 1fr);
+    gap: 12px;
+
+    @media (max-width: 1200px) {
+      grid-template-columns: repeat(2, 1fr);
+    }
+    @media (max-width: 768px) {
+      grid-template-columns: 1fr;
+    }
+
+    .kpi-card {
+      padding: 16px 18px;
+      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;
+        }
+      }
+
+      &.blue {
+        background: linear-gradient(135deg, #5b9bff 0%, #2f7cf6 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%);
+      }
+    }
+  }
+
+  .chart-row,
+  .chart-wrap,
+  .table-wrap {
+    margin-top: 12px;
+  }
+
+  .chart-empty {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 320px;
+  }
+
+  .donut-wrap {
+    position: relative;
+  }
+
+  .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;
+    }
+  }
+
+  .pager {
+    display: flex;
+    justify-content: flex-end;
+    margin-top: 12px;
+  }
+}
+</style>

+ 493 - 0
src/views/operation-analysis/occupancy/waiting-analysis/index.vue

@@ -0,0 +1,493 @@
+<template>
+  <div v-loading="loading" class="oa-page" element-loading-text="正在加载轮候长者数据…">
+    <ContentWrap>
+      <div class="page-header">
+        <div class="header-title">
+          <Icon icon="ep:user-filled" 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>
+        </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="handleQuery">查询</el-button>
+          <el-button :icon="RefreshLeft" @click="handleReset">重置</el-button>
+          <el-button type="success" :icon="Download" :disabled="!hasData" @click="handleExport">
+            导出 Excel
+          </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>
+          </div>
+        </div>
+      </div>
+    </ContentWrap>
+
+    <el-row :gutter="12" class="chart-row">
+      <el-col :xs="24" :lg="12">
+        <ContentWrap>
+          <div class="panel-title">护理等级分布</div>
+          <Echart v-if="hasData" :options="nurseLevelPieOption" 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="hasData" :options="waitingDaysOption" 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="12">
+        <ContentWrap>
+          <div class="panel-title">各院区轮候人数</div>
+          <Echart v-if="hasData" :options="campusBarOption" 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="hasData" :options="monthTrendOption" 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">轮候长者明细</div>
+      <el-table :data="pagedList" border stripe>
+        <el-table-column type="index" label="序号" width="60" align="center" />
+        <el-table-column prop="elderName" label="长者姓名" min-width="110" show-overflow-tooltip />
+        <el-table-column label="性别" width="70" align="center">
+          <template #default="{ row }">{{ row.gender === 1 ? '男' : row.gender === 2 ? '女' : '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="age" label="年龄" width="70" align="center">
+          <template #default="{ row }">{{ row.age ?? '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="nurseLevelName" label="护理等级" min-width="110" align="center">
+          <template #default="{ row }">{{ row.nurseLevelName || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="tenantName" label="意向机构" min-width="150" show-overflow-tooltip />
+        <el-table-column prop="registerDate" label="登记日期" min-width="120" align="center" />
+        <el-table-column prop="waitingDays" label="轮候天数" width="100" align="center" sortable />
+        <el-table-column label="优先级" width="90" align="center">
+          <template #default="{ row }">
+            <el-tag :type="priorityTag(row.priority)" size="small" effect="light">{{ row.priority || '-' }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="contactName" label="联系人" min-width="100" align="center">
+          <template #default="{ row }">{{ row.contactName || '-' }}</template>
+        </el-table-column>
+      </el-table>
+      <div class="pager">
+        <el-pagination
+          v-model:current-page="pageNo"
+          v-model:page-size="pageSize"
+          :total="list.length"
+          :page-sizes="[10, 20, 50]"
+          layout="total, sizes, prev, pager, next"
+          background
+        />
+      </div>
+    </ContentWrap>
+  </div>
+</template>
+
+<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 { getCampusOptions } from '../../overview/campus-data'
+import { fetchWaitingAnalysis, type WaitingAnalysisData } from '../occupancy-api'
+
+defineOptions({ name: 'OaOccupancyWaitingAnalysis' })
+
+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 data = ref<WaitingAnalysisData | null>(null)
+const hasData = computed(() => (data.value?.totalWaiting ?? 0) > 0)
+
+const pageNo = ref(1)
+const pageSize = ref(10)
+const list = computed(() => data.value?.list || [])
+const pagedList = computed(() => {
+  const start = (pageNo.value - 1) * pageSize.value
+  return list.value.slice(start, start + pageSize.value)
+})
+
+const pad2 = (n: number) => String(n).padStart(2, '0')
+
+const priorityTag = (p: string | null) => {
+  if (p === '高') return 'danger' as const
+  if (p === '中') return 'warning' as const
+  if (p === '低') return 'info' as const
+  return 'info' as const
+}
+
+const PIE_COLORS = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#8b5cf6', '#36a866', '#909399']
+
+/* ---------------- KPI ---------------- */
+
+const kpiCards = computed(() => {
+  const d = data.value
+  return [
+    { label: '轮候总人数', value: d?.totalWaiting || 0, unit: '人', icon: 'ep:user', theme: 'blue' },
+    {
+      label: '平均轮候天数',
+      value: d?.avgWaitingDays ?? '-',
+      unit: d?.avgWaitingDays == null ? '' : '天',
+      icon: 'ep:timer',
+      theme: 'cyan'
+    },
+    {
+      label: '最长轮候天数',
+      value: d?.maxWaitingDays ?? '-',
+      unit: d?.maxWaitingDays == null ? '' : '天',
+      icon: 'ep:alarm-clock',
+      theme: 'orange'
+    },
+    { label: '涉及院区', value: d?.campusStats?.length || 0, unit: '个', icon: 'ep:office-building', theme: 'purple' }
+  ]
+})
+
+/* ---------------- 图表 ---------------- */
+
+const nurseLevelPieOption = computed(() => {
+  const buckets = (data.value?.nursingLevelBuckets || [])
+    .filter((b) => b.count > 0)
+    .sort((a, b) => b.count - a.count)
+  return {
+    tooltip: { trigger: 'item', formatter: '{b}:{c} 人({d}%)' },
+    legend: { bottom: 0, icon: 'circle' },
+    series: [
+      {
+        type: 'pie',
+        radius: ['45%', '70%'],
+        center: ['50%', '46%'],
+        label: { formatter: '{b}\n{c} 人' },
+        data: buckets.map((b, i) => ({
+          name: b.nurseLevelName,
+          value: b.count,
+          itemStyle: { color: PIE_COLORS[i % PIE_COLORS.length] }
+        }))
+      }
+    ]
+  }
+})
+
+const waitingDaysOption = computed(() => {
+  const buckets = (data.value?.waitingDaysBuckets || []).sort((a, b) => b.count - a.count)
+  return {
+    tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: '{b}:{c} 人' },
+    grid: { left: 90, right: 48, top: 16, bottom: 16, containLabel: true },
+    xAxis: { type: 'value', name: '人' },
+    yAxis: {
+      type: 'category',
+      data: buckets.map((b) => b.label),
+      axisLabel: { width: 80, overflow: 'truncate' }
+    },
+    series: [
+      {
+        type: 'bar',
+        barWidth: 16,
+        data: buckets.map((b) => b.count),
+        itemStyle: { borderRadius: [0, 4, 4, 0], color: '#36a866' },
+        label: { show: true, position: 'right', formatter: '{c}' }
+      }
+    ]
+  }
+})
+
+const campusList = computed(() =>
+  (data.value?.campusStats || []).slice().sort((a, b) => b.totalWaiting - a.totalWaiting)
+)
+
+const campusBarOption = computed(() => ({
+  tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: '{b}:{c} 人' },
+  grid: { left: 120, right: 56, top: 16, bottom: 16, containLabel: true },
+  xAxis: { type: 'value', name: '人' },
+  yAxis: {
+    type: 'category',
+    data: campusList.value.map((c) => c.tenantName || `院区${c.tenantId}`),
+    axisLabel: { width: 110, overflow: 'truncate' }
+  },
+  series: [
+    {
+      type: 'bar',
+      barWidth: 16,
+      data: campusList.value.map((c) => c.totalWaiting),
+      itemStyle: { borderRadius: [0, 4, 4, 0], color: '#409eff' },
+      label: { show: true, position: 'right', formatter: '{c}' }
+    }
+  ]
+}))
+
+const monthTrendOption = computed(() => {
+  const items = data.value?.monthTrend || []
+  return {
+    tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
+    legend: { data: ['新增轮候', '转化入住', '取消轮候'], 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 },
+    series: [
+      { name: '新增轮候', type: 'bar', barWidth: 14, stack: 't', data: items.map((i) => i.newCount), itemStyle: { color: '#409eff' } },
+      { name: '转化入住', type: 'bar', barWidth: 14, stack: 't', data: items.map((i) => i.convertedCount), itemStyle: { color: '#67c23a' } },
+      { name: '取消轮候', type: 'bar', barWidth: 14, stack: 't', data: items.map((i) => i.canceledCount), itemStyle: { color: '#f56c6c' } }
+    ]
+  }
+})
+
+/* ---------------- 加载与导出 ---------------- */
+
+const loadAll = async () => {
+  const ids = selectedCampusIds.value.length
+    ? selectedCampusIds.value
+    : campusOptions.map((item) => item.value)
+  loading.value = true
+  loadFailed.value = false
+  pageNo.value = 1
+  try {
+    data.value = await fetchWaitingAnalysis(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
+    data.value = null
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleQuery = () => {
+  loadAll()
+}
+
+const handleReset = () => {
+  selectedCampusIds.value = campusOptions.map((item) => item.value)
+  loadAll()
+}
+
+const handleExport = () => {
+  const rows = list.value.map((r) => ({
+    ...r,
+    genderText: r.gender === 1 ? '男' : r.gender === 2 ? '女' : '-'
+  }))
+  const headers = [
+    { key: 'elderName', title: '长者姓名', width: 14 },
+    { key: 'genderText', title: '性别', width: 8 },
+    { key: 'age', title: '年龄', width: 8 },
+    { key: 'nurseLevelName', title: '护理等级', width: 12 },
+    { key: 'tenantName', title: '意向机构', width: 22 },
+    { key: 'registerDate', title: '登记日期', width: 14 },
+    { key: 'waitingDays', title: '轮候天数', width: 10 },
+    { key: 'priority', title: '优先级', width: 10 },
+    { key: 'contactName', title: '联系人', width: 12 },
+    { key: 'contactPhone', title: '联系电话', width: 16 }
+  ]
+  exportWithCustomHeaders(rows, headers, `轮候长者分析_${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;
+      }
+    }
+
+    .header-extra {
+      display: flex;
+      align-items: center;
+    }
+
+    .update-time {
+      margin-right: 14px;
+      font-size: 13px;
+      color: #909399;
+    }
+  }
+
+  .filter-form {
+    margin-top: 4px;
+
+    .campus-select {
+      width: 300px;
+      max-width: 56vw;
+    }
+  }
+
+  .api-tip {
+    margin: 4px 0 14px;
+  }
+
+  .kpi-grid {
+    display: grid;
+    grid-template-columns: repeat(4, 1fr);
+    gap: 12px;
+
+    @media (max-width: 1200px) {
+      grid-template-columns: repeat(2, 1fr);
+    }
+    @media (max-width: 768px) {
+      grid-template-columns: 1fr;
+    }
+
+    .kpi-card {
+      padding: 16px 18px;
+      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;
+        }
+      }
+
+      &.blue {
+        background: linear-gradient(135deg, #5b9bff 0%, #2f7cf6 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%);
+      }
+    }
+  }
+
+  .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;
+    }
+  }
+
+  .pager {
+    display: flex;
+    justify-content: flex-end;
+    margin-top: 12px;
+  }
+}
+</style>

+ 314 - 0
src/views/operation-analysis/occupancy/入住运营二期-设计与后端接口需求.md

@@ -0,0 +1,314 @@
+# 入住运营二期 — 页面设计与后端接口需求
+
+> 模块:运营分析 / 入住运营 | 2026-09-29 | 前端 4 页新建
+>
+> 本期在已有的 4 张入住运营报表(入住率与床位利用分析、空床明细报表、入住退住趋势与净增长、长者结构分析)基础上,
+> 新增:退住原因与平均入住时长、试住转化分析、轮候长者分析、合同到期预警。
+
+| # | 报表 | 路由 |
+| --- | --- | --- |
+| 1 | 退住原因与平均入住时长 | `/operation-analysis/occupancy/moveout-reason` |
+| 2 | 试住转化分析 | `/operation-analysis/occupancy/trial-conversion` |
+| 3 | 轮候长者分析 | `/operation-analysis/occupancy/waiting-analysis` |
+| 4 | 合同到期预警 | `/operation-analysis/occupancy/contract-expiry` |
+
+全局口径与既有一期保持一致:院区范围按登录人数据权限;在院口径为 `inStatus=1`;入住/退住以实际完成办理、档案状态变更时间为准。
+
+---
+
+## 一、退住原因与平均入住时长
+
+### 1.1 页面功能
+
+- 院区多选(默认全部);
+- 时间范围(默认近 12 个月,yyyy-MM-dd);
+- 4 张 KPI:退住总人数、平均入住时长(天)、TOP1 退住原因、TOP1 原因占比;
+- 退住原因分布(饼图/环形图);
+- 各原因平均入住时长对比(横向柱);
+- 月度退住趋势(按原因堆叠柱 + 总计折线,或分组柱);
+- 院区明细表:院区 / 退住人数 / 平均入住时长 / 各原因人数;
+- Excel 导出。
+
+### 1.2 后端接口
+
+`GET /operation-analysis/overview/moveout-analysis`
+
+参数:
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| tenantIds | Long[] | 否 | 空 = 当前账号可见全部院区 |
+| startDate | String | 否 | 开始日期 yyyy-MM-dd,默认近 12 个月首日 |
+| endDate | String | 否 | 结束日期 yyyy-MM-dd,默认今天 |
+| orgType | Integer | 否 | 固定 1 |
+
+响应 `data`:
+
+```ts
+{
+  totalMoveOut: number           // 区间退住总人数
+  avgStayDays: number | null     // 平均入住时长(天),保留 1 位小数
+  reasonBuckets: [
+    {
+      reasonValue: string | number | null // 字典原值(back_cause_arr),null=未设置
+      reasonLabel: string         // 原因名称
+      count: number               // 该原因退住人数
+      avgStayDays: number | null  // 该原因平均入住时长(天)
+    }
+  ]
+  monthTrend: [                   // 按月升序,无数据补 0
+    {
+      month: string               // yyyy-MM
+      total: number               // 当月退住总数
+      reasonMap: Record<string, number> // key=reasonValue, value=人数
+    }
+  ]
+  campusStats: [                  // 按院区统计
+    {
+      tenantId: number
+      tenantName: string
+      total: number               // 该院区退住人数
+      avgStayDays: number | null
+      reasonBuckets: [...]        // 同上层 reasonBuckets 结构
+    }
+  ]
+}
+```
+
+> 注意:`reasonValue` 返回字典原值,由前端按 `DICT_TYPE.BACK_CAUSE_ARR` 翻译;`null` 或空值归为"未设置"。
+
+---
+
+## 二、试住转化分析
+
+### 2.1 页面功能
+
+- 院区多选(默认全部);
+- 时间范围(默认近 12 个月);
+- 4 张 KPI:试住总人数、已转化人数、转化率(%)、平均试住天数;
+- 试住状态分布(环形图:已转化 / 试住中 / 未转化);
+- 月度试住与转化趋势(柱 + 折线);
+- 各院区转化对比(横向柱:转化率);
+- 试住明细表:长者 / 院区 / 试住开始日期 / 试住天数 / 状态 / 转化日期;
+- Excel 导出。
+
+### 2.2 后端接口
+
+`GET /operation-analysis/overview/trial-conversion`
+
+参数:
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| tenantIds | Long[] | 否 | 空 = 当前账号可见全部院区 |
+| startDate | String | 否 | 默认近 12 个月首日 |
+| endDate | String | 否 | 默认今天 |
+| orgType | Integer | 否 | 固定 1 |
+
+响应 `data`:
+
+```ts
+{
+  totalTrial: number             // 试住总人数
+  convertedCount: number         // 已转长住人数
+  notConvertedCount: number      // 未转化人数(含退住、流失等)
+  inTrialCount: number           // 当前仍在试住中人数
+  conversionRate: number | null  // 转化率 % = converted / (converted + notConverted),保留 1 位小数
+  avgTrialDays: number | null     // 平均试住天数
+  statusBuckets: [
+    { status: 'converted' | 'not_converted' | 'in_trial'; label: string; count: number }
+  ]
+  monthTrend: [                   // 按月升序
+    {
+      month: string               // yyyy-MM
+      trialCount: number          // 当月新增试住
+      convertedCount: number      // 当月转化
+      notConvertedCount: number   // 当月未转化
+    }
+  ]
+  campusStats: [
+    {
+      tenantId: number
+      tenantName: string
+      totalTrial: number
+      convertedCount: number
+      notConvertedCount: number
+      conversionRate: number | null
+      avgTrialDays: number | null
+    }
+  ]
+  list: [                         // 试住明细(可选分页,默认前端拉取后本地分页)
+    {
+      elderId: number
+      elderName: string
+      gender: number | string      // 1 男 2 女
+      age: number | null
+      tenantId: number
+      tenantName: string
+      trialStartDate: string       // yyyy-MM-dd
+      convertedDate: string | null // 转化日期
+      status: 'converted' | 'not_converted' | 'in_trial'
+      trialDays: number            // 已试住 / 试住天数
+    }
+  ]
+}
+```
+
+---
+
+## 三、轮候长者分析
+
+### 3.1 页面功能
+
+- 院区多选(默认全部);
+- 3 张 KPI:当前轮候总数、平均轮候天数、最长轮候天数;
+- 轮候长者护理等级分布(横向柱 / 饼图);
+- 轮候天数分布(柱:0-7 天 / 8-30 天 / 31-90 天 / 90 天以上);
+- 轮候趋势(月度新增 vs 月度转入住);
+- 轮候明细表:序号 / 长者 / 性别 / 年龄 / 期望护理等级 / 轮候天数 / 登记日期 / 状态;
+- Excel 导出。
+
+### 3.2 后端接口
+
+`GET /operation-analysis/overview/waiting-analysis`
+
+参数:
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| tenantIds | Long[] | 否 | 空 = 当前账号可见全部院区 |
+| orgType | Integer | 否 | 固定 1 |
+
+响应 `data`:
+
+```ts
+{
+  totalWaiting: number            // 当前轮候总数
+  avgWaitingDays: number | null   // 平均轮候天数
+  maxWaitingDays: number | null   // 最长轮候天数
+  nursingLevelBuckets: [          // 按期望/评估护理等级统计
+    { nurseLevelId: number; nurseLevelName: string; count: number }
+  ]
+  waitingDaysBuckets: [           // 轮候天数分段,固定骨架
+    { label: string; count: number } // 0-7天 / 8-30天 / 31-90天 / 90天以上
+  ]
+  monthTrend: [                   // 近 12 个月
+    {
+      month: string               // yyyy-MM
+      newCount: number            // 当月新增轮候
+      convertedCount: number      // 当月轮候转入住
+      canceledCount: number       // 当月取消/失效
+    }
+  ]
+  campusStats: [
+    {
+      tenantId: number
+      tenantName: string
+      totalWaiting: number
+      avgWaitingDays: number | null
+      maxWaitingDays: number | null
+    }
+  ]
+  list: [                         // 当前轮候明细
+    {
+      id: number
+      elderId: number
+      elderName: string
+      gender: number | string
+      age: number | null
+      tenantId: number
+      tenantName: string
+      nurseLevelId: number | null
+      nurseLevelName: string | null
+      waitingDays: number
+      registerDate: string         // yyyy-MM-dd
+      priority: string | null      // 优先级/紧急程度
+      status: string | null        // 轮候状态
+      contactName: string | null
+      contactPhone: string | null
+    }
+  ]
+}
+```
+
+---
+
+## 四、合同到期预警
+
+### 4.1 页面功能
+
+- 院区多选(默认全部);
+- 3 张 KPI:30 天内到期 / 60 天内到期 / 已过期未续签;
+- 到期时间分布(环形图:已过期 / 0-30 天 / 31-60 天 / 61-90 天 / 90 天以上);
+- 月度到期趋势(未来 12 个月每月到期人数);
+- 合同到期明细表:长者 / 院区 / 合同开始 / 合同结束 / 剩余天数 / 床位 / 联系人;
+- Excel 导出。
+
+### 4.2 后端接口
+
+已在《运营总览 — 二期规划数据清单》中设计,这里复用并扩展。
+
+`GET /operation-analysis/overview/contract-expiry`
+
+参数:
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| tenantIds | Long[] | 否 | 空 = 当前账号可见全部院区 |
+| days | Integer[] | 否 | 预警档位,默认 `[30,60,90]` |
+| orgType | Integer | 否 | 固定 1 |
+
+响应 `data`:
+
+```ts
+{
+  summary: [                       // 与二期规划一致
+    { days: number; expiringCount: number; expiredCount: number }
+  ]
+  distributionBuckets: [           // 用于环形图,固定分段
+    { label: string; count: number } // 已过期 / 0-30天 / 31-60天 / 61-90天 / 90天以上
+  ]
+  monthTrend: [                    // 未来 12 个月每月到期人数
+    { month: string; count: number }
+  ]
+  list: [                          // 到期明细(按到期日升序)
+    {
+      elderId: number
+      elderName: string
+      gender: number | string
+      age: number | null
+      tenantId: number
+      tenantName: string
+      bedName: string | null
+      contractStartDate: string
+      contractEndDate: string
+      remainDays: number           // 剩余天数,已过期为负数
+      contactName: string | null
+      contactPhone: string | null
+    }
+  ]
+}
+```
+
+---
+
+## 五、前端实现说明
+
+1. 四页面均放在 `src/views/operation-analysis/occupancy/` 下,目录分别为:
+   - `moveout-reason/index.vue`
+   - `trial-conversion/index.vue`
+   - `waiting-analysis/index.vue`
+   - `contract-expiry/index.vue`
+2. 统一使用 `occupancy-api.ts` 新增 4 个聚合接口;
+3. 统一使用 `overview/campus-data.ts` 的 `getCampusOptions()` 获取院区下拉;
+4. 统一风格:页头标题 + 刷新、筛选区、KPI 卡片、Echart 图表、明细表、Excel 导出;
+5. 后端接口未上线前,页面按空数据展示,并在 API 层保留 `console.warn` 提示;上线后只需替换 `fetchXxx` 内部 URL,无需改页面。
+
+## 六、权限建议
+
+建议菜单绑定权限标识(与一期一致前缀):
+
+- `operation-analysis:occupancy:moveout-reason`
+- `operation-analysis:occupancy:trial-conversion`
+- `operation-analysis:occupancy:waiting-analysis`
+- `operation-analysis:occupancy:contract-expiry`