浏览代码

设计入住运营前4个表格

chenjun 2 周之前
父节点
当前提交
c092a62ff0

+ 138 - 108
src/api/elderly/elder/bed-change/index.ts

@@ -71,7 +71,7 @@ export const getBedChangePage = (query) => {
 }
 
 // 获取详情
-export const getBedChangeRecordById = (id, status,isDetail) => {
+export const getBedChangeRecordById = (id, status, isDetail) => {
   return request.get({
     url: `elderly/bedChangeRecord/detail?id=${id}&status=${status}&isDetail=${isDetail}`
   })
@@ -80,11 +80,10 @@ export const getBedChangeRecordById = (id, status,isDetail) => {
 // 获取详情
 export const getBedChangeRecordElderlyById = (id) => {
   return request.get({
-    url: `/elderly/bedChangeRecord/getElderlyById?elderId=${id}`,
+    url: `/elderly/bedChangeRecord/getElderlyById?elderId=${id}`
   })
 }
 
-
 // 新增
 export const bedChangeCreate = (data) => {
   return request.post({
@@ -104,7 +103,7 @@ export const bedChangeRecord = (data) => {
 //
 
 // 检测是否可以包房
-export const checkPrivateRoom = (id,elderId=undefined) => {
+export const checkPrivateRoom = (id, elderId = undefined) => {
   return request.get({
     url: `elderly/bedChangeRecord/checkPrivateRoom?bedId=${id}&elderId=${elderId}`
   })
@@ -158,42 +157,46 @@ export interface LongDoctorOrderForm {
 
 // 长期医嘱单分页(list 时间字段自动反序列化,包括 entries[].orderTime/stopTime)
 export const getLongDoctorOrderPage = (query: Recordable) => {
-  return request.get({
-    url: 'elderly/longDoctorOrder/page',
-    params: query
-  }).then((data: any) => {
-    if (data?.list) {
-      data.list = data.list.map((row: any) => ({
-        ...row,
-        latestOrderTime: fromTs(row.latestOrderTime),
-        createTime: fromTs(row.createTime),
-        entries: (row.entries || []).map((e: any) => ({
-          ...e,
-          orderTime: fromTs(e.orderTime),
-          stopTime: fromTs(e.stopTime)
+  return request
+    .get({
+      url: 'elderly/longDoctorOrder/page',
+      params: query
+    })
+    .then((data: any) => {
+      if (data?.list) {
+        data.list = data.list.map((row: any) => ({
+          ...row,
+          latestOrderTime: fromTs(row.latestOrderTime),
+          createTime: fromTs(row.createTime),
+          entries: (row.entries || []).map((e: any) => ({
+            ...e,
+            orderTime: fromTs(e.orderTime),
+            stopTime: fromTs(e.stopTime)
+          }))
         }))
-      }))
-    }
-    return data
-  })
+      }
+      return data
+    })
 }
 
 // 长期医嘱单详情(entries[].orderTime/stopTime 自动反序列化)
 export const getLongDoctorOrder = (id: number) => {
-  return request.get({
-    url: `elderly/longDoctorOrder/get?id=${id}`
-  }).then((data: any) => {
-    if (data?.entries) {
-      data.entries = data.entries.map((e: any) => ({
-        ...e,
-        orderTime: fromTs(e.orderTime),
-        stopTime: fromTs(e.stopTime)
-      }))
-    }
-    data.latestOrderTime = fromTs(data.latestOrderTime)
-    data.createTime = fromTs(data.createTime)
-    return data
-  })
+  return request
+    .get({
+      url: `elderly/longDoctorOrder/get?id=${id}`
+    })
+    .then((data: any) => {
+      if (data?.entries) {
+        data.entries = data.entries.map((e: any) => ({
+          ...e,
+          orderTime: fromTs(e.orderTime),
+          stopTime: fromTs(e.stopTime)
+        }))
+      }
+      data.latestOrderTime = fromTs(data.latestOrderTime)
+      data.createTime = fromTs(data.createTime)
+      return data
+    })
 }
 
 // 新增长期医嘱单(orderTime/stopTime 自动转毫秒时间戳发送)
@@ -288,7 +291,12 @@ export const MED_TIME_OPTIONS: MedOption[] = [
 export const MED_ROUTE_OPTIONS: MedOption[] = [
   { value: 'po', label: 'po 口服', cn: '口服', tip: '绝大多数常规口服药物' },
   { value: 'iv', label: 'iv 静脉推注', cn: '静脉推注', tip: '小容量药剂快速静脉给药' },
-  { value: 'ivgtt', label: 'ivgtt/iv drip 静脉滴注', cn: '静脉滴注/输液', tip: '大容量补液、抗感染输液' },
+  {
+    value: 'ivgtt',
+    label: 'ivgtt/iv drip 静脉滴注',
+    cn: '静脉滴注/输液',
+    tip: '大容量补液、抗感染输液'
+  },
   { value: 'im', label: 'im 肌内注射', cn: '肌内注射', tip: '常规疫苗、止痛针、部分抗生素' },
   { value: 'sc', label: 'sc 皮下注射', cn: '皮下注射', tip: '胰岛素、低分子肝素等药物' },
   { value: 'ih', label: 'ih 皮下注射', cn: '皮下注射', tip: '胰岛素、低分子肝素等药物' },
@@ -298,7 +306,12 @@ export const MED_ROUTE_OPTIONS: MedOption[] = [
   // ↓↓↓ 9-08 新增给药途径项(与现有项 value 不重复)
   { value: 'id', label: 'id 皮内注射', cn: '皮内注射', tip: '皮试、疫苗过敏试验等' },
   { value: 'sl', label: 'sl 舌下含服', cn: '舌下含服', tip: '硝酸甘油、速效救心丸等急救药' },
-  { value: 'top', label: 'top 皮肤外用/经皮给药', cn: '皮肤外用/经皮给药', tip: '软膏、贴片、涂抹类外用药' },
+  {
+    value: 'top',
+    label: 'top 皮肤外用/经皮给药',
+    cn: '皮肤外用/经皮给药',
+    tip: '软膏、贴片、涂抹类外用药'
+  },
   { value: 'pr', label: 'pr 直肠给药', cn: '直肠给药', tip: '栓剂、灌肠、退热等' },
   { value: 'buccal', label: 'buccal 颊部给药', cn: '颊部给药', tip: '口腔黏膜贴片、含化药片' },
   { value: 'ia', label: 'ia 动脉内注射', cn: '动脉内注射', tip: '介入治疗、动脉化疗等' },
@@ -347,44 +360,48 @@ export interface ShortDoctorOrderForm {
 
 // 临时医嘱单分页(list 时间字段自动反序列化,包括 entries[].orderTime/executeTime)
 export const getShortDoctorOrderPage = (query: Recordable) => {
-  return request.get({
-    url: 'elderly/shortDoctorOrder/page',
-    params: query
-  }).then((data: any) => {
-    if (data?.list) {
-      data.list = data.list.map((row: any) => ({
-        ...row,
-        latestOrderTime: fromTs(row.latestOrderTime),
-        latestExecuteTime: fromTs(row.latestExecuteTime),
-        createTime: fromTs(row.createTime),
-        entries: (row.entries || []).map((e: any) => ({
-          ...e,
-          orderTime: fromTs(e.orderTime),
-          executeTime: fromTs(e.executeTime)
+  return request
+    .get({
+      url: 'elderly/shortDoctorOrder/page',
+      params: query
+    })
+    .then((data: any) => {
+      if (data?.list) {
+        data.list = data.list.map((row: any) => ({
+          ...row,
+          latestOrderTime: fromTs(row.latestOrderTime),
+          latestExecuteTime: fromTs(row.latestExecuteTime),
+          createTime: fromTs(row.createTime),
+          entries: (row.entries || []).map((e: any) => ({
+            ...e,
+            orderTime: fromTs(e.orderTime),
+            executeTime: fromTs(e.executeTime)
+          }))
         }))
-      }))
-    }
-    return data
-  })
+      }
+      return data
+    })
 }
 
 // 临时医嘱单详情(entries[].orderTime/executeTime 自动反序列化)
 export const getShortDoctorOrder = (id: number) => {
-  return request.get({
-    url: `elderly/shortDoctorOrder/get?id=${id}`
-  }).then((data: any) => {
-    if (data?.entries) {
-      data.entries = data.entries.map((e: any) => ({
-        ...e,
-        orderTime: fromTs(e.orderTime),
-        executeTime: fromTs(e.executeTime)
-      }))
-    }
-    data.latestOrderTime = fromTs(data.latestOrderTime)
-    data.latestExecuteTime = fromTs(data.latestExecuteTime)
-    data.createTime = fromTs(data.createTime)
-    return data
-  })
+  return request
+    .get({
+      url: `elderly/shortDoctorOrder/get?id=${id}`
+    })
+    .then((data: any) => {
+      if (data?.entries) {
+        data.entries = data.entries.map((e: any) => ({
+          ...e,
+          orderTime: fromTs(e.orderTime),
+          executeTime: fromTs(e.executeTime)
+        }))
+      }
+      data.latestOrderTime = fromTs(data.latestOrderTime)
+      data.latestExecuteTime = fromTs(data.latestExecuteTime)
+      data.createTime = fromTs(data.createTime)
+      return data
+    })
 }
 
 // 新增临时医嘱单(orderTime/executeTime 自动转毫秒时间戳发送)
@@ -415,6 +432,13 @@ export const deleteShortDoctorOrder = (id: number) => {
 export interface DoctorOrderExecution {
   id?: number
   orderType?: number // 1=长期医嘱 2=临时医嘱
+  /**
+   * 执行类型:1=常规执行(日常给药/治疗),2=停止确认(护士确认长期医嘱已停止执行)。
+   * 停止确认由后端在医嘱停止时生成待确认任务,护士确认后其 execTime/execNurseSign
+   * 即长期医嘱单上的“停止执行时间 / 停止执行护士签名”。
+   * 后端未返回时按 1(常规执行)处理。
+   */
+  execType?: number
   orderEntryId?: number // 关联原医嘱条目 id(长期/临时各自的 entry id)
   elderId?: number
   elderName?: string
@@ -440,6 +464,12 @@ export const EXEC_STATUS = {
   SKIPPED: 2
 }
 
+/** 执行记录类型:1=常规执行,2=停止确认(长期医嘱停止需护士确认签名) */
+export const EXEC_TYPE = {
+  NORMAL: 1,
+  STOP_CONFIRM: 2
+}
+
 export const EXEC_STATUS_OPTIONS = [
   { label: '待执行', value: 0 },
   { label: '已执行', value: 1 },
@@ -448,41 +478,43 @@ export const EXEC_STATUS_OPTIONS = [
 
 // 分页查询某日护士执行记录(list 时间字段自动反序列化,planDate 仍按 YYYY-MM-DD 字符串传参)
 export const getDoctorOrderExecutionPage = (query: Recordable) => {
-  return request.get({
-    url: 'elderly/doctorOrderExecution/page',
-    params: query
-  }).then((data: any) => {
-  if (data?.list) {
-    // ⚠️ 临时诊断:打印后端返回的 planTime 原始值(转换前),用于核对后端是否按前端传值精确写入
-    console.log(
-      '[planTime-收到-原始]',
-      (data.list as any[]).slice(0, 10).map((r: any) => ({
-        id: r.id,
-        orderType: r.orderType,
-        orderEntryId: r.orderEntryId,
-        planTimeRaw: r.planTime,
-        typeOf: typeof r.planTime
-      }))
-    )
-    data.list = data.list.map((row: any) => ({
-      ...row,
-      planTime: fromTs(row.planTime),
-      execTime: fromTs(row.execTime),
-      createTime: fromTs(row.createTime)
-    }))
-    // ⚠️ 临时诊断:转换后(前端最终用来匹配的值,会再截到 16 位)
-    console.log(
-      '[planTime-收到-转换后]',
-      data.list.slice(0, 10).map((r: any) => ({
-        orderType: r.orderType,
-        orderEntryId: r.orderEntryId,
-        planTime: r.planTime,
-        matchKey: `${r.orderType}-${r.orderEntryId}-${String(r.planTime).slice(0, 16)}`
-      }))
-    )
-  }
-  return data
-})
+  return request
+    .get({
+      url: 'elderly/doctorOrderExecution/page',
+      params: query
+    })
+    .then((data: any) => {
+      if (data?.list) {
+        // ⚠️ 临时诊断:打印后端返回的 planTime 原始值(转换前),用于核对后端是否按前端传值精确写入
+        console.log(
+          '[planTime-收到-原始]',
+          (data.list as any[]).slice(0, 10).map((r: any) => ({
+            id: r.id,
+            orderType: r.orderType,
+            orderEntryId: r.orderEntryId,
+            planTimeRaw: r.planTime,
+            typeOf: typeof r.planTime
+          }))
+        )
+        data.list = data.list.map((row: any) => ({
+          ...row,
+          planTime: fromTs(row.planTime),
+          execTime: fromTs(row.execTime),
+          createTime: fromTs(row.createTime)
+        }))
+        // ⚠️ 临时诊断:转换后(前端最终用来匹配的值,会再截到 16 位)
+        console.log(
+          '[planTime-收到-转换后]',
+          data.list.slice(0, 10).map((r: any) => ({
+            orderType: r.orderType,
+            orderEntryId: r.orderEntryId,
+            planTime: r.planTime,
+            matchKey: `${r.orderType}-${r.orderEntryId}-${String(r.planTime).slice(0, 16)}`
+          }))
+        )
+      }
+      return data
+    })
 }
 
 // 护士执行(新增/更新一条执行记录,planTime/execTime 自动转毫秒时间戳发送)
@@ -668,9 +700,7 @@ export const buildPendingPlanTimes = (opts: {
 
   // 截止:已停止 → 停止时间;否则 → 今天 23:59:59
   const endRaw = opts.stopTime ? fromTs(opts.stopTime) : opts.now
-  const end = endRaw
-    ? endRaw.slice(0, 16)
-    : `${dayjs().format('YYYY-MM-DD')} 23:59`
+  const end = endRaw ? endRaw.slice(0, 16) : `${dayjs().format('YYYY-MM-DD')} 23:59`
 
   const out: string[] = []
   let cur = start.slice(0, 10)

+ 25 - 10
src/views/elderly/elder/doctor-orders/execute-doctor-order/ExecuteDialog.vue

@@ -1,7 +1,7 @@
 <template>
   <el-dialog
     v-model="visible"
-    :title="isView ? '查看执行记录' : '护士执行医嘱'"
+    :title="dialogTitle"
     width="680px"
     :close-on-click-modal="false"
     append-to-body
@@ -10,30 +10,35 @@
       <el-descriptions-item label="长者">{{ row.elderName || '-' }}</el-descriptions-item>
       <el-descriptions-item label="床位">{{ row.bedName || '-' }}</el-descriptions-item>
       <el-descriptions-item label="医嘱类型">
-        {{ row.orderType === 1 ? '长期医嘱' : '临时医嘱' }}
+        {{ row.orderType === 1 ? '长期医嘱' : '临时医嘱'
+        }}<el-tag v-if="isStop" type="danger" size="small" class="ml-5px">停止确认</el-tag>
       </el-descriptions-item>
       <el-descriptions-item label="计划执行时间">{{ row.planTime || '-' }}</el-descriptions-item>
       <el-descriptions-item label="给药频次">{{ freqCn(row.frequency) }}</el-descriptions-item>
       <el-descriptions-item label="给药时间">{{ timeCn(row.medTime) }}</el-descriptions-item>
       <el-descriptions-item label="给药途径">{{ routeCn(row.route) }}</el-descriptions-item>
       <el-descriptions-item label="状态">
-        <el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
+        <el-tag :type="statusType(row.status)" size="small">{{
+          isStop && row.status === 1 ? '已停止确认' : statusText(row.status)
+        }}</el-tag>
       </el-descriptions-item>
-      <el-descriptions-item label="医嘱内容" :span="2">{{ row.orderContent || '-' }}</el-descriptions-item>
+      <el-descriptions-item label="医嘱内容" :span="2">{{
+        row.orderContent || '-'
+      }}</el-descriptions-item>
     </el-descriptions>
 
     <el-form label-width="120px" :disabled="isView">
-      <el-form-item label="实际执行时间" required>
+      <el-form-item :label="isStop ? '停止执行时间' : '实际执行时间'" required>
         <el-date-picker
           v-model="form.execTime"
           type="datetime"
           format="YYYY-MM-DD HH:mm"
           value-format="YYYY-MM-DD HH:mm:ss"
-          placeholder="请选择实际执行时间"
+          :placeholder="isStop ? '请选择停止执行时间' : '请选择实际执行时间'"
           style="width: 100%"
         />
       </el-form-item>
-      <el-form-item label="执行护士">
+      <el-form-item :label="isStop ? '停止执行护士' : '执行护士'">
         <el-input v-model="form.execNurseSign" disabled />
       </el-form-item>
       <el-form-item label="备注">
@@ -44,19 +49,20 @@
     <template #footer>
       <el-button @click="visible = false">关闭</el-button>
       <el-button v-if="!isView" type="primary" :loading="saving" @click="handleSave">
-        确认执行
+        {{ isStop ? '确认停止' : '确认执行' }}
       </el-button>
     </template>
   </el-dialog>
 </template>
 
 <script lang="ts" setup>
-import { ref, reactive } from 'vue'
+import { ref, reactive, computed } from 'vue'
 import { useMessage } from '@/hooks/web/useMessage'
 import { useUserStore } from '@/store/modules/user'
 import { formatDate } from '@/utils/formatTime'
 import {
   executeDoctorOrder,
+  EXEC_TYPE,
   type DoctorOrderExecution,
   MED_FREQUENCY_OPTIONS,
   MED_TIME_OPTIONS,
@@ -84,11 +90,19 @@ const routeCn = (v?: string) => getMedCn(MED_ROUTE_OPTIONS, v)
 const statusType = (s?: number) => (s === 1 ? 'success' : s === 2 ? 'info' : 'warning')
 const statusText = (s?: number) => (s === 1 ? '已执行' : s === 2 ? '已跳过' : '待执行')
 
+/** 是否停止确认任务:后端返回 execType=2;老数据无此字段按常规执行处理 */
+const isStop = computed(() => Number(row.value?.execType) === EXEC_TYPE.STOP_CONFIRM)
+const dialogTitle = computed(() => {
+  if (isView.value) return isStop.value ? '查看停止确认记录' : '查看执行记录'
+  return isStop.value ? '护士确认停止医嘱' : '护士执行医嘱'
+})
+
 const open = (r: any, view = false) => {
   row.value = r || {}
   isView.value = view
   form.execTime = r?.execTime || formatDate(new Date(), 'YYYY-MM-DD HH:mm:ss')
-  form.execNurseSign = r?.execNurseSign || (userStore as any).user?.nickname || (userStore as any).nickname || ''
+  form.execNurseSign =
+    r?.execNurseSign || (userStore as any).user?.nickname || (userStore as any).nickname || ''
   form.remark = r?.remark || ''
   visible.value = true
 }
@@ -109,6 +123,7 @@ const handleSave = async () => {
   const payload: DoctorOrderExecution = {
     id: row.value.execId,
     orderType: row.value.orderType,
+    execType: row.value.execType ?? EXEC_TYPE.NORMAL,
     orderEntryId: row.value.orderEntryId,
     elderId: row.value.elderId,
     elderName: row.value.elderName,

+ 38 - 20
src/views/elderly/elder/doctor-orders/execute-doctor-order/ExecutionRecordDialog.vue

@@ -22,7 +22,13 @@
         :editable="false"
         style="width: 280px"
       />
-      <el-button type="primary" plain @click="fetchList" :loading="loading" style="margin-left: 8px">
+      <el-button
+        type="primary"
+        plain
+        @click="fetchList"
+        :loading="loading"
+        style="margin-left: 8px"
+      >
         <Icon icon="ep:search" class="mr-5px" /> 查询
       </el-button>
       <el-button @click="resetRange" style="margin-left: 0">
@@ -34,9 +40,7 @@
       <h2 class="doc-title">{{ dialogTitle }}</h2>
       <div class="info-row">
         <div class="info-cell"><span class="lbl">机构:</span>{{ tenantName || '-' }}</div>
-        <div class="info-cell">
-          <span class="lbl">执行日期:</span>{{ dateRangeText }}
-        </div>
+        <div class="info-cell"> <span class="lbl">执行日期:</span>{{ dateRangeText }} </div>
         <div class="info-cell"><span class="lbl">长者:</span>{{ elderName || '-' }}</div>
         <div class="info-cell"><span class="lbl">类型:</span>{{ orderTypeText }}</div>
       </div>
@@ -86,7 +90,10 @@
               <td>{{ item.elderName || elderName || '-' }}</td>
               <td>{{ item.bedName || '-' }}</td>
               <td>{{ item.elderAge || '' }}</td>
-              <td><span class="cell-value pre">{{ item.orderContent || '-' }}</span></td>
+              <td>
+                <span v-if="isStopConfirm(item)" class="stop-flag">【停止确认】</span
+                ><span class="cell-value pre">{{ item.orderContent || '-' }}</span>
+              </td>
               <td>{{ freqCn(item.frequency) }}</td>
               <td>{{ timeCn(item.medTime) }}</td>
               <td>{{ routeCn(item.route) }}</td>
@@ -104,8 +111,10 @@
                   <div class="t-line">{{ formatDateTimeSplit(item.execTime).time }}</div>
                 </div>
               </td>
-              <td>{{ statusText(item.status) }}</td>
-              <td><span class="cell-value pre">{{ item.remark || '' }}</span></td>
+              <td>{{ rowStatusText(item) }}</td>
+              <td
+                ><span class="cell-value pre">{{ item.remark || '' }}</span></td
+              >
             </tr>
           </tbody>
         </table>
@@ -155,6 +164,7 @@ import {
   MED_FREQUENCY_OPTIONS,
   MED_TIME_OPTIONS,
   MED_ROUTE_OPTIONS,
+  EXEC_TYPE,
   getMedCn,
   type DoctorOrderExecution
 } from '@/api/elderly/elder/bed-change'
@@ -228,6 +238,16 @@ const freqCn = (v?: string) => getMedCn(MED_FREQUENCY_OPTIONS, v)
 const timeCn = (v?: string) => getMedCn(MED_TIME_OPTIONS, v)
 const routeCn = (v?: string) => getMedCn(MED_ROUTE_OPTIONS, v)
 const statusText = (s?: number) => (s === 1 ? '已执行' : s === 2 ? '已跳过' : '待执行')
+/** 停止确认任务判定与状态文案 */
+const isStopConfirm = (r?: any) => Number(r?.execType) === EXEC_TYPE.STOP_CONFIRM
+const rowStatusText = (r?: any) =>
+  isStopConfirm(r)
+    ? r?.status === 1
+      ? '已停止确认'
+      : r?.status === 2
+        ? '已跳过'
+        : '待停止确认'
+    : statusText(r?.status)
 
 /**
  * 把时间拆成 {date:'YYYY-MM-DD', time:'HH:mm'} 两段用于上下两行展示
@@ -312,9 +332,7 @@ const buildMockExecutionRecords = (): DoctorOrderExecution[] => {
       tenantId: userStore.orgTenantId as any
     }
   ]
-  return orderType
-    ? base.filter((b) => b.orderType === orderType)
-    : base
+  return orderType ? base.filter((b) => b.orderType === orderType) : base
 }
 
 /**
@@ -360,7 +378,8 @@ const fetchList = async () => {
     // 前端兜底过滤:只保留 elderId 匹配的记录(Number() 兼容后端返回 string/number)
     const targetElderId = Number(elderId)
     let records: DoctorOrderExecution[] = ((data.list || []) as any[]).filter(
-      (r: any) => r.elderId !== undefined && r.elderId !== null && Number(r.elderId) === targetElderId
+      (r: any) =>
+        r.elderId !== undefined && r.elderId !== null && Number(r.elderId) === targetElderId
     )
 
     // 兜底从医嘱单 entry 上补齐 execution 上可能缺失的字段(orderContent/frequency/medTime/route)
@@ -422,7 +441,7 @@ const fetchList = async () => {
               r.orderType === 1
                 ? await getLongDoctorOrder(ent._sid)
                 : await getShortDoctorOrder(ent._sid)
-            const de = ((detail?.entries) || []).find(
+            const de = (detail?.entries || []).find(
               (x: any) => Number(x.id) === Number(r.orderEntryId)
             )
             if (de?.doctorSign) r.doctorSign = de.doctorSign
@@ -446,10 +465,7 @@ const fetchList = async () => {
 
 /** 重置:时间范围回到「近 30 天 ~ 今天」并重新拉数 */
 const resetRange = () => {
-  dateRange.value = [
-    dayjs().subtract(30, 'day').format('YYYY-MM-DD'),
-    dayjs().format('YYYY-MM-DD')
-  ]
+  dateRange.value = [dayjs().subtract(30, 'day').format('YYYY-MM-DD'), dayjs().format('YYYY-MM-DD')]
   fetchList()
 }
 
@@ -477,10 +493,7 @@ const open = async (
   // 弹窗打开序号自增,确保本次打印区 id 与上次不同(避免 vue3-print-nb 缓存 DOM 串号)
   openCounter++
   // 每次打开时间范围回到「近 30 天 ~ 今天」(默认窗口)
-  dateRange.value = [
-    dayjs().subtract(30, 'day').format('YYYY-MM-DD'),
-    dayjs().format('YYYY-MM-DD')
-  ]
+  dateRange.value = [dayjs().subtract(30, 'day').format('YYYY-MM-DD'), dayjs().format('YYYY-MM-DD')]
   // 立即清空旧数据,避免"切换长者时表格残留前一位"(用户 9-08 反馈)
   list.value = []
   visible.value = true
@@ -575,6 +588,11 @@ defineExpose({ open })
 .cell-value.pre {
   white-space: pre-wrap;
 }
+.stop-flag {
+  color: #c45656;
+  font-weight: bold;
+  white-space: nowrap;
+}
 .empty-tip {
   text-align: center;
   color: #909399;

+ 91 - 17
src/views/elderly/elder/doctor-orders/execute-doctor-order/index.vue

@@ -81,7 +81,14 @@
       <el-table-column label="长者" prop="elderName" width="110" fixed />
       <el-table-column label="床位" prop="bedName" width="180" />
       <el-table-column label="年龄" prop="elderAge" width="80" align="center" />
-      <el-table-column label="医嘱内容" prop="orderContent" min-width="220" show-overflow-tooltip />
+      <el-table-column label="医嘱内容" min-width="240" show-overflow-tooltip>
+        <template #default="{ row }">
+          <el-tag v-if="isStopConfirm(row)" type="danger" size="small" class="mr-5px">
+            停止确认
+          </el-tag>
+          {{ row.orderContent }}
+        </template>
+      </el-table-column>
       <el-table-column label="频次" width="90" align="center">
         <template #default="{ row }">{{ freqCn(row.frequency) }}</template>
       </el-table-column>
@@ -96,11 +103,13 @@
       </el-table-column>
       <el-table-column label="执行护士" prop="execNurseSign" width="100" />
       <el-table-column label="实际执行时间" width="160">
-        <template #default="{ row }">{{ row.execTime ? formatDateTimeShort(row.execTime) : '' }}</template>
+        <template #default="{ row }">{{
+          row.execTime ? formatDateTimeShort(row.execTime) : ''
+        }}</template>
       </el-table-column>
-      <el-table-column label="状态" width="90" align="center">
+      <el-table-column label="状态" width="100" align="center">
         <template #default="{ row }">
-          <el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
+          <el-tag :type="statusType(row.status)" size="small">{{ rowStatusText(row) }}</el-tag>
         </template>
       </el-table-column>
       <el-table-column label="操作" width="220" align="center" fixed="right">
@@ -111,10 +120,10 @@
             type="primary"
             @click="openExecute(row, false)"
             v-hasPermi="['doctor-order-execute:execute']"
-            >执行</el-button
+            >{{ isStopConfirm(row) ? '确认停止' : '执行' }}</el-button
           >
           <el-button
-            v-if="row.status === 0"
+            v-if="row.status === 0 && !isStopConfirm(row)"
             link
             type="danger"
             @click="handleSkip(row)"
@@ -125,6 +134,7 @@
             >查看</el-button
           >
           <el-button
+            v-if="!isStopConfirm(row)"
             link
             type="warning"
             @click="openPrintDialog('single', row)"
@@ -242,9 +252,10 @@
             <td>{{ item.elderName }}</td>
             <td>{{ item.bedName }}</td>
             <td>{{ item.elderAge || '' }}</td>
-            <td
-              ><span class="cell-value pre">{{ item.orderContent }}</span></td
-            >
+            <td>
+              <span v-if="isStopConfirm(item)" class="stop-flag">【停止确认】</span
+              ><span class="cell-value pre">{{ item.orderContent }}</span>
+            </td>
             <td>{{ freqCn(item.frequency) }}</td>
             <td>{{ timeCn(item.medTime) }}</td>
             <td>{{ routeCn(item.route) }}</td>
@@ -261,7 +272,7 @@
                 <div class="t-line">{{ formatDateTimeSplit(item.execTime).time }}</div>
               </div>
             </td>
-            <td>{{ statusText(item.status) }}</td>
+            <td>{{ rowStatusText(item) }}</td>
             <td
               ><span class="cell-value pre">{{ item.remark || '' }}</span></td
             >
@@ -300,7 +311,7 @@
               <th>年龄</th>
               <td>{{ singlePrintRow.elderAge || '' }}</td>
               <th>状态</th>
-              <td>{{ statusText(singlePrintRow.status) }}</td>
+              <td>{{ rowStatusText(singlePrintRow) }}</td>
             </tr>
             <tr>
               <th>频次</th>
@@ -376,6 +387,7 @@ import {
   MED_ROUTE_OPTIONS,
   getMedCn,
   fromTs,
+  EXEC_TYPE,
   type DoctorOrderExecution
 } from '@/api/elderly/elder/bed-change'
 import ExecuteDialog from './ExecuteDialog.vue'
@@ -460,10 +472,10 @@ const queryParams = reactive({
   // 执行日期范围:默认「前 3 天 ~ 今天」、必填、不可清除。
   // 用户 2026-09-08 反馈:默认 30 天数据量太大,前 3 天够看近期欠账;想看更早手动拉宽范围。
   // 展开窗口 = 用户所选范围(替换硬编码 HISTORY_DAYS=30),上界 dateEnd 23:59:59 不展未来。
-  dateRange: [
-    dayjs().subtract(30, 'day').format('YYYY-MM-DD'),
-    dayjs().format('YYYY-MM-DD')
-  ] as [string, string],
+  dateRange: [dayjs().subtract(30, 'day').format('YYYY-MM-DD'), dayjs().format('YYYY-MM-DD')] as [
+    string,
+    string
+  ],
   orderType: undefined as number | undefined,
   // 默认筛选「未执行」(0),减少首屏噪声;想看全部清掉该筛选项即可
   status: 0 as number | undefined
@@ -521,6 +533,17 @@ const timeCn = (v?: string) => getMedCn(MED_TIME_OPTIONS, v)
 const routeCn = (v?: string) => getMedCn(MED_ROUTE_OPTIONS, v)
 const statusType = (s?: number) => (s === 1 ? 'success' : s === 2 ? 'info' : 'warning')
 const statusText = (s?: number) => (s === 1 ? '已执行' : s === 2 ? '已跳过' : '待执行')
+/** 是否为“停止确认”任务(长期医嘱停止后由后端生成,等待护士确认签名) */
+const isStopConfirm = (row: any) => Number(row?.execType) === EXEC_TYPE.STOP_CONFIRM
+/** 状态文案:停止确认任务使用“待停止确认/已停止确认”,其余沿用通用状态 */
+const rowStatusText = (row: any) =>
+  isStopConfirm(row)
+    ? row?.status === 1
+      ? '已停止确认'
+      : row?.status === 2
+        ? '已跳过'
+        : '待停止确认'
+    : statusText(row?.status)
 
 /** 构造一条执行行 */
 const makeRow = (orderType: number, e: any, planTime: string, vo: any) => ({
@@ -622,6 +645,8 @@ const buildRows = async () => {
     //    后端 doctorOrderExecution/page 新增 planTime 数组参数(按 plan_time 范围过滤);
     //    旧的 planDate 单日参数本次不传(始终走范围,更通用)
     const execMap = new Map<string, any>() // 精确 key → execution 记录(用于回填状态:已执行/已跳过/待执行)
+    // 停止确认任务(execType=2):不对应频次展开的计划时点,需要作为独立行追加展示
+    const stopRecords: any[] = []
     try {
       const ep = await getDoctorOrderExecutionPage({
         pageNo: 1,
@@ -635,6 +660,10 @@ const buildRows = async () => {
           console.warn('[执行医嘱] execution 记录缺少关键字段,已忽略:', r)
           return
         }
+        if (Number(r.execType) === EXEC_TYPE.STOP_CONFIRM) {
+          stopRecords.push(r)
+          return
+        }
         const plan = String(r.planTime).slice(0, 16)
         const k = `${r.orderType}-${r.orderEntryId}`
         execMap.set(`${k}-${plan}`, r)
@@ -706,6 +735,46 @@ const buildRows = async () => {
       }
     })
 
+    // 5.1) 追加“停止确认”任务行(execType=2):
+    //   长期医嘱停止后由后端生成,不对应频次展开的计划时点,因此必须作为独立行展示。
+    //   待确认(status=0)/已确认(status=1)/已跳过(status=2) 都追加,受下方类型/状态筛选与排序统一处理。
+    const longEntryMap = new Map<number, { e: any; vo: any }>()
+    longOrders.forEach((vo: any) => {
+      ;(vo.entries || []).forEach((e: any) => longEntryMap.set(Number(e.id), { e, vo }))
+    })
+    stopRecords.forEach((r: any) => {
+      const plan = String(r.planTime || '').slice(0, 16)
+      const planDate = plan.slice(0, 10)
+      if (!planDate || planDate < viewStartDate || planDate > dateEnd) return
+      const hit = r.orderEntryId !== null ? longEntryMap.get(Number(r.orderEntryId)) : undefined
+      const e = hit?.e
+      const vo = hit?.vo
+      const elderName = r.elderName || vo?.elderName || ''
+      // 与列表的长者姓名搜索保持一致(模糊包含)
+      if (queryParams.elderName && !elderName.includes(queryParams.elderName)) return
+      rows.push({
+        execId: r.id,
+        orderType: 1,
+        execType: EXEC_TYPE.STOP_CONFIRM,
+        orderEntryId: r.orderEntryId,
+        elderId: r.elderId ?? vo?.elderId,
+        elderName,
+        elderAge: r.elderAge ?? vo?.elderAge ?? '',
+        bedName: r.bedName || vo?.bedName || '',
+        orderContent: r.orderContent || e?.orderContent || '',
+        frequency: r.frequency || e?.frequency || '',
+        medTime: r.medTime || e?.medTime || '',
+        route: r.route || e?.route || '',
+        doctorSign: r.doctorSign || e?.doctorSign || '',
+        planTime: plan,
+        status: r.status ?? 0,
+        execTime: r.execTime || '',
+        execNurseSign: r.execNurseSign || '',
+        remark: r.remark || '',
+        tenantId: r.tenantId ?? vo?.tenantId
+      })
+    })
+
     // ⚠️ 临时诊断(核对后端 planTime 写入精度用,确认后删除):
     //   若「未命中_后端planTime与前端展开不一致」> 0,说明后端 planTime 未按请求体精确入库,
     //   已执行/已跳过的记录将无法正确回填 → 表现为「已执行的仍显示待执行」。
@@ -889,7 +958,8 @@ const skipForm = reactive({ remark: '' })
 /** 选中的行(仅 status=0 可选) */
 const selectedRows = ref<any[]>([])
 /** 表格 selectable:仅「待执行」(status=0) 可勾选 */
-const selectable = (row: any) => row?.status === 0
+// 仅常规待执行任务可批量;停止确认任务需逐条在“确认停止”弹窗审慎处理
+const selectable = (row: any) => row?.status === 0 && !isStopConfirm(row)
 const onSelectionChange = (rows: any[]) => {
   selectedRows.value = rows || []
 }
@@ -905,7 +975,6 @@ const onBatchSuccess = (_info: any) => {
   buildRows()
 }
 
-
 const handleSkip = (row: any) => {
   skipRow.value = row
   skipForm.remark = ''
@@ -1039,6 +1108,11 @@ onMounted(() => {
       text-align: left;
       word-break: break-word;
     }
+    .stop-flag {
+      color: #c45656;
+      font-weight: bold;
+      white-space: nowrap;
+    }
     /* 日期 + 时间上下两行展示(计划时间/实际时间列) */
     .datetime {
       display: flex;

+ 36 - 4
src/views/elderly/elder/doctor-orders/long-doctor-order/Form.vue

@@ -32,7 +32,11 @@
         <el-col :span="6">
           <el-form-item label="年龄">
             <el-input
-              :model-value="form.elderAge !== '' && form.elderAge !== undefined && form.elderAge !== null ? `${form.elderAge}岁` : ''"
+              :model-value="
+                form.elderAge !== '' && form.elderAge !== undefined && form.elderAge !== null
+                  ? `${form.elderAge}岁`
+                  : ''
+              "
               disabled
               placeholder="选择长者后自动填充"
             />
@@ -174,6 +178,11 @@
             />
           </template>
         </el-table-column>
+        <el-table-column label="停止护士签名" width="140">
+          <template #default="{ row }">
+            <el-input v-model="row.stopNurseSign" placeholder="护士确认停止后自动带出" disabled />
+          </template>
+        </el-table-column>
         <el-table-column label="备注" min-width="160">
           <template #default="{ row }">
             <el-input
@@ -240,6 +249,7 @@ import {
 } from '@/api/elderly/elder/bed-change'
 import { getDictLabel, DICT_TYPE } from '@/utils/dict'
 import { useUserStore } from '@/store/modules/user'
+import { formatDate } from '@/utils/formatTime'
 import Print from './Print.vue'
 
 defineOptions({ name: 'LongDoctorOrderForm' })
@@ -400,7 +410,13 @@ const handleRemoveEntry = (idx: number) => {
   form.entries.splice(idx, 1)
 }
 
-/** 单条医嘱启用 / 停止切换(走 entries 子表内按钮) */
+/**
+ * 单条医嘱启用 / 停止切换(走 entries 子表内按钮)
+ * 停止时:
+ * 1. 自动补全停止时间(默认当前时间)与停止医生签名(当前登录人),随单据 update 持久化;
+ * 2. 再调状态接口置为“已停止”,由后端生成“停止确认”执行任务,
+ *    护士在「执行医嘱」页确认后即产生停止执行时间与停止护士签名。
+ */
 const handleToggleEntryStatus = async (row: LongDoctorOrderEntry) => {
   if (!row.id) {
     message.warning('新添加的条目请先保存后再启用/停止')
@@ -411,10 +427,26 @@ const handleToggleEntryStatus = async (row: LongDoctorOrderEntry) => {
   const action = isInUse ? '停止' : '启用'
   const targetStatus = isInUse ? LONG_ORDER_STATUS.STOPPED : LONG_ORDER_STATUS.IN_USE
   try {
-    await message.confirm(`确定${action}该长期医嘱吗?`, '提示')
+    await message.confirm(
+      isInUse
+        ? '确定停止该长期医嘱吗?停止后将生成“停止确认”任务,需护士在执行医嘱页确认。'
+        : '确定重新启用该长期医嘱吗?',
+      '提示'
+    )
     togglingId.value = row.id
+
+    if (isInUse) {
+      row.stopTime = row.stopTime || formatDate(new Date(), 'YYYY-MM-DD HH:mm:ss')
+      row.stopDoctorSign = row.stopDoctorSign || CURRENT_USER
+      await updateLongDoctorOrder({
+        id: form.id,
+        elderId: form.elderId,
+        entries: form.entries.map((e) => ({ ...e }))
+      })
+    }
+
     await updateLongDoctorOrderEntryStatus(row.id, targetStatus)
-    message.success(`已${action}`)
+    message.success(isInUse ? `已${action},等待护士确认停止` : `已${action}`)
     row.status = targetStatus
     row.statusText = targetStatus === LONG_ORDER_STATUS.STOPPED ? '已停止' : '在用'
   } catch {

+ 201 - 76
src/views/elderly/elder/doctor-orders/long-doctor-order/Print.vue

@@ -28,10 +28,16 @@
       </div>
 
       <!--
-        纸质长期医嘱单结构(7 列):
-        开始(时间/医生/护士) | 长期医嘱(序号+内容+途径+给药时间+频次) | 停止(时间/医生/护士)
-        同一开始/停止时间连续相同时,时间单元格纵向合并(对应纸质单上的“|”承接记号),
-        医生、护士签名每行照常显示。
+        数据口径:
+        每行 = 一条常规执行记录(doctorOrderExecution,execType=1、已执行):
+          开始时间 = 实际执行时间(execTime),医生=开嘱医生,护士=执行护士(execNurseSign);
+        同一种医嘱(orderEntryId 相同)的多条执行记录归为一组:
+          医嘱序号 / 内容 / 用法只在组内首行展示,停止信息只在组内最后一行显示(不合并单元格,保留行线)。
+        停止信息(execType=2 停止确认任务):
+          停止时间 = 护士确认停止的执行时间(execTime),停止护士 = 确认护士(execNurseSign),
+          停止医生 = 医嘱条目上的 stopDoctorSign;
+          停止任务待确认(status=0)时停止栏留空;无停止任务(老数据)回退条目 stopTime/stopNurseSign。
+        7 列:开始(时间/医生/护士) | 长期医嘱 | 停止(时间/医生/护士)
       -->
       <table class="order-table">
         <colgroup>
@@ -52,7 +58,7 @@
           <tr>
             <th class="sub-head">时间</th>
             <th class="sub-head">医生</th>
-            <th class="sub-head">护士</th>
+            <th class="sub-head">执行<br />护士</th>
             <th class="sub-head">时间</th>
             <th class="sub-head">医生</th>
             <th class="sub-head">护士</th>
@@ -60,26 +66,26 @@
         </thead>
         <tbody>
           <tr v-for="(item, idx) in renderRows" :key="idx">
-            <!-- 开始时间(连续相同合并) -->
-            <td v-if="item._startSpan > 0" :rowspan="item._startSpan" class="time-cell">
+            <!-- 开始:每条执行记录一行 -->
+            <td class="time-cell">
               <div class="datetime">
-                <div class="d-line">{{ formatDateTimeSplit(item.orderTime).date }}</div>
-                <div class="t-line">{{ formatDateTimeSplit(item.orderTime).time }}</div>
+                <div class="d-line">{{ formatDateTimeSplit(item.execTime).date }}</div>
+                <div class="t-line">{{ formatDateTimeSplit(item.execTime).time }}</div>
               </div>
             </td>
             <td class="sign-cell">{{ item.doctorSign || '' }}</td>
             <td class="sign-cell">{{ item.nurseSign || '' }}</td>
-            <!-- 长期医嘱内容 -->
+            <!-- 长期医嘱内容(同一种医嘱组内仅首行显示序号;每行都带备注位) -->
             <td class="content-cell">
               <div class="oe-line">
-                <span class="oe-no">{{ idx + 1 }}</span>
+                <span v-if="item.orderNo" class="oe-no">{{ item.orderNo }}</span>
                 <span class="oe-text">{{ item.orderContent || '' }}</span>
                 <span class="oe-meta">{{ buildMeta(item) }}</span>
               </div>
               <div v-if="item.remark" class="oe-remark">备注:{{ item.remark }}</div>
             </td>
-            <!-- 停止时间(连续相同合并) -->
-            <td v-if="item._stopSpan > 0" :rowspan="item._stopSpan" class="time-cell">
+            <!-- 停止:每行都输出单元格以保留行分割线;停止信息仅组内最后一行有值 -->
+            <td class="time-cell">
               <div class="datetime">
                 <div class="d-line">{{ formatDateTimeSplit(item.stopTime).date }}</div>
                 <div class="t-line">{{ formatDateTimeSplit(item.stopTime).time }}</div>
@@ -100,18 +106,25 @@
 </template>
 
 <script lang="ts" setup>
+import dayjs from 'dayjs'
 import {
   getLongDoctorOrder,
+  getDoctorOrderExecutionPage,
   getMedCn,
   MED_FREQUENCY_OPTIONS,
   MED_TIME_OPTIONS,
   MED_ROUTE_OPTIONS,
-  type LongDoctorOrderEntry
+  type LongDoctorOrderEntry,
+  type DoctorOrderExecution
 } from '@/api/elderly/elder/bed-change'
 import { getDictLabel, DICT_TYPE } from '@/utils/dict'
+import { useUserStore } from '@/store/modules/user'
 
 defineOptions({ name: 'LongDoctorOrderPrint' })
 
+const userStore = useUserStore()
+const message = useMessage()
+
 const dialogVisible = ref(false)
 const loading = ref(false)
 
@@ -142,69 +155,33 @@ const ageText = computed(() => {
 /** 打印页最少展示 6 行,不足补空行 */
 const MIN_ROWS = 6
 
-interface RenderRow extends LongDoctorOrderEntry {
-  /** 开始时间单元格 rowspan:>0 渲染且合并,0 表示被上一行合并、不渲染 td */
-  _startSpan: number
-  /** 停止时间单元格 rowspan,规则同上 */
-  _stopSpan: number
+interface PrintRow {
+  /** 实际执行时间(开始时间) */
+  execTime?: string
+  /** 开嘱医生(每行显示) */
+  doctorSign?: string
+  /** 执行护士(每行显示) */
+  nurseSign?: string
+  orderContent?: string
+  frequency?: string
+  medTime?: string
+  route?: string
+  remark?: string
+  /** 医嘱序号:仅同组首行有值 */
+  orderNo: number | ''
+  /** 停止信息:仅同组最后一行有值,其余行留空(保证行分割线完整) */
+  stopTime?: string
+  stopDoctorSign?: string
+  stopNurseSign?: string
 }
 
-/**
- * 生成表格行:在 entry 基础上计算连续相同时间的合并跨度。
- * - 仅非空时间参与合并,空补行各自独立;
- * - 比较口径为格式化后的“YYYY-MM-DD HH:mm”整串。
- */
-const renderRows = computed<RenderRow[]>(() => {
-  const list: LongDoctorOrderEntry[] = form.entries || []
-  const padding: LongDoctorOrderEntry[] = Array.from(
-    { length: Math.max(0, MIN_ROWS - list.length) },
-    () => ({
-      orderTime: '',
-      orderContent: '',
-      frequency: '',
-      medTime: '',
-      route: '',
-      doctorSign: '',
-      nurseSign: '',
-      stopTime: '',
-      stopDoctorSign: '',
-      stopNurseSign: '',
-      remark: ''
-    })
-  )
-  const all = [...list, ...padding]
-
-  const timeKey = (val?: any) => {
-    if (val === null || val === undefined || val === '') return ''
-    const { date, time } = formatDateTimeSplit(val)
-    return date && time ? `${date} ${time}` : date
-  }
-
-  const calcSpan = (getter: (e: LongDoctorOrderEntry) => string) =>
-    all.map((row, i) => {
-      const key = getter(row)
-      if (!key) return 1
-      if (i > 0 && getter(all[i - 1]) === key) return 0
-      let span = 1
-      while (i + span < all.length && getter(all[i + span]) === key) span += 1
-      return span
-    })
-
-  const startSpans = calcSpan((e) => timeKey(e.orderTime))
-  const stopSpans = calcSpan((e) => timeKey(e.stopTime))
-
-  return all.map((row, i) => ({
-    ...row,
-    _startSpan: startSpans[i],
-    _stopSpan: stopSpans[i]
-  }))
-})
+const renderRows = ref<PrintRow[]>([])
 
 /**
  * 拼接纸质单“长期医嘱”栏右侧的用法信息:途径 + 给药时间 + 频次(缩写),
  * 如“静脉滴注 立即执行 tid”;无值的要素不显示。
  */
-const buildMeta = (item: LongDoctorOrderEntry): string => {
+const buildMeta = (item: { frequency?: string; medTime?: string; route?: string }): string => {
   const parts: string[] = []
   const route = getMedCn(MED_ROUTE_OPTIONS, item.route)
   const medTime = getMedCn(MED_TIME_OPTIONS, item.medTime)
@@ -237,17 +214,164 @@ const formatDateTimeSplit = (val?: any): { date: string; time: string } => {
   return { date: str, time: '' }
 }
 
-/** 打开打印(详情页) */
+/**
+ * 打开打印(详情页)
+ * 数据组装:
+ * 1. 长期医嘱单详情:表头信息 + 各医嘱条目的停止时间 / 停止签名(按 entry id 索引);
+ * 2. 执行记录分页(orderType=1 长期,时间窗取足够大的历史区间 ~ 今天),
+ *    仅保留当前长者、有实际执行时间(已执行)的记录;
+ * 3. execution 表为引用型设计,内容/频次/给药时间/途径/医生签名可能缺失,
+ *    用医嘱条目兜底回填;
+ * 4. 按医嘱条目分组(同一种医嘱),组内按执行时间升序;停止信息 rowspan 到末行。
+ */
 const open = async (row: any = {}) => {
   dialogVisible.value = true
   loading.value = true
+  renderRows.value = []
   try {
     Object.assign(form, defaultForm, { entries: [] })
-    if (row?.id) {
-      const data: any = await getLongDoctorOrder(row.id)
-      Object.assign(form, defaultForm, data)
-      form.entries = (data.entries || []) as LongDoctorOrderEntry[]
+    if (!row?.id || !row?.elderId) {
+      message.error('缺少医嘱单或长者信息,无法打印')
+      return
+    }
+
+    const [detail, execPage] = await Promise.all([
+      getLongDoctorOrder(row.id),
+      getDoctorOrderExecutionPage({
+        pageNo: 1,
+        pageSize: 3000,
+        tenantIds: userStore.orgTenantId,
+        elderId: row.elderId,
+        orderType: 1,
+        planTime: ['2000-01-01', dayjs().format('YYYY-MM-DD')]
+      })
+    ])
+
+    Object.assign(form, defaultForm, detail)
+    form.entries = (detail.entries || []) as LongDoctorOrderEntry[]
+
+    // 医嘱条目索引:用于停止信息 + 内容兜底
+    const entryMap = new Map<number, LongDoctorOrderEntry>()
+    ;(form.entries || []).forEach((e) => {
+      if (e.id !== undefined && e.id !== null) entryMap.set(Number(e.id), e)
+    })
+
+    const targetElderId = Number(row.elderId)
+    const allExecRecords = ((execPage?.list || []) as any[]).filter(
+      (r: any) =>
+        r.elderId !== undefined && r.elderId !== null && Number(r.elderId) === targetElderId
+    )
+
+    // 停止确认任务(execType=2):按医嘱条目建索引,优先取已确认(status=1)的一条。
+    // 仅有待确认任务时表示医嘱停止尚未在护理层面闭环,停止栏留空。
+    const stopConfirmMap = new Map<number, any>()
+    allExecRecords
+      .filter(
+        (r: any) =>
+          Number(r.execType) === 2 && r.orderEntryId !== undefined && r.orderEntryId !== null
+      )
+      .forEach((r: any) => {
+        const key = Number(r.orderEntryId)
+        const prev = stopConfirmMap.get(key)
+        if (!prev || (prev.status !== 1 && r.status === 1)) stopConfirmMap.set(key, r)
+      })
+
+    // 常规执行记录(execType 非 2、已执行):单据正文每行一条
+    const records: DoctorOrderExecution[] = allExecRecords
+      .filter((r: any) => Number(r.execType) !== 2 && r.execTime)
+      .map((r: any) => {
+        const entry =
+          r.orderEntryId !== undefined && r.orderEntryId !== null
+            ? entryMap.get(Number(r.orderEntryId))
+            : undefined
+        return {
+          ...r,
+          orderContent: r.orderContent || entry?.orderContent || '',
+          frequency: r.frequency || entry?.frequency || '',
+          medTime: r.medTime || entry?.medTime || '',
+          route: r.route || entry?.route || '',
+          doctorSign: r.doctorSign || entry?.doctorSign || '',
+          _entry: entry,
+          _entryId:
+            r.orderEntryId !== undefined && r.orderEntryId !== null
+              ? Number(r.orderEntryId)
+              : undefined
+        }
+      })
+
+    // 全部记录按实际执行时间升序(同时间按 id 稳定排序)
+    records.sort((a: any, b: any) => {
+      const ta = String(a.execTime || '')
+      const tb = String(b.execTime || '')
+      if (ta !== tb) return ta < tb ? -1 : 1
+      return Number(a.id || 0) - Number(b.id || 0)
+    })
+
+    // 同一种医嘱(orderEntryId)分组;缺失 id 时用内容+用法组合键兜底
+    const groupMap = new Map<string, any[]>()
+    records.forEach((r: any) => {
+      const key =
+        r._entryId !== undefined
+          ? `e-${r._entryId}`
+          : `c-${r.orderContent || ''}-${r.frequency || ''}-${r.medTime || ''}-${r.route || ''}`
+      if (!groupMap.has(key)) groupMap.set(key, [])
+      groupMap.get(key)!.push(r)
+    })
+
+    // records 已按时间排序,Map 插入顺序即各组首条执行时间先后
+    const rows: PrintRow[] = []
+    let orderNo = 0
+    groupMap.forEach((group) => {
+      orderNo += 1
+      const entry: LongDoctorOrderEntry | undefined = group[0]?._entry
+      const entryId: number | undefined = group[0]?._entryId
+
+      // 停止信息解析:
+      // - 有已确认的停止任务(status=1):停止时间=护士确认时间,停止护士=确认护士(真实签名);
+      // - 停止任务待确认(status=0):护理层面尚未闭环,停止栏整体留空;
+      // - 没有停止任务(功能上线前的老数据):回退医嘱条目上的 stopTime/stopNurseSign。
+      const stopRec = entryId !== undefined ? stopConfirmMap.get(entryId) : undefined
+      const stopConfirmed = stopRec && stopRec.status === 1 && stopRec.execTime
+      const stopTime = stopConfirmed ? stopRec.execTime : stopRec ? '' : entry?.stopTime || ''
+      const stopNurseSign = stopConfirmed
+        ? stopRec.execNurseSign || ''
+        : stopRec
+          ? ''
+          : entry?.stopNurseSign || ''
+      const stopDoctorSign = stopRec
+        ? stopConfirmed
+          ? entry?.stopDoctorSign || ''
+          : ''
+        : entry?.stopDoctorSign || ''
+
+      group.forEach((r, i) => {
+        // 停止信息只落在组内最后一行;前面的行停止栏留空但保留单元格与行线
+        const isLast = i === group.length - 1
+        rows.push({
+          execTime: r.execTime,
+          doctorSign: r.doctorSign || '',
+          nurseSign: r.execNurseSign || '',
+          orderContent: r.orderContent || '',
+          frequency: r.frequency,
+          medTime: r.medTime,
+          route: r.route,
+          remark: r.remark || '',
+          orderNo: i === 0 ? orderNo : '',
+          stopTime: isLast ? stopTime : '',
+          stopDoctorSign: isLast ? stopDoctorSign : '',
+          stopNurseSign: isLast ? stopNurseSign : ''
+        })
+      })
+    })
+
+    // 不足最小行数补空行(独立 7 列空行)
+    for (let i = rows.length; i < MIN_ROWS; i++) {
+      rows.push({ orderNo: '' })
     }
+    renderRows.value = rows
+  } catch (e) {
+    console.error('[长期医嘱单] 打印数据加载失败', e)
+    message.error('长期医嘱单数据加载失败,请稍后重试')
   } finally {
     loading.value = false
   }
@@ -257,6 +381,7 @@ defineExpose({ open })
 const handleClosed = () => {
   dialogVisible.value = false
   Object.assign(form, defaultForm, { entries: [] })
+  renderRows.value = []
 }
 
 // 打印配置(vue3-print-nb)
@@ -414,7 +539,7 @@ const print = {
   @page 必须 un-scoped(CSS 规范限制),单独放一个 style 块。
   A4 竖版 210×297mm:
   - thead { display: table-header-group }:浏览器自动在每页重复两行表头
-  - tr { page-break-inside: avoid }:单条医嘱行不被切断
+  - tr { page-break-inside: avoid }:单条执行记录行不被切断
 -->
 <style lang="scss">
 @media print {

+ 253 - 0
src/views/elderly/elder/doctor-orders/长期医嘱-停止确认-后端接口需求.md

@@ -0,0 +1,253 @@
+# 长期医嘱单 — “停止确认”签名环节后端接口需求
+
+> 提出方:前端 | 日期:2026-09-20 | 模块:健康管理 / 长期医嘱、执行医嘱涉及前端页面:
+>
+> - 长期医嘱(`/trend-charts/long-doctor-order`):长期医嘱单、长期医嘱单打印
+> - 执行医嘱(`/trend-charts/execute-doctor-order`):执行任务列表、护士执行/确认弹窗、执行记录
+
+---
+
+## 一、背景与目标
+
+纸质《长期医嘱单》的法定格式中,停止环节必须有护士签名,完整记录为:开具日期时间、医嘱内容、医师签名、核对时间、核对护士签名、**停止日期与时间、医师签名、停止执行时间、执行护士签名**。
+
+其中停止环节的护士签名与日常“执行医嘱”签名性质不同:
+
+- **执行签名**:护士每次落实医嘱内容(给药、治疗)后的确认,记录“已执行”;
+- **停止签名**:医嘱有效性终止时,护士填写**停止执行时间**并签名,确认该医嘱已在临床执行层面实际终止,是医嘱单闭环的强制要求。
+
+当前系统现状:
+
+- 日常执行签名已有:`doctorOrderExecution` 记录 + 护士在执行医嘱页确认,数据真实;
+- 停止签名**无采集入口**:长期医嘱“停止”只改条目状态,没有护士确认动作,长期医嘱单停止栏的护士签名永远为空。
+
+**目标方案(前端已按此开发完成):把“停止”本身做成一条特殊的执行任务——“停止确认任务”,复用执行医嘱的确认与签名机制。**
+
+业务闭环:
+
+```
+医生在长期医嘱表单点击「停止医嘱」
+  → 写入停止时间、停止医生签名
+  → 后端生成一条「停止确认」任务(待确认)
+       ↓
+护士在「执行医嘱」页看到红色【停止确认】任务,点击「确认停止」
+  → 停止执行时间(默认当前时间)、停止执行护士(默认当前登录人)
+  → 确认结果即长期医嘱单上的:停止时间 + 停止护士签名
+```
+
+---
+
+## 二、数据模型变更
+
+### 2.1 `doctorOrderExecution`(医嘱执行记录表)新增字段
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `execType` | Integer | 是(默认 1) | 执行类型:**1=常规执行**(日常给药/治疗);**2=停止确认**(长期医嘱停止需护士确认签名) |
+
+约定:
+
+- 历史数据、所有临时医嘱执行记录均为 `execType=1`;
+- `execType=2` 仅用于长期医嘱(`orderType=1`),临时医嘱不产生;
+- 停止确认任务**不参与按频次展开的执行计划**,不参与欠账/销账计算,是一次性任务;
+- 同一长期医嘱条目,同一时间最多存在 **1 条** `execType=2 AND status=0` 的待确认任务(幂等)。
+
+其余字段沿用现有结构(仅列出与本次相关的):
+
+| 字段 | 说明 |
+| --- | --- |
+| `id` | 执行记录 id |
+| `orderType` | 1=长期医嘱,2=临时医嘱 |
+| `orderEntryId` | 关联长期医嘱条目 id(`longDoctorOrderEntry.id`) |
+| `elderId / elderName / elderAge / bedName` | 长者冗余信息(列表展示用,与常规执行记录一致) |
+| `orderContent / frequency / medTime / route` | 医嘱内容与给药三要素冗余 |
+| `planTime` | 计划时间:停止确认任务填**停止时间**(即长期医嘱条目 `stopTime`) |
+| `execTime` | 实际执行时间:护士确认停止时填**停止执行时间** |
+| `execNurseSign` | 执行护士签名:护士确认停止时填**停止执行护士签名** |
+| `doctorSign` | 医生签名(开嘱/停止医生,展示用) |
+| `status` | 0=待执行(待停止确认),1=已执行(已停止确认),2=已跳过 |
+| `remark` | 备注 |
+| `tenantId` | 租户(机构)id |
+
+### 2.2 `longDoctorOrderEntry`(长期医嘱条目)字段沿用,无需加字段
+
+停止信息字段已存在,继续使用:
+
+| 字段             | 写入时机                                                               |
+| ---------------- | ---------------------------------------------------------------------- |
+| `stopTime`       | 医生点“停止医嘱”时写入(前端已改为默认当前时间并随单据 update 提交)   |
+| `stopDoctorSign` | 医生点“停止医嘱”时写入(前端已改为默认当前登录人)                     |
+| `stopNurseSign`  | 建议:护士完成停止确认后由后端回写(见 3.4),供长期医嘱表单只读列展示 |
+| `status`         | 0=未开始,1=在用,2=已停止(现有逻辑不变)                             |
+
+---
+
+## 三、接口变更清单
+
+### 3.1 停止医嘱接口:停止时自动生成停止确认任务
+
+`PUT /admin-api/elderly/longDoctorOrder/entry/updateStatus?id={entryId}&status=2`
+
+现有接口仅更新条目状态,需要增加逻辑:
+
+1. 将条目置为“已停止”(`status=2`);
+2. 若该条目尚不存在 `execType=2 AND status=0` 的停止确认任务,则**自动新增一条**:
+   - `orderType=1`、`execType=2`、`orderEntryId=当前条目id`
+   - `status=0`(待停止确认)
+   - `planTime = 条目.stopTime`
+   - 冗余 `elderId / elderName / elderAge / bedName / orderContent / frequency / medTime / route / doctorSign(=stopDoctorSign) / tenantId`
+   - `execTime / execNurseSign` 留空
+3. **幂等**:重复调用停止、或已存在待确认任务时,不重复生成;
+4. 重新启用(`status=2 → 1`)时:该条目未确认的停止确认任务(`execType=2, status=0`)自动失效,建议置 `status=2`(已跳过)或物理删除,避免护士确认到一条过期任务。
+
+> 时序说明:前端在医生点“停止”时,会先调一次单据 update(写入 stopTime/stopDoctorSign),再调本接口,因此后端生成任务时可直接读到条目上的 stopTime/stopDoctorSign。
+
+### 3.2 执行记录分页接口:返回 `execType`
+
+`GET /admin-api/elderly/doctorOrderExecution/page`
+
+现有参数(`pageNo/pageSize/tenantIds/elderId/orderType/planTime[]/status`)不变,响应 `data.list` 每条记录**增加返回 `execType` 字段**。
+
+前端用法(已实现):
+
+- `execType=2` 的记录从“频次展开计划行”的回填逻辑中排除,作为**独立任务行**追加到执行医嘱列表;
+- 按 `planTime` 日期范围、`orderType`、`status`、长者姓名的过滤对两类记录统一生效;
+- 兼容:字段缺失时前端按 `execType=1` 处理。
+
+### 3.3 执行(确认)接口:支持确认停止任务
+
+`POST /admin-api/elderly/doctorOrderExecution/execute`
+
+请求体在现有基础上透传 `execType`:
+
+```json
+{
+  "id": 990001,
+  "orderType": 1,
+  "execType": 2,
+  "orderEntryId": 123,
+  "elderId": 456,
+  "elderName": "test0824",
+  "bedName": "颐年-3-322-01",
+  "orderContent": "每日一次运动",
+  "frequency": "bid",
+  "medTime": "pc",
+  "route": "ivgtt",
+  "planTime": "2026-09-20 10:00:00",
+  "execTime": "2026-09-20 10:15:00",
+  "execNurseSign": "李护长",
+  "doctorSign": "张医生",
+  "status": 1,
+  "remark": ""
+}
+```
+
+后端要求:
+
+- `execType=2` 时允许确认,写入 `execTime`(停止执行时间)、`execNurseSign`(停止执行护士签名)、`status=1`;
+- 跳过接口 `PUT /doctorOrderExecution/skip?id=` 对停止确认任务**应禁用**(前端已隐藏“跳过”按钮,后端建议也拒绝,返回明确提示“停止确认任务不可跳过”)。
+
+### 3.4(建议)确认后回写长期医嘱条目
+
+护士确认停止成功后,建议后端同步回写条目:
+
+- `longDoctorOrderEntry.stopNurseSign = execNurseSign`;
+- 若 `stopTime` 为空(历史脏数据或异常时序),以 `execTime` 回填 `stopTime`;
+- 这样长期医嘱表单的“停止护士签名”只读列、以及其他只读消费方都能直接取到值。
+
+---
+
+## 四、长期医嘱单打印的取数口径(前端已实现,供后端核对)
+
+打印数据来自:长期医嘱单详情 + `doctorOrderExecution/page`(该长者、orderType=1、足够大的历史时间窗)。
+
+| 纸质栏位 | 取数字段 |
+| --- | --- |
+| 开始·时间(每条执行一行) | 常规执行记录(execType=1, status=1)的 `execTime` |
+| 开始·医生 | 医嘱条目 `doctorSign`(执行记录缺失时兜底) |
+| 开始·执行护士 | 常规执行记录 `execNurseSign` |
+| 停止·时间 | 已确认停止任务(execType=2, status=1)的 `execTime`;无停止任务的老数据回退条目 `stopTime` |
+| 停止·医生 | 医嘱条目 `stopDoctorSign` |
+| 停止·护士 | 已确认停止任务的 `execNurseSign`;无停止任务的老数据回退条目 `stopNurseSign` |
+
+边界规则:
+
+- 停止任务**待确认**(execType=2, status=0):停止栏整体留空(表示护理层面尚未闭环),不回退条目字段;
+- 停止任务不进入单据正文(正文只列常规执行记录);
+- 同一种医嘱(orderEntryId 相同)的多条常规执行记录归为一组,停止信息单元格合并显示在该组最后一条。
+
+---
+
+## 五、前端已完成改造(2026-09-20)
+
+| 文件 | 改动 |
+| --- | --- |
+| `src/api/elderly/elder/bed-change/index.ts` | `DoctorOrderExecution` 增加 `execType`,新增 `EXEC_TYPE` 常量(1 常规 / 2 停止确认) |
+| `long-doctor-order/Print.vue` | 停止栏优先取已确认停止任务;待确认留空;老数据回退条目字段;停止任务不进正文 |
+| `long-doctor-order/Form.vue` | 点“停止医嘱”自动写 stopTime/stopDoctorSign 并先保存;新增只读“停止护士签名”列;停止提示语说明需护士确认 |
+| `execute-doctor-order/index.vue` | 停止确认任务作为独立行展示:红色【停止确认】标记、“待停止确认/已停止确认”状态、“确认停止”按钮;不可跳过、不参与批量执行、不进执行单打印 |
+| `execute-doctor-order/ExecuteDialog.vue` | 停止任务弹窗:标题“护士确认停止医嘱”、字段“停止执行时间/停止执行护士”、按钮“确认停止”,提交透传 execType |
+| `execute-doctor-order/ExecutionRecordDialog.vue` | 执行记录弹窗/打印同步标记【停止确认】与状态文案 |
+
+前端已用注入模拟数据验证:已确认任务、待确认任务、无任务老数据三种场景的长期医嘱单打印,以及执行医嘱列表与确认弹窗,均符合预期。
+
+---
+
+## 六、联调与验收要点
+
+1. 医生停止一条在用长期医嘱后,执行医嘱页(默认待执行筛选)出现一条【停止确认】任务;
+2. 护士确认后:任务状态变“已停止确认”,长期医嘱单打印该医嘱停止栏显示停止执行时间 + 停止医生 + 停止护士,位置在同一种医嘱最后一条执行记录处;
+3. 停止确认任务不出现在:批量执行勾选、跳过按钮、医嘱执行单打印;
+4. 医嘱重新启用后,原待确认任务消失/失效;
+5. 历史老数据(无 execType=2 记录)打印不受影响,停止栏仍按条目 stopTime/stopDoctorSign/stopNurseSign 展示;
+6. `execType` 字段缺失时全部按常规执行处理,保证向后兼容。
+
+---
+
+## 七、后端实测记录(2026-09-21,生产环境 `47.107.245.0:48080`,测试养老院 tenantId=195)
+
+### 7.1 已验证通过
+
+| # | 验收点 | 实测结果 |
+| --- | --- | --- |
+| 1 | `doctorOrderExecution/page` 返回 `execType` | ✅ 字段已返回,历史记录均为 execType=1 |
+| 2 | 停止自动生成停止确认任务 | ✅ 停止 entry51 后生成 execType=2/status=0 任务,planTime=条目 stopTime,长者/床位/医嘱内容/医生签名冗余齐全 |
+| 3 | 幂等(重复停止不重复生成) | ✅ 对在用条目重复调 updateStatus=2,始终只有 1 条待确认任务 |
+| 4 | 护士确认 `execute` | ✅ 写入 status=1、execTime、execNurseSign |
+| 5 | 确认后回写条目(3.4) | ✅ `longDoctorOrderEntry.stopNurseSign` 自动回写为确认护士 |
+| 6 | 停止任务不可跳过 | ✅ `PUT /doctorOrderExecution/skip` 返回 `code=1008024004,msg="停止确认任务不可跳过"`,任务状态不变 |
+| 7 | 重新启用作废旧任务 | ✅ 条目 status=2→1 后,原待确认任务自动置 status=2 |
+| 8 | 页面与打印 | ✅ 执行医嘱页红色【停止确认】行、仅"确认停止"按钮、弹窗默认当前时间/当前护士;长期医嘱单打印停止栏取已确认任务的 execTime/execNurseSign;执行记录弹窗标记【停止确认】+"已停止确认" |
+
+### 7.2 缺陷:重新启用后再次停止,不再生成停止确认任务(需后端修复)
+
+**影响**:同一医嘱"停止→启用→再停止"后,护士端永远收不到第二次停止的确认任务,停止执行时间与停止护士签名无法采集,医嘱单停止栏无法闭环。
+
+**复现步骤**(测试单据 id=22 / test0824 / 条目 entryId=50):
+
+1. 在用条目调停止:`PUT /elderly/longDoctorOrder/entry/updateStatus?id=50&status=2` → 条目 status=2,自动生成任务 id=52(execType=2,**status=0**)✅
+2. 重新启用:`PUT /elderly/longDoctorOrder/entry/updateStatus?id=50&status=1` → 条目 status=1,任务 id=52 自动失效(**status=2**)✅
+3. **再次停止**:`PUT /elderly/longDoctorOrder/entry/updateStatus?id=50&status=2` → 返回 `code=0`,条目 status=2,但 execution 表**未新增**任何 status=0 任务,旧任务 id=52 仍为 status=2 ❌
+4. 护士在执行医嘱页看不到【停止确认】任务;长期医嘱单停止栏无法产生真实签名。
+
+**原因推测**:停止时"是否已存在停止确认任务"的幂等查询漏了 status 条件——只要该条目存在任意 execType=2 记录(含已失效 status=2 / 已确认 status=1)就跳过生成。
+
+**修复建议**:幂等判断严格按本文 3.1 约定,仅当存在 `execType=2 AND status=0` 的任务时才跳过;旧任务已失效(status=2)时再次停止必须**新建**一条待确认任务。
+
+### 7.3 复测记录(2026-09-21,修复后环境 `192.168.101.118:48080`,同套测试数据 entryId=50)
+
+**结论:7.2 缺陷已修复,全部验收点复测通过。**
+
+| # | 复测点 | 实测结果 |
+| --- | --- | --- |
+| 1 | 重启用后再次停止生成任务(7.2 缺陷修复点) | ✅ 停止(status=2)→启用(status=1)→再停止:任务 id=52 由 status=2 重置回 status=0 重新进入待确认,接口均返回 code=0 |
+| 2 | 幂等(修复后仍保持) | ✅ 待确认(status=0)期间重复调停止,仍只有 1 条待确认任务,不重复生成 |
+| 3 | 已确认任务不受重启用影响 | ✅ 任务 id=52 确认(status=1)后再重启用,任务保持 status=1 不回退;再次停止生成**新任务** id=57(status=0) |
+| 4 | planTime 取值 | ✅ 停止任务 planTime=条目 stopTime |
+| 5 | 护士确认 + 回写 | ✅ API 与页面弹窗确认均正常:status→1、写入 execTime/execNurseSign,条目 `stopNurseSign` 回写"张三" |
+| 6 | 停止任务不可跳过 | ✅ skip 仍返回 `code=1008024004` |
+| 7 | 页面 UI 全链路 | ✅ 停止行在执行医嘱列表显示红色【停止确认】+"待停止确认",操作列仅"确认停止";确认弹窗提交后提示"执行成功" |
+| 8 | 执行记录弹窗 | ✅ 停止任务 planTime=2026-09-25(条目 stopTime 为未来时间,历史测试数据遗留),默认"近30天~今天"窗口内查不到属**预期过滤行为**;将弹窗日期范围扩大到覆盖 planTime 后可见【停止确认】+"已停止确认"行(任务 52/57,护士=张三),非缺陷 |
+| 9 | 长期医嘱单打印 | ✅ 停止栏"停止时间/停止医生/停止护士"渲染在同组最后一条执行记录行,每行 7 个独立单元格,虚线分隔完整 |
+
+**附注**:① 复测经前端页面+接口双重验证;② 复测后 entryId=50 处于"已停止+已确认"终态(原为在用)。

+ 73 - 2
src/views/elderly/fee/bill-pay/preview.vue

@@ -159,11 +159,69 @@
           </div>
         </div>
       </el-tab-pane>
+      <el-tab-pane label="打印类型C(含固定费明细·床位号在前)" name="third">
+        <div class="first" id="printTab3">
+          <div class="header">
+            <p class="title" style="font-size: 18px">{{ tenantName }}对账单预览</p>
+            <div class="info-wrap" style="margin-top: -4px">
+              <span><b>床位号:</b> {{ dataForm.bedName }}</span>
+              <span
+                ><b>姓名:</b> {{ dataForm.elderName
+                }}{{
+                  dataForm.contractNumber == null || dataForm.contractNumber == ''
+                    ? ''
+                    : `(${dataForm.contractNumber})`
+                }}</span
+              >
+              <span><b>缴费时间:</b> {{ formatTime(dataForm.payTime, 'yyyy-MM-dd') }}</span>
+              <span><b>经手人:</b> {{ dataForm.payeeName }}</span>
+            </div>
+          </div>
+          <el-divider style="margin-top: 0px" />
+          <div class="content">
+            <div class="flex">
+              <b>{{
+                dataForm.description
+                  ? tTitle + '(' + dataForm.description + ')'
+                  : tTitle + '(' + bTitle + ')'
+              }}</b>
+              <span><b>金额小计:</b>¥ {{ tableSubtotal }}</span>
+            </div>
+            <table style="width: 100%" border="2">
+              <tr align="center">
+                <td>序号</td>
+                <td>费用分类</td>
+                <td>费用名称</td>
+                <td>金额(元)</td>
+                <td>实际发生时间</td>
+                <td>备注</td>
+              </tr>
+              <tr v-for="(item, index) in dataForm.items" :key="index" align="center">
+                <td>{{ index + 1 }}</td>
+                <td>{{ item.itemCategoryName }}</td>
+                <td>{{ item.itemName }}</td>
+                <td>{{ tableRowPay(item) }}</td>
+                <td>{{ formatStartDate(item) }}</td>
+                <td>{{ item.description }}</td>
+              </tr>
+              <tr align="center">
+                <td>支付方式描述</td>
+                <td colspan="5" class="pay-text-cell">{{ payText }}</td>
+              </tr>
+            </table>
+          </div>
+          <!-- 机构签章:叠在表格右下角,旋转 -45° -->
+          <div v-if="sealUrl" class="seal-overlay">
+            <img :src="sealUrl" class="seal-img" alt="机构公章" />
+          </div>
+        </div>
+      </el-tab-pane>
     </el-tabs>
     <template #footer>
       <el-button @click="handleClosed">取消</el-button>
       <el-button type="primary" v-print="printTab2" v-if="activeName == 'second'">打印</el-button>
       <el-button type="primary" v-print="printTab1" v-if="activeName == 'first'">打印</el-button>
+      <el-button type="primary" v-print="printTab3" v-if="activeName == 'third'">打印</el-button>
     </template>
   </Dialog>
 </template>
@@ -217,7 +275,8 @@ const sealUrl = ref('')
 
 const formatPayCreatedTime = (createdTime) => {
   if (!createdTime) return ''
-  const timestamp = String(createdTime).length === 10 ? Number(createdTime) * 1000 : Number(createdTime)
+  const timestamp =
+    String(createdTime).length === 10 ? Number(createdTime) * 1000 : Number(createdTime)
   return dayjs(timestamp).format('YYYY-MM-DD')
 }
 
@@ -664,7 +723,7 @@ const formatStr = (dateStr) => {
 // - 同步印章位置:right: 5mm / bottom: 8mm,让印章向下偏移并完整可见
 const printExtraCss = `
   html, body { height: auto !important; overflow: visible !important; }
-  #printTab1, #printTab2 { height: auto !important; overflow: visible !important; padding-bottom: 12mm !important; }
+  #printTab1, #printTab2, #printTab3 { height: auto !important; overflow: visible !important; padding-bottom: 12mm !important; }
   table { page-break-inside: auto; width: 100%; border-collapse: collapse; }
   tr { page-break-inside: avoid; page-break-after: auto; }
   thead { display: table-header-group; }
@@ -701,6 +760,18 @@ const printTab1 = {
   },
   standard: ''
 }
+// 打印(类型C:内容同类型A,头部床位号与姓名对调)
+const printTab3 = {
+  id: 'printTab3',
+  extraCss: '',
+  popTitle: '',
+  extraHead: `<style>${printExtraCss}</style>`,
+  preview: false,
+  previewTitle: tenantName + '对账单预览',
+  previewPrintBtnLabel: '预览结束,开始打印',
+  zIndex: 20002,
+  standard: ''
+}
 // 打印
 const printTab2 = {
   id: 'printTab2',

+ 703 - 3
src/views/operation-analysis/occupancy/bed-utilization/index.vue

@@ -1,7 +1,707 @@
 <template>
-  <ReportPlaceholder status="ready" desc="基于房态图/楼栋统计接口,展示院区-楼栋-楼层入住率、床位利用率及月度趋势。" />
+  <div v-loading="loading" class="oa-page">
+    <ContentWrap>
+      <div class="page-header">
+        <div class="header-title">
+          <Icon icon="ep:data-analysis" 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"
+            @change="loadAll"
+          >
+            <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 @click="toggleAllCampus">全选/全不选</el-button>
+        </el-form-item>
+      </el-form>
+
+      <el-alert
+        v-if="dynamicsReady === false"
+        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="15">
+        <ContentWrap>
+          <div class="panel-title">
+            各院区入住率对比
+            <span class="title-tip">按入住率升序,临时占用不计入在住</span>
+          </div>
+          <Echart v-if="campusStats.length" :options="campusBarOption" height="380px" />
+          <el-empty v-else description="暂无数据" :image-size="90" class="chart-empty" />
+        </ContentWrap>
+      </el-col>
+      <el-col :xs="24" :lg="9">
+        <ContentWrap>
+          <div class="panel-title">床位状态结构</div>
+          <div class="donut-wrap">
+            <Echart v-if="bedSummary.total" :options="bedDonutOption" height="380px" />
+            <el-empty v-else description="暂无数据" :image-size="90" class="chart-empty" />
+            <div v-if="bedSummary.total" class="donut-center">
+              <div class="donut-total">{{ bedSummary.total }}</div>
+              <div class="donut-label">总床位(张)</div>
+            </div>
+          </div>
+        </ContentWrap>
+      </el-col>
+    </el-row>
+
+    <el-row :gutter="12" class="chart-row">
+      <el-col :span="24">
+        <ContentWrap>
+          <div class="panel-title">
+            入住率月度趋势
+            <span class="title-tip">近 12 个月月末入住率,无法回溯的历史月份为断点</span>
+          </div>
+          <Echart v-if="trendReady && trendItems.length" :options="trendOption" height="300px" />
+          <el-empty
+            v-else
+            :description="
+              trendReady
+                ? '所选院区暂无趋势数据'
+                : '暂无数据:月度趋势接口待后端提供(check-trend)'
+            "
+            :image-size="90"
+            class="chart-empty"
+          />
+        </ContentWrap>
+      </el-col>
+    </el-row>
+
+    <ContentWrap class="table-wrap">
+      <div class="panel-title">
+        床位利用明细
+        <span class="title-tip">院区 → 楼栋 → 楼层逐级展开,展开时实时统计</span>
+      </div>
+      <el-table
+        v-if="tableRows.length"
+        :data="tableRows"
+        row-key="rowKey"
+        border
+        lazy
+        :load="loadChildren"
+        :tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
+      >
+        <el-table-column prop="name" label="院区 / 楼栋 / 楼层" min-width="220" />
+        <el-table-column label="房间数" width="90" align="center">
+          <template #default="{ row }">{{ row.totalRoomCount ?? '—' }}</template>
+        </el-table-column>
+        <el-table-column prop="totalBedCount" label="总床位" width="90" align="center" />
+        <el-table-column prop="occupiedBedCount" label="在住床位" width="90" align="center" />
+        <el-table-column prop="emptyBedCount" label="空闲床位" width="90" align="center" />
+        <el-table-column
+          prop="temporaryOccupationCount"
+          label="临时占用"
+          width="90"
+          align="center"
+        />
+        <el-table-column label="入住率" width="110" align="center">
+          <template #default="{ row }">
+            <span :class="rateClass(row.occupancyRate)">{{ formatRate(row.occupancyRate) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="床位利用率" width="110" align="center">
+          <template #default="{ row }">
+            <span :class="rateClass(row.utilizationRate)">{{
+              formatRate(row.utilizationRate)
+            }}</span>
+          </template>
+        </el-table-column>
+      </el-table>
+      <el-empty v-else description="暂无数据" :image-size="90" class="chart-empty" />
+    </ContentWrap>
+  </div>
 </template>
 
-<script setup lang="ts">
-import ReportPlaceholder from '../../components/ReportPlaceholder.vue'
+<script lang="ts" setup>
+import { Refresh } from '@element-plus/icons-vue'
+import Echart from '@/components/Echart/src/Echart.vue'
+import { getBuildList, getBuildData, getBuildFloorData } from '@/api/system/badManage'
+import {
+  fetchOverviewDynamics,
+  getCampusOptions,
+  type CampusBedStat,
+  type OverviewDynamics
+} from '../../overview/campus-data'
+import { fetchCheckTrend, type CheckTrendItem } from '../occupancy-api'
+
+defineOptions({ name: 'OaOccupancyBedUtilization' })
+
+const campusOptions = getCampusOptions()
+const selectedCampusIds = ref<number[]>(campusOptions.map((item) => item.value))
+const loading = ref(false)
+const updateTime = ref('')
+const dynamics = ref<OverviewDynamics | null>(null)
+const trendItems = ref<CheckTrendItem[]>([])
+const trendReady = ref<boolean | null>(null)
+
+const dynamicsReady = computed(() => dynamics.value !== null)
+const campusStats = computed<CampusBedStat[]>(() => dynamics.value?.campusStats ?? [])
+
+const toggleAllCampus = () => {
+  selectedCampusIds.value =
+    selectedCampusIds.value.length === campusOptions.length
+      ? []
+      : campusOptions.map((item) => item.value)
+  loadAll()
+}
+
+/** KPI 汇总(入住率按 sum 在住 / sum 总床 重算,不直接相加院区百分比) */
+const bedSummary = computed(() => {
+  const list = campusStats.value
+  const total = list.reduce((acc, item) => acc + item.totalBed, 0)
+  const used = list.reduce((acc, item) => acc + item.usedBed, 0)
+  const unused = list.reduce((acc, item) => acc + item.unusedBed, 0)
+  const temp = list.reduce((acc, item) => acc + item.tempBed, 0)
+  return {
+    total,
+    used,
+    unused,
+    temp,
+    occupancyRate: total > 0 ? (used / total) * 100 : null,
+    utilizationRate: total > 0 ? ((used + temp) / total) * 100 : null
+  }
+})
+
+const kpiCards = computed(() => [
+  {
+    label: '总床位',
+    value: bedSummary.value.total || '-',
+    unit: '张',
+    icon: 'ep:house',
+    theme: 'blue'
+  },
+  { label: '在住床位', value: bedSummary.value.used, unit: '张', icon: 'ep:user', theme: 'green' },
+  { label: '空闲床位', value: bedSummary.value.unused, unit: '张', icon: 'ep:moon', theme: 'cyan' },
+  {
+    label: '临时占用',
+    value: bedSummary.value.temp,
+    unit: '张',
+    icon: 'ep:clock',
+    theme: 'orange'
+  },
+  {
+    label: '入住率',
+    value: formatRate(bedSummary.value.occupancyRate),
+    unit: '',
+    icon: 'ep:trend-chances',
+    theme: 'purple'
+  }
+])
+
+function formatRate(val: number | null | undefined) {
+  return val === null || val === undefined ? '-' : `${val.toFixed(1)}%`
+}
+
+function rateClass(val: number | null | undefined) {
+  if (val === null || val === undefined) return ''
+  if (val >= 85) return 'rate-high'
+  if (val >= 60) return 'rate-mid'
+  return 'rate-low'
+}
+
+/** 明细树表:院区级行(床位数据取 dynamics,房间数院区层不展示) */
+interface TreeRow {
+  rowKey: string
+  level: 'campus' | 'building' | 'floor'
+  name: string
+  totalRoomCount?: number | null
+  totalBedCount: number
+  occupiedBedCount: number
+  emptyBedCount: number
+  temporaryOccupationCount: number
+  occupancyRate: number | null
+  utilizationRate: number | null
+  hasChildren: boolean
+  /** 楼栋行缓存楼层(getList 已带回) */
+  floorList?: { id: number; floorName: string }[]
+}
+
+const buildStatRow = (
+  level: TreeRow['level'],
+  rowKey: string,
+  name: string,
+  stat: {
+    totalRoomCount?: number | null
+    totalBedCount?: number | null
+    usedBedCount?: number | null
+    unusedBedCount?: number | null
+    temporaryOccupationCount?: number | null
+  },
+  hasChildren: boolean,
+  floorList?: TreeRow['floorList']
+): TreeRow => {
+  const totalBedCount = Number(stat.totalBedCount) || 0
+  const occupiedBedCount = Number(stat.usedBedCount) || 0
+  const temporaryOccupationCount = Number(stat.temporaryOccupationCount) || 0
+  return {
+    rowKey,
+    level,
+    name,
+    totalRoomCount: stat.totalRoomCount ?? null,
+    totalBedCount,
+    occupiedBedCount,
+    emptyBedCount: Number(stat.unusedBedCount) || 0,
+    temporaryOccupationCount,
+    occupancyRate: totalBedCount > 0 ? (occupiedBedCount / totalBedCount) * 100 : null,
+    utilizationRate:
+      totalBedCount > 0
+        ? ((occupiedBedCount + temporaryOccupationCount) / totalBedCount) * 100
+        : null,
+    hasChildren,
+    floorList
+  }
+}
+
+const tableRows = computed<TreeRow[]>(() =>
+  campusStats.value.map((item) =>
+    buildStatRow(
+      'campus',
+      `campus-${item.tenantId}`,
+      item.campusName,
+      {
+        totalRoomCount: null,
+        totalBedCount: item.totalBed,
+        usedBedCount: item.usedBed,
+        unusedBedCount: item.unusedBed,
+        temporaryOccupationCount: item.tempBed
+      },
+      true
+    )
+  )
+)
+
+/** 懒加载:院区→楼栋(逐楼统计),楼栋→楼层(逐层统计) */
+const loadChildren = async (
+  row: TreeRow,
+  _treeNode: unknown,
+  resolve: (data: TreeRow[]) => void
+) => {
+  try {
+    if (row.level === 'campus') {
+      const tenantId = Number(row.rowKey.replace('campus-', ''))
+      const buildings = await getBuildList({ tenantIds: tenantId })
+      const rows = await Promise.all(
+        (buildings || []).map(async (build: any) => {
+          const stat = await getBuildData(build.id).catch(() => ({}))
+          return buildStatRow(
+            'building',
+            `building-${build.id}`,
+            build.buildName,
+            stat || {},
+            (build.floorList || []).length > 0,
+            (build.floorList || []).map((f: any) => ({ id: f.id, floorName: f.floorName }))
+          )
+        })
+      )
+      resolve(rows)
+      return
+    }
+    if (row.level === 'building') {
+      const rows = await Promise.all(
+        (row.floorList || []).map(async (floor) => {
+          const stat = await getBuildFloorData(floor.id).catch(() => ({}))
+          return buildStatRow('floor', `floor-${floor.id}`, floor.floorName, stat || {}, false)
+        })
+      )
+      resolve(rows)
+      return
+    }
+  } catch {
+    // 单层统计失败不影响其它层级
+  }
+  resolve([])
+}
+
+/* ---------------- 图表 ---------------- */
+
+const campusBarOption = computed(() => {
+  const list = [...campusStats.value].sort((a, b) => {
+    const ra = a.occupancyRate ?? -1
+    const rb = b.occupancyRate ?? -1
+    return ra - rb
+  })
+  return {
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: { type: 'shadow' },
+      formatter: (params: any[]) => {
+        const idx = params[0]?.dataIndex ?? 0
+        const item = list[idx]
+        if (!item) return ''
+        return `${item.campusName}<br/>总床位 ${item.totalBed} 张<br/>在住 ${item.usedBed} 张<br/>空闲 ${
+          item.unusedBed
+        } 张<br/>临时占用 ${item.tempBed} 张<br/>入住率 ${formatRate(item.occupancyRate)}`
+      }
+    },
+    grid: { left: 110, right: 60, top: 16, bottom: 16, containLabel: true },
+    xAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } },
+    yAxis: {
+      type: 'category',
+      data: list.map((item) => item.campusName),
+      axisLabel: { width: 100, overflow: 'truncate' }
+    },
+    series: [
+      {
+        name: '入住率',
+        type: 'bar',
+        barWidth: 16,
+        data: list.map((item) => item.occupancyRate),
+        itemStyle: {
+          borderRadius: [0, 4, 4, 0],
+          color: (p: any) => {
+            const v = p.value
+            if (v === null || v === undefined) return '#dcdfe6'
+            if (v >= 85) return '#67c23a'
+            if (v >= 60) return '#409eff'
+            return '#e6a23c'
+          }
+        },
+        label: { show: true, position: 'right', formatter: (p: any) => formatRate(p.value) }
+      }
+    ]
+  }
+})
+
+const bedDonutOption = computed(() => {
+  const s = bedSummary.value
+  return {
+    tooltip: { trigger: 'item', formatter: '{b}:{c} 张({d}%)' },
+    legend: { bottom: 0, icon: 'circle' },
+    series: [
+      {
+        type: 'pie',
+        radius: ['52%', '72%'],
+        center: ['50%', '46%'],
+        avoidLabelOverlap: true,
+        label: { formatter: '{b}\n{c} 张' },
+        data: [
+          { name: '在住床位', value: s.used, itemStyle: { color: '#36a866' } },
+          { name: '空闲床位', value: s.unused, itemStyle: { color: '#5b9bff' } },
+          { name: '临时占用', value: s.temp, itemStyle: { color: '#f5851d' } }
+        ].filter((item) => item.value > 0)
+      }
+    ]
+  }
+})
+
+const trendOption = computed(() => {
+  const items = trendItems.value
+  return {
+    tooltip: {
+      trigger: 'axis',
+      formatter: (params: any[]) => {
+        const item = items[params[0]?.dataIndex ?? 0]
+        if (!item) return ''
+        return `${item.period}<br/>期初在院 ${item.beginInHospital} 人<br/>入住 ${item.checkInCount} 人<br/>退住 ${
+          item.checkOutCount
+        } 人<br/>净增 ${item.netGrowth} 人<br/>期末在院 ${item.endInHospital} 人<br/>月末入住率 ${formatRate(
+          item.endOccupancyRate
+        )}`
+      }
+    },
+    legend: { data: ['入住', '退住', '净增', '月末入住率'], top: 0 },
+    grid: { left: 48, right: 56, top: 40, bottom: 30 },
+    xAxis: { type: 'category', data: items.map((item) => item.period) },
+    yAxis: [
+      { type: 'value', name: '人数' },
+      { type: 'value', name: '入住率', max: 100, axisLabel: { formatter: '{value}%' } }
+    ],
+    series: [
+      {
+        name: '入住',
+        type: 'bar',
+        data: items.map((item) => item.checkInCount),
+        itemStyle: { color: '#67c23a', borderRadius: [3, 3, 0, 0] }
+      },
+      {
+        name: '退住',
+        type: 'bar',
+        data: items.map((item) => item.checkOutCount),
+        itemStyle: { color: '#f56c6c', borderRadius: [3, 3, 0, 0] }
+      },
+      {
+        name: '净增',
+        type: 'bar',
+        data: items.map((item) => item.netGrowth),
+        itemStyle: { color: '#409eff', borderRadius: [3, 3, 0, 0] }
+      },
+      {
+        name: '月末入住率',
+        type: 'line',
+        yAxisIndex: 1,
+        connectNulls: false,
+        data: items.map((item) => item.endOccupancyRate),
+        smooth: true,
+        symbolSize: 7,
+        lineStyle: { width: 2.5, color: '#e6a23c' },
+        itemStyle: { color: '#e6a23c' }
+      }
+    ]
+  }
+})
+
+const pad2 = (n: number) => String(n).padStart(2, '0')
+
+const loadAll = async () => {
+  if (!selectedCampusIds.value.length) {
+    dynamics.value = null
+    trendItems.value = []
+    trendReady.value = null
+    return
+  }
+  loading.value = true
+  try {
+    const [overview] = await Promise.all([
+      fetchOverviewDynamics(selectedCampusIds.value),
+      fetchCheckTrend({ tenantIds: selectedCampusIds.value, granularity: 'month', months: 12 })
+        .then((items) => {
+          trendItems.value = items
+          trendReady.value = true
+        })
+        .catch(() => {
+          trendItems.value = []
+          trendReady.value = false
+        })
+    ])
+    dynamics.value = overview
+    const now = new Date()
+    updateTime.value = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())} ${pad2(
+      now.getHours()
+    )}:${pad2(now.getMinutes())}`
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(loadAll)
 </script>
+
+<style lang="scss" scoped>
+.oa-page {
+  .page-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin-bottom: 14px;
+
+    .header-title {
+      display: flex;
+      align-items: center;
+      font-size: 18px;
+      font-weight: 600;
+      color: #303133;
+
+      .title-icon {
+        margin-right: 8px;
+        font-size: 22px;
+        color: #409eff;
+      }
+    }
+
+    .update-time {
+      margin-right: 14px;
+      font-size: 13px;
+      color: #909399;
+    }
+  }
+
+  .filter-form {
+    margin-top: 4px;
+
+    .campus-select {
+      width: 360px;
+      max-width: 60vw;
+    }
+  }
+
+  .api-tip {
+    margin-bottom: 12px;
+  }
+
+  .kpi-grid {
+    display: grid;
+    grid-template-columns: repeat(5, 1fr);
+    gap: 12px;
+    margin-bottom: 4px;
+
+    @media (max-width: 1200px) {
+      grid-template-columns: repeat(3, 1fr);
+    }
+    @media (max-width: 768px) {
+      grid-template-columns: repeat(2, 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: 30px;
+        font-weight: 700;
+        line-height: 1.1;
+
+        .kpi-unit {
+          margin-left: 4px;
+          font-size: 13px;
+          font-weight: 400;
+          opacity: 0.85;
+        }
+      }
+
+      &.blue {
+        background: linear-gradient(135deg, #5b9bff 0%, #2f7cf6 100%);
+      }
+      &.green {
+        background: linear-gradient(135deg, #62d391 0%, #36a866 100%);
+      }
+      &.cyan {
+        background: linear-gradient(135deg, #4fd0e0 0%, #1fa9bf 100%);
+      }
+      &.orange {
+        background: linear-gradient(135deg, #ffb066 0%, #f5851d 100%);
+      }
+      &.purple {
+        background: linear-gradient(135deg, #b794f6 0%, #8b5cf6 100%);
+      }
+    }
+  }
+
+  .chart-row {
+    margin-top: 12px;
+  }
+
+  .table-wrap {
+    margin-top: 12px;
+  }
+
+  .chart-empty {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 320px;
+  }
+
+  .donut-wrap {
+    position: relative;
+  }
+
+  .donut-center {
+    position: absolute;
+    top: 46%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    text-align: center;
+    pointer-events: none;
+  }
+
+  .donut-total {
+    font-size: 26px;
+    font-weight: 700;
+    color: #303133;
+    line-height: 1.2;
+  }
+
+  .donut-label {
+    margin-top: 2px;
+    font-size: 12px;
+    color: #909399;
+  }
+
+  .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;
+    }
+  }
+
+  .rate-high {
+    color: #67c23a;
+    font-weight: 600;
+  }
+  .rate-mid {
+    color: #409eff;
+    font-weight: 600;
+  }
+  .rate-low {
+    color: #e6a23c;
+    font-weight: 600;
+  }
+}
+</style>

+ 613 - 3
src/views/operation-analysis/occupancy/checkin-trend/index.vue

@@ -1,7 +1,617 @@
 <template>
-  <ReportPlaceholder status="backend" desc="基于运营周汇总扩展,展示任意区间按日/月的入住、退住、净增长序列(期初+入-出=期末)。" />
+  <div v-loading="loading" class="oa-page">
+    <ContentWrap>
+      <div class="page-header">
+        <div class="header-title">
+          <Icon icon="ep:trend-charts" class="title-icon" />
+          <span>入住退住趋势与净增</span>
+        </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="items in campusOptions"
+              :key="items.value"
+              :label="items.label"
+              :value="items.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="统计粒度">
+          <el-radio-group v-model="granularity" @change="handleGranularityChange">
+            <el-radio-button label="month" value="month">按月</el-radio-button>
+            <el-radio-button label="day" value="day">按日</el-radio-button>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="时间范围">
+          <el-date-picker
+            v-if="granularity === 'month'"
+            v-model="monthRange"
+            type="monthrange"
+            range-separator="至"
+            start-placeholder="开始月"
+            end-placeholder="结束月"
+            value-format="YYYY-MM"
+            class="range-picker"
+          />
+          <el-date-picker
+            v-else
+            v-model="dayRange"
+            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="!items.length"
+            @click="handleExport"
+          >
+            导出 Excel
+          </el-button>
+        </el-form-item>
+      </el-form>
+
+      <el-alert
+        v-if="ready === false"
+        type="warning"
+        :closable="false"
+        show-icon
+        class="api-tip"
+        title="趋势接口(/operation-analysis/overview/check-trend)待后端提供;当前可先查看页面下方的近 12 个月退住趋势(现网真实数据)。"
+      />
+
+      <!-- 区间 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>
+
+    <ContentWrap class="chart-wrap">
+      <div class="panel-title">
+        入住 / 退住 / 净增 与期末入住率
+        <span class="title-tip">柱为人数,折线为期末入住率(右轴)</span>
+      </div>
+      <Echart v-if="ready && items.length" :options="mainChartOption" height="380px" />
+      <el-empty
+        v-else
+        :description="ready ? '所选区间暂无数据' : '暂无数据:趋势接口待后端提供(check-trend)'"
+        :image-size="90"
+        class="chart-empty"
+      />
+    </ContentWrap>
+
+    <ContentWrap class="table-wrap">
+      <div class="panel-title">
+        区间明细
+        <span class="title-tip">恒等校验:期初在院 + 入住 − 退住 = 期末在院</span>
+      </div>
+      <el-table :data="items" border stripe>
+        <el-table-column prop="period" label="期间" min-width="110" align="center" />
+        <el-table-column
+          prop="beginInHospital"
+          label="期初在院(人)"
+          min-width="120"
+          align="center"
+        />
+        <el-table-column prop="checkInCount" label="入住(人)" min-width="100" align="center">
+          <template #default="{ row }">
+            <span class="num-up">{{ row.checkInCount }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="checkOutCount" label="退住(人)" min-width="100" align="center">
+          <template #default="{ row }">
+            <span class="num-down">{{ row.checkOutCount }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="净增(人)" min-width="100" align="center">
+          <template #default="{ row }">
+            <span :class="netClass(row.netGrowth)">{{ formatNet(row.netGrowth) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column
+          prop="endInHospital"
+          label="期末在院(人)"
+          min-width="120"
+          align="center"
+        />
+        <el-table-column label="期末入住率" min-width="110" align="center">
+          <template #default="{ row }">{{ formatRate(row.endOccupancyRate) }}</template>
+        </el-table-column>
+        <el-table-column label="勾稽" min-width="90" align="center">
+          <template #default="{ row }">
+            <el-tag
+              :type="
+                row.beginInHospital + row.checkInCount - row.checkOutCount === row.endInHospital
+                  ? 'success'
+                  : 'danger'
+              "
+              size="small"
+              effect="plain"
+            >
+              {{
+                row.beginInHospital + row.checkInCount - row.checkOutCount === row.endInHospital
+                  ? '平衡'
+                  : '异常'
+              }}
+            </el-tag>
+          </template>
+        </el-table-column>
+      </el-table>
+    </ContentWrap>
+
+    <ContentWrap class="chart-wrap">
+      <div class="panel-title">
+        近 12 个月已完成退住趋势
+        <span class="title-tip">现网真实数据(dynamics.retreatTrend,按所选院区合计)</span>
+      </div>
+      <Echart v-if="retreatItems.length" :options="retreatChartOption" height="300px" />
+      <el-empty v-else description="暂无退住数据" :image-size="90" class="chart-empty" />
+    </ContentWrap>
+  </div>
 </template>
 
-<script setup lang="ts">
-import ReportPlaceholder from '../../components/ReportPlaceholder.vue'
+<script lang="ts" setup>
+import { Download, RefreshLeft, Search } from '@element-plus/icons-vue'
+import Echart from '@/components/Echart/src/Echart.vue'
+import { exportWithCustomHeaders } from '@/utils/excel-export'
+import { fetchOverviewDynamics, getCampusOptions } from '../../overview/campus-data'
+import { fetchCheckTrend, type CheckTrendItem } from '../occupancy-api'
+
+defineOptions({ name: 'OaOccupancyCheckinTrend' })
+
+const message = useMessage()
+const campusOptions = getCampusOptions()
+
+const loading = ref(false)
+const selectedCampusIds = ref<number[]>(campusOptions.map((item) => item.value))
+const granularity = ref<'month' | 'day'>('month')
+const monthRange = ref<string[]>([])
+const dayRange = ref<string[]>([])
+
+/** 趋势数据(check-trend 未上线前为空) */
+const items = ref<CheckTrendItem[]>([])
+/** null=未加载,true=成功,false=接口不可用 */
+const ready = ref<boolean | null>(null)
+/** 现网真实退住趋势 */
+const retreatItems = ref<{ month: string; label: string; count: number }[]>([])
+
+const pad2 = (n: number) => String(n).padStart(2, '0')
+const fmtDate = (d: Date) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
+const fmtMonth = (d: Date) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`
+
+/** 默认时间范围:按月=近 12 个月;按日=近 30 天 */
+const setDefaultRange = () => {
+  const now = new Date()
+  if (granularity.value === 'month') {
+    const start = new Date(now.getFullYear(), now.getMonth() - 11, 1)
+    monthRange.value = [fmtMonth(start), fmtMonth(now)]
+  } else {
+    const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 29)
+    dayRange.value = [fmtDate(start), fmtDate(now)]
+  }
+}
+
+/** yyyy-MM → 该月最后一天 yyyy-MM-dd */
+const monthLastDay = (month: string) => {
+  const [y, m] = month.split('-').map(Number)
+  return fmtDate(new Date(y, m, 0))
+}
+
+const handleGranularityChange = () => {
+  setDefaultRange()
+}
+
+function formatRate(val: number | null | undefined) {
+  return val === null || val === undefined ? '-' : `${Number(val).toFixed(1)}%`
+}
+function formatNet(val: number) {
+  return val > 0 ? `+${val}` : `${val}`
+}
+function netClass(val: number) {
+  if (val > 0) return 'num-up'
+  if (val < 0) return 'num-down'
+  return 'num-zero'
+}
+
+/** 区间汇总 KPI */
+const kpiCards = computed(() => {
+  const list = items.value
+  const checkIn = list.reduce((acc, item) => acc + item.checkInCount, 0)
+  const checkOut = list.reduce((acc, item) => acc + item.checkOutCount, 0)
+  const net = checkIn - checkOut
+  const rates = list.map((item) => item.endOccupancyRate).filter((v) => v !== null) as number[]
+  const avgRate = rates.length ? rates.reduce((acc, v) => acc + v, 0) / rates.length : null
+  return [
+    {
+      label: '期初在院',
+      value: list.length ? list[0].beginInHospital : '-',
+      unit: '人',
+      icon: 'ep:flag',
+      theme: 'blue'
+    },
+    {
+      label: '期间入住',
+      value: list.length ? checkIn : '-',
+      unit: '人',
+      icon: 'ep:plus',
+      theme: 'green'
+    },
+    {
+      label: '期间退住',
+      value: list.length ? checkOut : '-',
+      unit: '人',
+      icon: 'ep:minus',
+      theme: 'orange'
+    },
+    {
+      label: '净增',
+      value: list.length ? formatNet(net) : '-',
+      unit: '人',
+      icon: 'ep:top-right',
+      theme: net >= 0 ? 'cyan' : 'red'
+    },
+    {
+      label: '期末在院',
+      value: list.length ? list[list.length - 1].endInHospital : '-',
+      unit: '人',
+      icon: 'ep:user',
+      theme: 'purple'
+    },
+    {
+      label: '平均入住率',
+      value: avgRate === null ? '-' : avgRate.toFixed(1),
+      unit: avgRate === null ? '' : '%',
+      icon: 'ep:data-line',
+      theme: 'grey'
+    }
+  ]
+})
+
+const mainChartOption = computed(() => {
+  const list = items.value
+  return {
+    tooltip: {
+      trigger: 'axis',
+      formatter: (params: any[]) => {
+        const item = list[params[0]?.dataIndex ?? 0]
+        if (!item) return ''
+        return `${item.period}<br/>期初 ${item.beginInHospital} 人<br/>入住 ${
+          item.checkInCount
+        } 人<br/>退住 ${item.checkOutCount} 人<br/>净增 ${formatNet(item.netGrowth)} 人<br/>期末 ${
+          item.endInHospital
+        } 人<br/>入住率 ${formatRate(item.endOccupancyRate)}`
+      }
+    },
+    legend: { data: ['入住', '退住', '净增', '期末入住率'], top: 0 },
+    grid: { left: 48, right: 56, top: 40, bottom: 30 },
+    xAxis: { type: 'category', data: list.map((item) => item.period) },
+    yAxis: [
+      { type: 'value', name: '人数' },
+      { type: 'value', name: '入住率', max: 100, axisLabel: { formatter: '{value}%' } }
+    ],
+    series: [
+      {
+        name: '入住',
+        type: 'bar',
+        data: list.map((item) => item.checkInCount),
+        itemStyle: { color: '#67c23a', borderRadius: [3, 3, 0, 0] }
+      },
+      {
+        name: '退住',
+        type: 'bar',
+        data: list.map((item) => item.checkOutCount),
+        itemStyle: { color: '#f56c6c', borderRadius: [3, 3, 0, 0] }
+      },
+      {
+        name: '净增',
+        type: 'bar',
+        data: list.map((item) => item.netGrowth),
+        itemStyle: { color: '#409eff', borderRadius: [3, 3, 0, 0] }
+      },
+      {
+        name: '期末入住率',
+        type: 'line',
+        yAxisIndex: 1,
+        connectNulls: false,
+        data: list.map((item) => item.endOccupancyRate),
+        smooth: true,
+        symbolSize: 7,
+        lineStyle: { width: 2.5, color: '#e6a23c' },
+        itemStyle: { color: '#e6a23c' }
+      }
+    ]
+  }
+})
+
+const retreatChartOption = computed(() => ({
+  tooltip: {
+    trigger: 'axis',
+    formatter: (params: any[]) => {
+      const item = retreatItems.value[params[0]?.dataIndex ?? 0]
+      return item ? `${item.month}:退住 ${item.count} 人` : ''
+    }
+  },
+  grid: { left: 48, right: 24, top: 30, bottom: 30 },
+  xAxis: {
+    type: 'category',
+    data: retreatItems.value.map((item) => item.month.slice(5)),
+    boundaryGap: false
+  },
+  yAxis: { type: 'value', name: '人', minInterval: 1 },
+  series: [
+    {
+      name: '退住人数',
+      type: 'line',
+      smooth: true,
+      symbolSize: 7,
+      data: retreatItems.value.map((item) => item.count),
+      lineStyle: { width: 2.5, color: '#f56c6c' },
+      itemStyle: { color: '#f56c6c' },
+      areaStyle: { color: 'rgba(245,108,108,0.12)' }
+    }
+  ]
+}))
+
+const handleQuery = async () => {
+  const ids = selectedCampusIds.value.length
+    ? selectedCampusIds.value
+    : campusOptions.map((item) => item.value)
+  if (!ids.length) {
+    items.value = []
+    ready.value = null
+    return
+  }
+  loading.value = true
+  try {
+    const range = granularity.value === 'month' ? monthRange.value : dayRange.value
+    const params: any = { tenantIds: ids, granularity: granularity.value }
+    if (range && range.length === 2) {
+      if (granularity.value === 'month') {
+        params.startDate = `${range[0].slice(0, 7)}-01`
+        params.endDate = monthLastDay(range[1].slice(0, 7))
+      } else {
+        params.startDate = range[0].slice(0, 10)
+        params.endDate = range[1].slice(0, 10)
+      }
+    } else if (granularity.value === 'month') {
+      params.months = 12
+    }
+    const [trend, overview] = await Promise.allSettled([
+      fetchCheckTrend(params),
+      fetchOverviewDynamics(ids)
+    ])
+    if (trend.status === 'fulfilled') {
+      items.value = trend.value
+      ready.value = true
+    } else {
+      items.value = []
+      ready.value = false
+    }
+    if (overview.status === 'fulfilled' && overview.value) {
+      retreatItems.value = overview.value.checkoutTrend
+    } else {
+      retreatItems.value = []
+    }
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleReset = () => {
+  selectedCampusIds.value = campusOptions.map((item) => item.value)
+  granularity.value = 'month'
+  setDefaultRange()
+  handleQuery()
+}
+
+const handleExport = () => {
+  exportWithCustomHeaders(
+    items.value,
+    [
+      { key: 'period', title: '期间', width: 12 },
+      { key: 'beginInHospital', title: '期初在院(人)', width: 14 },
+      { key: 'checkInCount', title: '入住(人)', width: 10 },
+      { key: 'checkOutCount', title: '退住(人)', width: 10 },
+      { key: 'netGrowth', title: '净增(人)', width: 10 },
+      { key: 'endInHospital', title: '期末在院(人)', width: 14 },
+      { key: 'endOccupancyRate', title: '期末入住率(%)', width: 14 }
+    ],
+    `入住退住趋势_${granularity.value === 'month' ? '按月' : '按日'}_${new Date()
+      .toISOString()
+      .slice(0, 10)}.xlsx`,
+    '入退趋势'
+  )
+  message.success('导出成功')
+}
+
+onMounted(() => {
+  setDefaultRange()
+  handleQuery()
+})
 </script>
+
+<style lang="scss" scoped>
+.oa-page {
+  .page-header {
+    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;
+      }
+    }
+  }
+
+  .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(6, 1fr);
+    gap: 12px;
+
+    @media (max-width: 1400px) {
+      grid-template-columns: repeat(3, 1fr);
+    }
+    @media (max-width: 768px) {
+      grid-template-columns: repeat(2, 1fr);
+    }
+
+    .kpi-card {
+      padding: 15px 16px;
+      border-radius: 8px;
+      color: #fff;
+
+      .kpi-label {
+        display: flex;
+        align-items: center;
+        font-size: 13px;
+
+        .el-icon {
+          margin-right: 6px;
+          font-size: 16px;
+        }
+      }
+
+      .kpi-value {
+        margin-top: 8px;
+        font-size: 28px;
+        font-weight: 700;
+        line-height: 1.1;
+
+        .kpi-unit {
+          margin-left: 4px;
+          font-size: 13px;
+          font-weight: 400;
+          opacity: 0.85;
+        }
+      }
+
+      &.blue {
+        background: linear-gradient(135deg, #5b9bff 0%, #2f7cf6 100%);
+      }
+      &.green {
+        background: linear-gradient(135deg, #62d391 0%, #36a866 100%);
+      }
+      &.cyan {
+        background: linear-gradient(135deg, #4fd0e0 0%, #1fa9bf 100%);
+      }
+      &.orange {
+        background: linear-gradient(135deg, #ffb066 0%, #f5851d 100%);
+      }
+      &.purple {
+        background: linear-gradient(135deg, #b794f6 0%, #8b5cf6 100%);
+      }
+      &.grey {
+        background: linear-gradient(135deg, #a8abb2 0%, #7a7f8a 100%);
+      }
+      &.red {
+        background: linear-gradient(135deg, #ff8f8f 0%, #f56c6c 100%);
+      }
+    }
+  }
+
+  .chart-wrap,
+  .table-wrap {
+    margin-top: 12px;
+  }
+
+  .chart-empty {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 320px;
+  }
+
+  .panel-title {
+    position: relative;
+    padding-left: 10px;
+    margin-bottom: 12px;
+    font-size: 15px;
+    font-weight: 600;
+    color: #303133;
+
+    &::before {
+      content: '';
+      position: absolute;
+      left: 0;
+      top: 2px;
+      width: 4px;
+      height: 16px;
+      background: #409eff;
+      border-radius: 2px;
+    }
+
+    .title-tip {
+      margin-left: 10px;
+      font-size: 12px;
+      font-weight: 400;
+      color: #a8abb2;
+    }
+  }
+
+  .num-up {
+    color: #67c23a;
+    font-weight: 600;
+  }
+  .num-down {
+    color: #f56c6c;
+    font-weight: 600;
+  }
+  .num-zero {
+    color: #909399;
+    font-weight: 600;
+  }
+}
+</style>

+ 704 - 3
src/views/operation-analysis/occupancy/elder-structure/index.vue

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

+ 435 - 3
src/views/operation-analysis/occupancy/empty-bed/index.vue

@@ -1,7 +1,439 @@
 <template>
-  <ReportPlaceholder status="ready" desc="基于 build/queryBuildRooms,按楼层/房型/朝向等筛选空床并导出。" />
+  <div v-loading="loading" class="oa-page">
+    <ContentWrap>
+      <div class="page-header">
+        <div class="header-title">
+          <Icon icon="ep:house" class="title-icon" />
+          <span>空床明细报表</span>
+        </div>
+      </div>
+
+      <!-- 筛选区 -->
+      <el-form :inline="true" class="filter-form" @submit.prevent>
+        <el-form-item label="院区">
+          <el-select
+            v-model="queryCampusIds"
+            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="queryBuildKeys"
+            multiple
+            collapse-tags
+            collapse-tags-tooltip
+            clearable
+            filterable
+            placeholder="全部楼栋"
+            class="build-select"
+          >
+            <el-option
+              v-for="item in buildOptions"
+              :key="item.key"
+              :label="item.label"
+              :value="item.key"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="床位状态">
+          <el-select v-model="queryStatus" class="status-select">
+            <el-option label="空床" :value="0" />
+            <el-option label="在住" :value="1" />
+            <el-option label="全部" :value="-1" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="房号/床号">
+          <el-input
+            v-model="queryBedName"
+            placeholder="房间号或床位号关键字"
+            clearable
+            class="keyword-input"
+            @keyup.enter="handleQuery"
+          />
+        </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="!filteredRows.length"
+            @click="handleExport"
+          >
+            导出 Excel
+          </el-button>
+        </el-form-item>
+      </el-form>
+
+      <el-alert
+        type="info"
+        :closable="false"
+        show-icon
+        class="api-tip"
+        title="当前数据来自现有床位接口(按院区实时统计):房间区域/朝向/房型/房间配置等筛选与服务端分页导出待后端接口增强,详见《入住运营报表-设计与后端接口需求》接口 B。"
+      />
+
+      <!-- 汇总条 -->
+      <div class="summary-grid">
+        <div class="summary-card blue">
+          <div class="s-label"><Icon icon="ep:moon" />空床总数</div>
+          <div class="s-value">{{ summary.emptyCount }}<span class="s-unit">张</span></div>
+        </div>
+        <div class="summary-card cyan">
+          <div class="s-label"><Icon icon="ep:office-building" />涉及空床房间</div>
+          <div class="s-value">{{ summary.emptyRoomCount }}<span class="s-unit">间</span></div>
+        </div>
+        <div class="summary-card green">
+          <div class="s-label"><Icon icon="ep:home-filled" />整间空房</div>
+          <div class="s-value">{{ summary.fullEmptyRoomCount }}<span class="s-unit">间</span></div>
+        </div>
+        <div class="summary-card orange">
+          <div class="s-label"><Icon icon="ep:files" />当前筛选结果</div>
+          <div class="s-value">{{ filteredRows.length }}<span class="s-unit">条</span></div>
+        </div>
+      </div>
+    </ContentWrap>
+
+    <ContentWrap class="table-wrap">
+      <div class="panel-title">
+        床位明细
+        <span class="title-tip">按 院区 / 楼栋 / 楼层 / 房间 排序,默认仅展示空床</span>
+      </div>
+      <el-table :data="pagedRows" border stripe v-loading="loading">
+        <el-table-column type="index" label="序号" width="60" align="center" />
+        <el-table-column prop="tenantName" label="机构" min-width="140" show-overflow-tooltip />
+        <el-table-column prop="buildName" label="楼栋" min-width="130" show-overflow-tooltip />
+        <el-table-column prop="floorName" label="楼层" min-width="90" />
+        <el-table-column prop="roomName" label="房间号" min-width="100" />
+        <el-table-column prop="bedName" label="床位号" min-width="90" />
+        <el-table-column label="房间床位" width="90" align="center">
+          <template #default="{ row }">{{ roomInfo(row).used }}/{{ roomInfo(row).total }}</template>
+        </el-table-column>
+        <el-table-column label="状态" width="90" align="center">
+          <template #default="{ row }">
+            <el-tag :type="row.status === 0 ? 'warning' : 'success'" effect="light">
+              {{ row.status === 0 ? '空床' : '在住' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+      </el-table>
+      <div class="pager">
+        <el-pagination
+          v-model:current-page="pageNo"
+          v-model:page-size="pageSize"
+          :page-sizes="[20, 50, 100, 200]"
+          :total="filteredRows.length"
+          layout="total, sizes, prev, pager, next, jumper"
+          background
+        />
+      </div>
+    </ContentWrap>
+  </div>
 </template>
 
-<script setup lang="ts">
-import ReportPlaceholder from '../../components/ReportPlaceholder.vue'
+<script lang="ts" setup>
+import { Download, RefreshLeft, Search } from '@element-plus/icons-vue'
+import { getBuildList } from '@/api/system/badManage'
+import { exportWithCustomHeaders } from '@/utils/excel-export'
+import { getCampusOptions } from '../../overview/campus-data'
+import { fetchEmptyBedRows, type EmptyBedRow } from '../occupancy-api'
+
+defineOptions({ name: 'OaOccupancyEmptyBed' })
+
+const message = useMessage()
+const campusOptions = getCampusOptions()
+
+const loading = ref(false)
+const queryCampusIds = ref<number[]>(campusOptions.map((item) => item.value))
+const queryBuildKeys = ref<string[]>([])
+const queryStatus = ref(0)
+const queryBedName = ref('')
+
+const allRows = ref<EmptyBedRow[]>([])
+const buildOptions = ref<{ key: string; label: string }[]>([])
+const pageNo = ref(1)
+const pageSize = ref(20)
+
+/** 楼栋下拉:所选院区的全部楼栋(key=机构id::楼栋名) */
+const loadBuildOptions = async () => {
+  const ids = queryCampusIds.value.length
+    ? queryCampusIds.value
+    : campusOptions.map((item) => item.value)
+  const nameMap = new Map(campusOptions.map((item) => [item.value, item.label]))
+  const groups = await Promise.all(
+    ids.map(async (id) => {
+      try {
+        const list = await getBuildList({ tenantIds: id })
+        return (list || []).map((b: any) => ({
+          key: `${id}::${b.buildName}`,
+          label: `${nameMap.get(id) || id} / ${b.buildName}`
+        }))
+      } catch {
+        return []
+      }
+    })
+  )
+  buildOptions.value = groups.flat()
+  queryBuildKeys.value = queryBuildKeys.value.filter((key) =>
+    buildOptions.value.some((item) => item.key === key)
+  )
+}
+
+/** 行 → 所属房间 key */
+const roomKeyOf = (row: EmptyBedRow) =>
+  `${row.tenantId}::${row.buildName}::${row.floorName}::${row.roomName}`
+
+/** 房间床位占用情况(基于空+在住全量行计算) */
+const roomStatMap = computed(() => {
+  const map = new Map<string, { total: number; used: number }>()
+  allRows.value.forEach((row) => {
+    const key = roomKeyOf(row)
+    const stat = map.get(key) || { total: 0, used: 0 }
+    stat.total += 1
+    if (row.status === 1) stat.used += 1
+    map.set(key, stat)
+  })
+  return map
+})
+
+const roomInfo = (row: EmptyBedRow) =>
+  roomStatMap.value.get(roomKeyOf(row)) || { total: 0, used: 0 }
+
+/** 当前筛选条件下的行(状态/楼栋在前端过滤) */
+const filteredRows = computed(() => {
+  let list = allRows.value
+  if (queryStatus.value !== -1) list = list.filter((row) => row.status === queryStatus.value)
+  if (queryBuildKeys.value.length) {
+    const keySet = new Set(queryBuildKeys.value)
+    list = list.filter((row) => keySet.has(`${row.tenantId}::${row.buildName}`))
+  }
+  return [...list].sort((a, b) =>
+    `${a.tenantName}${a.buildName}${a.floorName}${a.roomName}${a.bedName}`.localeCompare(
+      `${b.tenantName}${b.buildName}${b.floorName}${b.roomName}${b.bedName}`
+    )
+  )
+})
+
+const pagedRows = computed(() =>
+  filteredRows.value.slice((pageNo.value - 1) * pageSize.value, pageNo.value * pageSize.value)
+)
+
+/** 汇总恒以“空床”口径统计,不受床位状态筛选影响 */
+const summary = computed(() => {
+  const emptyRows = allRows.value.filter((row) => row.status === 0)
+  const emptyRooms = new Set<string>()
+  const fullEmptyRooms = new Set<string>()
+  emptyRows.forEach((row) => {
+    const key = roomKeyOf(row)
+    emptyRooms.add(key)
+    const info = roomStatMap.value.get(key)
+    if (info && info.used === 0) fullEmptyRooms.add(key)
+  })
+  return {
+    emptyCount: emptyRows.length,
+    emptyRoomCount: emptyRooms.size,
+    fullEmptyRoomCount: fullEmptyRooms.size
+  }
+})
+
+const handleQuery = async () => {
+  loading.value = true
+  pageNo.value = 1
+  try {
+    const ids = queryCampusIds.value.length
+      ? queryCampusIds.value
+      : campusOptions.map((item) => item.value)
+    allRows.value = await fetchEmptyBedRows(ids, queryBedName.value.trim())
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleReset = () => {
+  queryCampusIds.value = campusOptions.map((item) => item.value)
+  queryBuildKeys.value = []
+  queryStatus.value = 0
+  queryBedName.value = ''
+  loadBuildOptions()
+  handleQuery()
+}
+
+const handleExport = () => {
+  exportWithCustomHeaders(
+    filteredRows.value.map((row) => ({
+      ...row,
+      statusText: row.status === 0 ? '空床' : '在住',
+      roomBedText: `${roomInfo(row).used}/${roomInfo(row).total}`
+    })),
+    [
+      { key: 'tenantName', title: '机构', width: 18 },
+      { key: 'buildName', title: '楼栋', width: 16 },
+      { key: 'floorName', title: '楼层', width: 10 },
+      { key: 'roomName', title: '房间号', width: 12 },
+      { key: 'bedName', title: '床位号', width: 10 },
+      { key: 'roomBedText', title: '房间床位(在住/总数)', width: 16 },
+      { key: 'statusText', title: '状态', width: 8 }
+    ],
+    `空床明细_${new Date().toISOString().slice(0, 10)}.xlsx`,
+    '空床明细'
+  )
+  message.success('导出成功')
+}
+
+watch(queryCampusIds, () => loadBuildOptions())
+
+onMounted(async () => {
+  await loadBuildOptions()
+  handleQuery()
+})
 </script>
+
+<style lang="scss" scoped>
+.oa-page {
+  .page-header {
+    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;
+      }
+    }
+  }
+
+  .filter-form {
+    margin-top: 4px;
+
+    .campus-select {
+      width: 280px;
+      max-width: 52vw;
+    }
+    .build-select {
+      width: 240px;
+      max-width: 48vw;
+    }
+    .status-select {
+      width: 100px;
+    }
+    .keyword-input {
+      width: 200px;
+    }
+  }
+
+  .api-tip {
+    margin: 4px 0 14px;
+  }
+
+  .summary-grid {
+    display: grid;
+    grid-template-columns: repeat(4, 1fr);
+    gap: 12px;
+
+    @media (max-width: 992px) {
+      grid-template-columns: repeat(2, 1fr);
+    }
+
+    .summary-card {
+      padding: 14px 18px;
+      border-radius: 8px;
+      color: #fff;
+
+      .s-label {
+        display: flex;
+        align-items: center;
+        font-size: 13px;
+        opacity: 0.95;
+
+        .el-icon {
+          margin-right: 6px;
+          font-size: 16px;
+        }
+      }
+
+      .s-value {
+        margin-top: 8px;
+        font-size: 28px;
+        font-weight: 700;
+        line-height: 1.1;
+
+        .s-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%);
+      }
+      &.green {
+        background: linear-gradient(135deg, #62d391 0%, #36a866 100%);
+      }
+      &.orange {
+        background: linear-gradient(135deg, #ffb066 0%, #f5851d 100%);
+      }
+    }
+  }
+
+  .table-wrap {
+    margin-top: 12px;
+  }
+
+  .pager {
+    display: flex;
+    justify-content: flex-end;
+    margin-top: 14px;
+  }
+
+  .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>

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

@@ -0,0 +1,181 @@
+import request from '@/config/axios'
+import { getCampusOptions } from '../overview/campus-data'
+
+/**
+ * 入住运营报表共享数据层
+ * - 空床明细:复用现有 build/getBedList(2026-09-20 集团账号实测契约见下)
+ * - 入退趋势:待后端实现 /operation-analysis/overview/check-trend(契约见同目录 md)
+ * - 长者结构:复用现有 elderlyInfo/findPage 全量在院长者前端聚合
+ */
+
+/** build/getBedList 单条原始记录(实测:扁平数组,无分页,仅 5 个字段) */
+export interface BedListRawVO {
+  buildName: string
+  buildFloorName: string
+  buildRoomName: string
+  buildBedId: number
+  buildBedName: string
+}
+
+/** 空床明细行(前端补齐机构名与状态后供表格使用) */
+export interface EmptyBedRow {
+  tenantId: number
+  tenantName: string
+  buildName: string
+  floorName: string
+  roomName: string
+  bedId: number
+  bedName: string
+  /** 床位状态:0=空床,1=在住 */
+  status: number
+}
+
+/**
+ * 空床/床位明细:
+ *
+ * 现有 GET /build/getBedList 实测行为(2026-09-20,集团账号 tenant-id=1):
+ * - 必填 bedName(可空串)与 tenantIds;支持多个 tenantIds;
+ * - status=0 空床 / 1 在住 / 不传全部;pageNo、pageSize 不生效,直接返回全量扁平数组;
+ * - 每条仅 5 个字段(楼栋/楼层/房间名称 + 床id/床名),无状态、无机构 id/名称、无房间属性;
+ * 因此这里按机构分别请求、按 status=0/1 请求两次,在前端补上机构名与状态。
+ *
+ * @param tenantIds 机构 id 集合
+ * @param bedName 房号/床号关键字(后端模糊匹配,可空)
+ */
+export const fetchEmptyBedRows = async (
+  tenantIds: number[],
+  bedName = ''
+): Promise<EmptyBedRow[]> => {
+  const nameMap = new Map(getCampusOptions().map((item) => [item.value, item.label]))
+  // 注意:该接口后端用 @RequestParam List 接收,实测只认重复键(tenantIds=1&tenantIds=2),
+  // qs 默认的 tenantIds[0]= 形式会报“请求参数缺失:tenantIds”,因此手动拼 URL。
+  const buildUrl = (tenantId: number, status: number) =>
+    `/build/getBedList?bedName=${encodeURIComponent(bedName)}&status=${status}&tenantIds=${tenantId}&orgType=1`
+  const tasks: Promise<EmptyBedRow[]>[] = []
+  tenantIds.forEach((tenantId) => {
+    ;[0, 1].forEach((status) => {
+      tasks.push(
+        request
+          .get({
+            url: buildUrl(tenantId, status)
+          })
+          .then((list: BedListRawVO[]) =>
+            (Array.isArray(list) ? list : []).map((item) => ({
+              tenantId,
+              tenantName: nameMap.get(tenantId) || `院区${tenantId}`,
+              buildName: item.buildName || '',
+              floorName: item.buildFloorName || '',
+              roomName: item.buildRoomName || '',
+              bedId: item.buildBedId,
+              bedName: item.buildBedName || '',
+              status
+            }))
+          )
+          .catch(() => [])
+      )
+    })
+  })
+  const groups = await Promise.all(tasks)
+  return groups.flat()
+}
+
+/** 入退趋势一行(契约同运营总览二期 check-trend) */
+export interface CheckTrendItem {
+  /** yyyy-MM(月)或 yyyy-MM-dd(日) */
+  period: string
+  beginInHospital: number
+  checkInCount: number
+  checkOutCount: number
+  netGrowth: number
+  endInHospital: number
+  /** 期末入住率 %,无法回溯的历史月份为 null */
+  endOccupancyRate: number | null
+}
+
+export interface CheckTrendParams {
+  tenantIds: number[]
+  /** month=按月(默认),day=按日 */
+  granularity?: 'month' | 'day'
+  startDate?: string
+  endDate?: string
+  /** 不传区间且按月时生效,默认 12 */
+  months?: number
+}
+
+/**
+ * 入住/退住/净增趋势:GET /operation-analysis/overview/check-trend
+ * 后端尚未实现(2026-09-20),失败由调用方按“待后端接口”空态处理。
+ */
+export const fetchCheckTrend = async (params: CheckTrendParams): Promise<CheckTrendItem[]> => {
+  const data = await request.get({
+    url: '/operation-analysis/overview/check-trend',
+    params: { orgType: 1, ...params }
+  })
+  return Array.isArray(data?.items) ? data.items : []
+}
+
+/** 在院长者档案行(findPage 字段裁剪,仅保留结构分析所需字段) */
+export interface InHospitalElderVO {
+  id: number
+  elderName: string
+  /** 1=男,2=女 */
+  elderSex: number | null
+  elderAge: number | null
+  nurseLevelId: number | null
+  nurseLevelName: string | null
+  /** 文化程度字典 degree_education_arr 值 */
+  degreeEducation: number | null
+  /** 缴费方式字典 payment_method_arr 值 */
+  paymentMethod: number | null
+  /** 照护类型字典 care_type 值 */
+  careType: number | null
+  /** 入住时间(毫秒时间戳) */
+  checkInTime: number | null
+  tenantId: number | null
+  tenantName: string | null
+}
+
+/**
+ * 拉取所选机构的全部在院长者(inStatus=1),用于长者结构分析前端聚合。
+ *
+ * 现有 GET /elderlyInfo/findPage 实测(2026-09-20 集团账号):
+ * - tenantIds 支持多个机构(与 getBedList 一样用重复键拼 URL);
+ * - pageSize=500 生效但单页较重(500 条约 8.5s,返回字段多),故首页拿 total 后并发拉剩余页;
+ * - 字段齐全:elderSex/elderAge/nurseLevelName/degreeEducation/paymentMethod/checkInTime 等。
+ * 待后端提供聚合接口(文档接口 D)后可替换为一次请求。
+ */
+export const fetchInHospitalElders = async (tenantIds: number[]): Promise<InHospitalElderVO[]> => {
+  const tenantPart = tenantIds.map((id) => `tenantIds=${id}`).join('&')
+  const PAGE_SIZE = 500
+  const urlOf = (pageNo: number) =>
+    `/elderlyInfo/findPage?pageNo=${pageNo}&pageSize=${PAGE_SIZE}&inStatus=1&${tenantPart}&orgType=1`
+
+  const first: any = await request.get({ url: urlOf(1) })
+  const total: number = Number(first?.total) || 0
+  const firstList: any[] = Array.isArray(first?.list) ? first.list : []
+  if (!total || firstList.length === 0) return []
+
+  const pageCount = Math.ceil(total / PAGE_SIZE)
+  const rest: any[][] = await Promise.all(
+    Array.from({ length: pageCount - 1 }, (_, idx) =>
+      request
+        .get({ url: urlOf(idx + 2) })
+        .then((res: any) => (Array.isArray(res?.list) ? res.list : []))
+        .catch(() => [])
+    )
+  )
+  return [...firstList, ...rest.flat()].map((r: any) => ({
+    id: r.id,
+    elderName: r.elderName || '',
+    elderSex: r.elderSex ?? null,
+    elderAge: r.elderAge ?? null,
+    nurseLevelId: r.nurseLevelId ?? null,
+    nurseLevelName: r.nurseLevelName || '',
+    degreeEducation: r.degreeEducation ?? null,
+    paymentMethod: r.paymentMethod ?? null,
+    careType: r.careType ?? null,
+    checkInTime: r.checkInTime ?? null,
+    tenantId: r.tenantId ?? null,
+    tenantName: r.tenantName || ''
+  }))
+}

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

@@ -0,0 +1,146 @@
+# 入住运营报表 — 页面设计与后端接口需求
+
+> 模块:运营分析 / 入住运营 | 2026-09-20~21 | 前端 4 页均已上线(现有接口过渡,待后端接口 A~D 增强)
+
+| # | 报表 | 路由 |
+| --- | --- | --- |
+| 1 | 入住率与床位利用分析 | `/operation-analysis/occupancy/bed-utilization` |
+| 2 | 空床明细报表 | `/operation-analysis/occupancy/empty-bed` |
+| 3 | 入住退住趋势与净增 | `/operation-analysis/occupancy/checkin-trend` |
+| 4 | 长者结构分析 | `/operation-analysis/occupancy/elder-structure` |
+
+全局口径:入住率 = 在住床位 ÷ 总床位 ×100%(1 位小数),临时占用床单独统计、不计入在住,总床位 0 时返回 null;入住/退住以实际完成办理、档案转为在院/离院为准(不含试住、预订),与驾驶舱一致。
+
+---
+
+## 〇、现有接口满足度
+
+| 现有接口 | 报表 1 | 报表 2 | 报表 3 | 报表 4 |
+| --- | --- | --- | --- | --- |
+| `GET /operation-analysis/overview/dynamics`(总床/在住/空闲/临时/入住率、retreatTrend 近12月退住) | ✅ KPI/院区对比/结构 | — | ⚠️ 仅退住 | ⚠️ 护理等级 |
+| `GET /build/getList` / `getBuildData` / `getBuildFloorData`(楼栋树、单楼/单层统计) | ✅ 树表懒加载 | ✅ 楼栋选择 | — | — |
+| `GET /build/getBedList`(扁平床位,多机构、status 过滤、无分页、仅 5 字段,实测见附录) | — | ✅ 已上线 | — | — |
+| `POST /build/queryBuildRooms`(仅单机构+单楼层房态图) | — | ❌ 不可聚合 | — | — |
+| `GET /elderlyInfo/findPage`(长者档案分页,多机构、字段全,实测见附录) | — | — | — | ✅ 已上线 |
+| 历史入住率快照 / 入退趋势统计 | ❌ | — | ❌ | — |
+
+待后端接口:**A** 楼栋楼层聚合(可选)、**B** 空床明细分页+属性+导出、**C** 入退趋势 check-trend、**D** 长者结构聚合(B/C/D 非阻塞,前端现有方案数据准确)。
+
+---
+
+## 一、报表 1:入住率与床位利用分析
+
+页面(已上线):院区多选(默认全选)→ 5 张 KPI(总床/在住/空闲/临时/入住率);各院区入住率横向条形图(升序,≥85 绿/≥60 蓝/否则橙);床位状态环形图;入住率月度趋势(接口 C 未上线前空态);院区→楼栋→楼层三级懒加载树表(房间数/总床/在住/空闲/临时/入住率/利用率,利用率=(在住+临时)/总床)。
+
+数据源:dynamics + getList/getBuildData/getBuildFloorData。缺字段:**整间空房数**。
+
+### 接口 A:楼栋/楼层床位统计聚合(可选)
+
+`GET /build/statistics`,参数 `tenantIds?:Long[]`、`buildIds?:Long[]`、`orgType=1`;返回 `data.items`:
+
+| 字段 | 说明 |
+| --- | --- |
+| tenantId/tenantName、buildId/buildName、floorId/floorName、level(1楼/2层) | 层级 |
+| totalRoomCount、emptyRoomCount | 房间总数、**整间空房数**(房内全部床位空闲) |
+| totalBedCount、occupiedBedCount、emptyBedCount、temporaryOccupationCount | 床位 |
+| occupancyRate、utilizationRate | 入住率 %、利用率 %=(occupied+temporary)/total |
+
+> 不做聚合接口也请在 `getBuildData`/`getBuildFloorData` 响应补 `emptyRoomCount`。
+
+---
+
+## 二、报表 2:空床明细报表
+
+页面(已上线):院区/楼栋多选、床位状态(空/在/全部)、房号床号关键字;4 张汇总卡(空床总数/涉及空床房间/整间空房/筛选结果,汇总恒按空床统计);表格列房间 已住/总数(前端按 roomKey 从空+在两态计算);前端分页 + Excel 导出。
+
+现方案:getBedList 按"机构 × 状态"逐对请求(15 院区=30 请求),前端补机构名/状态。待接口 B 降为 1 次并补房间属性筛选(区域 `room_area_arr`、房型 `elderly_health_status`、朝向 `room_orientation`、房间配置)。
+
+### 接口 B:空床明细分页 + 导出
+
+优先增强 `GET /build/getBedList`,也可新建 `/build/empty-bed/page`,契约一致。
+
+参数:`pageNo/pageSize`(上限 200)、`tenantIds:Long[]`(多机构)、`buildIds`、`floodIds`(沿用 flood 拼写)、`status`(默认 0 空床,空=全部)、`roomAreaArray`、`elderTypeArray`、`orientationArray`、`roomSettingsArray`、`bedName`(模糊)、`orgType=1`。
+
+响应标准分页 `data:{total,list[]}`,行粒度一张床,每行冗余:
+
+```
+bedId/bedName/bedStatus、roomId/roomName/roomArea/orientation/roomSettings/roomUsage/roomBedCount/roomUsedCount、
+floorId/floorName、buildId/buildName、tenantId/tenantName、remark
+```
+
+权限按登录人机构范围过滤,超范围 tenantIds 忽略。汇总(可选)`GET /build/empty-bed/summary` 入参同筛选返回 `{emptyBedCount, emptyRoomCount, fullEmptyRoomCount}`;导出 `GET /build/empty-bed/export-excel` 列同表格。
+
+---
+
+## 三、报表 3:入住退住趋势与净增
+
+页面(已上线):院区多选;按月/按日(月默认近 12 月、日默认近 30 天,上限 366 天);6 张 KPI(期初/入住/退住/净增/期末/平均入住率);组合图(入住绿柱/退住红柱/净增蓝柱 + 入住率橙折线右轴);明细表带恒等式勾稽列(期初+入−出=期末);Excel 导出。接口 C 未上线时全页空态+提示,底部近 12 月退住趋势用 dynamics 真实数据。
+
+### 接口 C:入住/退住趋势
+
+`GET /operation-analysis/overview/check-trend`(与运营总览二期同一接口,驾驶舱传 `months=12&granularity=month`)
+
+参数:`tenantIds?:Long[]`、`granularity`(month 默认/day)、`startDate`+`endDate`(yyyy-MM-dd,day 必填)、`months`(默认 12)、`orgType=1`。
+
+`data.items[]` 升序、无数据期间补零:
+
+| 字段 | 说明 |
+| --- | --- |
+| period | yyyy-MM / yyyy-MM-dd |
+| beginInHospital、checkInCount、checkOutCount、netGrowth | 期初在院、入住、退住、净增 |
+| endInHospital | 期末在院(满足 期初+入−出=期末) |
+| endOccupancyRate | 期末入住率 %;无历史快照的月份返回 null,接口上线当月起准确 |
+
+导出由前端用 items 生成,不需要后端导出接口。
+
+---
+
+## 四、报表 4:长者结构分析
+
+页面(已上线):院区多选(默认全选);6 张 KPI(在院总数/平均年龄/男(占比)/女(占比)/90+人数/平均入住月数);6 张图:性别环形、年龄段柱(<60/60-69/70-79/80-89/90-99/≥100)、护理等级 TOP10 横向柱、入住时长柱(<1年/1-3/3-5/5-10/≥10年)、文化程度(字典 `degree_education_arr`)、缴费方式(字典 `payment_method_arr`);各院区明细表(人数/男女/男性占比/平均年龄/80+/平均入住月数/主要护理等级)+ Excel 导出。口径:仅 inStatus=1,空值归"未设置"但计入总数。
+
+现方案:findPage 全量在院长者(pageSize=500 并发翻页)前端聚合,3060 人约 10s。待接口 D 降为 1 次轻请求。
+
+### 接口 D:长者结构聚合
+
+`GET /operation-analysis/overview/elder-structure`,参数 `tenantIds?:Long[]`、`orgType=1`。返回 `data`:
+
+| 字段 | 说明 |
+| --- | --- |
+| total、maleCount/femaleCount、avgAge、over80Count/over90Count、avgStayMonths | 汇总(均值 1 位小数) |
+| sexBuckets、ageBuckets、stayBuckets | `{label,count}[]`,分段骨架固定、count=0 也返回 |
+| nurseLevelBuckets | `{nurseLevelId,nurseLevelName,count}[]` 降序 |
+| educationBuckets、paymentBuckets | `{value,label,count}[]`,**value 返回字典原值**由前端翻译 |
+| campusStats | 每院区 `{tenantId,tenantName,total,maleCount,femaleCount,avgAge,over80Count,avgStayMonths,mainNurseLevelName}` |
+
+导出由前端完成,不需要后端导出接口。
+
+---
+
+## 验收要点
+
+1. 四报表与驾驶舱同口径可对账:2026-09-20 集团实测 dynamics 在住床位 3062 / findPage 在院长者 3060(差 2 为档案在院未占床口径,页面注明);
+2. 恒等式 期初+入−出=期末 成立;空床数与 dynamics 空闲床位一致(实测 1244);
+3. 长者结构各分段人数之和 = 在院总数(未设置单列不丢数);
+4. 集团账号/单机构账号两种权限下筛选范围正确;大数据量导出不超时。
+
+---
+
+## 附录:现有接口实测(2026-09-20,集团账号 tenant-id=1,生产库,辖区 15 机构 196~211)
+
+**`GET /build/getBedList`**
+
+- 必传 `bedName`(可空串)+ `tenantIds`;多机构**只认重复键** `tenantIds=196&tenantIds=197`,qs indices 形式报"参数缺失"(前端需手动拼 URL);
+- status:0 空 / 1 在 / 不传全部(196+197:29/591/620,自洽);pageNo/pageSize 被忽略,返回全量;
+- 每条仅 `{buildName, buildFloorName, buildRoomName, buildBedId, buildBedName}`,无机构名/状态/房间属性;越权 tenant-id 返回 403。
+
+**床位统计接口**:`getList?tenantIds=196` ✅ 楼栋树含 floorList;`getBuildData?buildId=116` ✅ `{totalRoomCount:12,totalBedCount:50,usedBedCount:39,unusedBedCount:11,temporaryOccupationCount:0}`;`getBuildFloorData?floodId=423` ✅ 同结构(无床位楼层全 0);`getBuildFloorStatistics` 500 不可用;`queryBuildRooms` 多机构/不传 floodId 返回 []。
+
+**`GET /elderlyInfo/findPage`**
+
+- 不传 tenantIds 只回集团本部 102 条;重复键传 15 机构:inStatus=1 在院 **3060**、不传状态全档案 4070;
+- pageSize=500 生效,单页约 8.5s(100+ 冗余字段),7 页并发全量约 10s;
+- 可用字段:elderSex(1男2女)、elderAge、birthday、nurseLevelId/Name、degreeEducation、paymentMethod、careType、checkInTime/checkOutTime、tenantId/tenantName;
+- 实测聚合:平均 80.7 岁、男 1127(36.8%)/女 1933、90+ 501 人、平均入住 26.4 月;颐年天河 727 人(男 235/女 492、81.3 岁、主要等级一级护理 452 人)。
+
+**全集团对账**:总床位 4306 / 在住 3062 / 空闲 1244 / 入住率 71.1%;空床涉及房间 656、整间空房 358(前端按 roomKey 计算)。