소스 검색

增加临时医嘱2

chenjun 1 개월 전
부모
커밋
882932527d

+ 87 - 0
.workbuddy/memory/2026-08-26.md

@@ -265,3 +265,90 @@
      - `handleSave` → `updateTenant({ ...currentTenant.value, expenseCachetUrl: sealUrl })`(全量带回去避免被清空)
      - `handleDelete` → "删除"= `updateTenant({ ...currentTenant.value, expenseCachetUrl: '' })` 把字段清空
 - 范式总结:后端字段挂在通用实体(如 `tenant.*`)而非专用接口时,**扩展既有类型 + 全量带回其他字段** 比新建专用 API/页面更稳
+
+### 入住办理 fixed 项目删除后合计变空(2026-08-26 16:36)
+- 用户截图:月固定收费区点击任意 X 删除某项 → 「月固定金额合计 ¥元」显示空
+- 文件:`src/views/elderly/apply/check-in/ProcessForm.vue`
+- 根因(两层):
+  1. **直接原因**:`totalMonthFee` computed 直接 `total += Number(item.totalAmount)`,无 NaN 防护。任一项 totalAmount 是 undefined/NaN → `Number(undefined) = NaN` → `total` 立即被污染 → `formatNum(NaN)` 返回 '' → 合计显示空
+  2. **诱导原因**:`init()` 里 `arr[0] = { ...arr[0], ...item }` 用后端数据覆盖 arr 元素,spread 顺序导致 `item.totalAmount = undefined` 时把默认值 0 覆盖成 undefined
+  3. **触发时机**:用户 X 删除某项 → Vue 3 reactive array splice → computed 重新求值 → 累加时撞上某项 undefined 一击毙命
+- 修复(两层):
+  - `totalMonthFee` 加 `safeAdd(v)` helper:`Number.isFinite(Number(v)) ? Number(v) : 0`,跳过 NaN/undefined/字符串
+  - `init()` 4 处 arr 覆盖加 `totalAmount: Number.isFinite(Number(item.totalAmount)) ? Number(item.totalAmount) : 0` 强制归一
+- ⚠️ 隐患未动:`submitForm()` 里有 `arr.splice(3, 1)`(line 985-988)物理删除服务费项,破坏 UI 数据源完整性,只在用户没填服务费时触发。建议后续改成"标记跳过"而非数组删除
+- 范式:**computed 累加一定要 NaN 防护**。`formatNum` 对 NaN/不合法值统一返回 '',导致一点点 NaN 就让整列合计为空,肉眼上跟"删除某项后合计消失"非常像
+
+### 对账单预览加机构公章(2026-08-26 16:08)
+- 用户:在 `src/views/elderly/fee/bill-pay/preview.vue` 的对账单预览**右下角**(表格尾部,红色框区域)加公章图
+- 改动:
+  1. import:`getTenant` from `@/api/system/tenant`、`getTenantId` from `@/utils/auth`
+  2. 加 `sealUrl = ref('')`
+  3. `open()` 改用 `Promise.all([getExpenseOrderDetail(id), getTenant(tenantId).catch(() => null)])` 并行拉取;公章取 `tenant.expenseCachetUrl`,接口失败容错
+  4. `handleClosed()` 关闭时清空 `sealUrl`,避免下次打开看到旧机构公章
+  5. 模板:两个 tab 的表格结束后都加 `<div class="seal-area">`,内含 `<img :src="sealUrl" v-if="sealUrl">` + `<span class="seal-label">(机构签章)</span>`
+  6. 样式:`.seal-area` 右对齐 + `min-height: 30mm` 占位;`.seal-img { width:28mm; height:28mm; object-fit:contain }` 打印自适应
+- 公章来源 = 已对接的 `tenant.expenseCachetUrl`(不重复加 API),新机构未上传则不渲染
+- v-print iframe 同步:v3-print-nb 把 scoped CSS 克隆进 iframe,公章图会随打印输出
+
+### 对账单公章改为叠在表格内部 + 旋转 -45°(2026-08-26 16:12)
+- 用户第二轮反馈:公章位置改到**红框处**(表格内"备注"列右下角,跨第3行+支付方式行),**旋转 -45°**,去掉"机构签章"4字
+- 改动:
+  1. 模板:`<div class="seal-area">` 改为 `<div v-if="sealUrl" class="seal-overlay">`,去掉 `.seal-label` 文字
+  2. `.first, .second { position: relative }` 提供绝对定位上下文
+  3. `.seal-overlay` 改绝对定位:`right:0; bottom:0; width:36mm; height:36mm; z-index:2; pointer-events:none`
+  4. `.seal-img { transform: rotate(-45deg); transform-origin: 100% 100%; opacity:0.85 }` 旋转中心在右下角 → 旋转后向左上"倾倒",自然落在表格右下区域
+- 关键技巧:`transform-origin: 100% 100%` 让旋转中心在公章图右下角,旋转后公章左上角会向左上方延伸出去,正好覆盖表格右下备注区域;如果用 `center` 旋转则公章整体会跑出容器
+- `pointer-events: none` 保证不挡表格交互(hover / 复制备注)
+
+### 缴费通知单预览加"家属信息"按钮 + 弹窗(2026-08-26 16:25)
+- 用户:在 `src/views/elderly/fee/bill-pay/unpay-preview.vue` 缴费通知单预览**右上角**加"家属信息"按钮,点击弹家属列表;**按钮不能被打印出来**
+- 后端响应新增 `relatives[]` 字段(每项含 peopleName/relation/phone/idCard/insurance/isBillSender 等)
+- 改动:
+  1. Script:加 `relatives = ref<any[]>([])` + `relativeDialogVisible = ref(false)`;`open()` 里 `relatives.value = Array.isArray(res.relatives) ? res.relatives : []`;`handleClosed()` 清空
+  2. 模板:`.header` 标题下加 `<el-button v-if="relatives.length" class="no-print relative-action">家属信息({n})</el-button>`,按钮带家属数量徽标
+  3. 模板:`<template #footer>` 后追加独立 Dialog(家属信息表格:序号/姓名/关系/联系电话/身份证号/险种/账单发送人 el-tag)
+  4. 样式:`.header { position: relative }` + `.relative-action { position: absolute; right:0; top:4px }`
+  5. **打印隐藏关键**:`printExtraCss` 注入两条规则
+     - `.no-print { display: none !important; }`(v-print iframe 内的全局样式)
+     - `@media print { .no-print { display: none !important; } }`(屏幕本身按 print 媒体查询时也隐藏)
+- 范式:**打印排除元素**首选 `.no-print` + `printExtraCss` 注入样式(v-print 走 iframe,scoped data-v 属性不被命中,必须走全局规则);不要靠把按钮放容器外这种"侥幸路径"——一旦后续把 Dialog 包进更外层容器,按钮就会被纳入打印范围
+
+### 修复 submitForm 的 arr.splice(3,1) 物理删除(2026-08-26 17:12)
+- 问题:长住提交时若服务费未选,`arr.splice(3, 1)` 把"服务标准"项从 arr 物理删除 → arr 永久变 3 项,`getOverheadByType(7)` 的 `arr[3].list`、`totalMonthFee` computed、编辑回填 `arr[3]` 全部索引错乱,补填也回不来
+- 修复(ProcessForm.vue,语义不变):
+  1. 校验段:删 try/catch + splice,改 `arr.forEach` —— `item.type == 7 && !item.itemId` 跳过校验(服务费可选),其余三项 `!item.itemId` 报错
+  2. 提交段:`push(...arr)` 改 `push(...arr.filter(item => item.itemId))` —— filter 返回新数组,未选的服务费不提交,arr 本体保持 4 项
+- 判断"已选/未选"用 `item.itemId`(el-select v-model 直接绑定,比原 `arr[3].amount` 更可靠)
+- 范式:**固定结构数组(按下标访问的 arr[i])绝不能 splice 删项**,用 filter/标记跳过代替
+- ESLint 0 错误(3 个 v-for shadow warning 为既有问题)
+
+### 复核"费用改动"影响面 + totalAmount 兜底升级(2026-08-26 17:20)
+- 用户要求复核上上次(safeAdd + totalAmount 归一)改动是否导致费用计算错误/提交参数问题
+- 链路核对结论:
+  - `totalMonthFee`(safeAdd)只影响显示合计,不影响提交;有值项 Number() 保真
+  - 提交金额来源:回填时 `dataForm.monthlyExpenses` 会过滤掉 type 1/2/3/7(914-916 行),费用项只经 `arr` 提交 → `init()` 归一出的 totalAmount **就是后端入库金额**
+  - **发现真实隐患**:后端存在不返回 totalAmount 的费用项(用户最初 bug 的证据),原归一 `: 0` 会把真实费用提交成 0 元
+- 修复:init() 4 处归一升级为 `totalAmount 合法 → 用它;否则 actualAmount 合法 → 用它(月费 totalAmount 本就 = actualAmount);都缺才 0`
+- 核对 `handleChangeDiscount` 的 `amount * ((discount/100)*10)`:配合 discount=9(9折)语义 = amount*0.9,公式正确,无需动
+- 既有行为(未改):push(...arr.filter()) 提交的 arr 元素含 label1/label2/errMsg/discountTemp 等 UI 字段(后端忽略未知字段);`item.list=undefined` 引用共享污染 arr.list(splice 时代就有)
+- ESLint 0 错误
+
+### 是否赠送=是时 赠送起止时间必填(2026-08-26 17:26)
+- 需求:`ProcessForm.vue`(入住办理)阶段性收费项,`item.isFreeGift == 1` 时"赠送开始/结束时间"必填
+- 实现(两层):
+  1. 模板:两个 el-form-item 改动态 prop `'stageExpenses.' + index + '.freeStartTime'`(原 prop="freeStartTime" 挂在外层 form model=dataForm 上,指向 dataForm.freeStartTime 不存在,校验永不生效)+ 内联 rules required 随 isFreeGift 变化(trigger change/blur)
+  2. submitForm:stageExpenses.forEach 前手动兜底校验,缺失时 message.error(`请选择第N个收费项目的赠送开始/结束时间`) + return(项目先例 Check.vue/ToLong.vue 同款写法)
+- 注意:`Renewal.vue`(续约)438-482 行有相同结构的赠送字段,本次未改,用户未要求
+- ESLint 0 错误
+
+### "重新发起"赠送时间不回显 排查+防御(2026-08-26 17:33)
+- 现象:重新发起(getProcess→getCheckInRecordId 回填)时 stageExpenses 的赠送开始/结束时间不回显,且能提交成功
+- 链路:`getProcess` → `init({bpmStatus,elderId,id}, true, status)` → `getCheckInRecordId` → `dataForm.value = {...res}` → stageExpenses 原样带入
+- 候选根因(按概率):
+  1. 后端 getCheckInDetail 返回的 freeStartTime/freeEndTime 是**时间戳(Long)**或"YYYY-MM-DD HH:mm:ss",而 TgDatePicker value-format=date("YYYY-MM-DD") → 显示空
+  2. 后端 isFreeGift 返回 null/0/缺字段 → v-if 不显示时间框、必填校验不触发 → 提交成功
+  3. 后端 isFreeGift 返回 "true"/"1" 字符串 → `== 1` 宽松相等,"1"==1 true 但 "true"==1 false
+- 防御修复(init 回填 stageExpenses map 内):`item.isFreeGift = Number(item.isFreeGift) === 1 ? 1 : 0` + `normalizeGiftDate()`(数字时间戳/长字符串→"YYYY-MM-DD")
+- 诊断钩子:`console.info('[入住办理] stageExpenses 回显:', ...)` 打印每项 isFreeGift/freeStartTime/freeEndTime,用户重发后看控制台判断是后端字段缺失还是类型问题
+- ESLint 0 错误

+ 109 - 13
src/views/elderly/apply/check-in/ProcessForm.vue

@@ -618,12 +618,32 @@
             </el-col>
             <template v-if="item.isFreeGift == 1">
               <el-col :lg="6" :md="24" :sm="24" :xs="24">
-                <el-form-item label="赠送开始时间" prop="freeStartTime">
+                <el-form-item
+                  label="赠送开始时间"
+                  :prop="'stageExpenses.' + index + '.freeStartTime'"
+                  :rules="[
+                    {
+                      required: item.isFreeGift == 1,
+                      message: '请选择赠送开始时间',
+                      trigger: ['change', 'blur']
+                    }
+                  ]"
+                >
                   <TgDatePicker type="date" v-model="item.freeStartTime" format />
                 </el-form-item>
               </el-col>
               <el-col :lg="5" :md="24" :sm="24" :xs="24">
-                <el-form-item label="赠送结束时间" prop="freeEndTime">
+                <el-form-item
+                  label="赠送结束时间"
+                  :prop="'stageExpenses.' + index + '.freeEndTime'"
+                  :rules="[
+                    {
+                      required: item.isFreeGift == 1,
+                      message: '请选择赠送结束时间',
+                      trigger: ['change', 'blur']
+                    }
+                  ]"
+                >
                   <TgDatePicker type="date" v-model="item.freeEndTime" format />
                 </el-form-item>
               </el-col>
@@ -877,6 +897,13 @@ const init = async (row, detail = false, status) => {
         arr[0] = {
           ...arr[0],
           ...item,
+          // totalAmount 缺失/非法时优先用 actualAmount 兜底(月费 totalAmount 本就 = actualAmount),
+          // 避免把真实费用提交成 0 元;两者都缺才归 0
+          totalAmount: Number.isFinite(Number(item.totalAmount))
+            ? Number(item.totalAmount)
+            : Number.isFinite(Number(item.actualAmount))
+              ? Number(item.actualAmount)
+              : 0,
           discountTemp: item.discount ? formatNum(item.discount) : undefined,
           discountAmountTemp: item.discountAmount ? formatNum(item.discountAmount) : ''
         }
@@ -884,6 +911,13 @@ const init = async (row, detail = false, status) => {
         arr[1] = {
           ...arr[1],
           ...item,
+          // totalAmount 缺失/非法时优先用 actualAmount 兜底(月费 totalAmount 本就 = actualAmount),
+          // 避免把真实费用提交成 0 元;两者都缺才归 0
+          totalAmount: Number.isFinite(Number(item.totalAmount))
+            ? Number(item.totalAmount)
+            : Number.isFinite(Number(item.actualAmount))
+              ? Number(item.actualAmount)
+              : 0,
           discountTemp: item.discount ? formatNum(item.discount) : undefined,
           discountAmountTemp: item.discountAmount ? formatNum(item.discountAmount) : ''
         }
@@ -891,6 +925,13 @@ const init = async (row, detail = false, status) => {
         arr[2] = {
           ...arr[2],
           ...item,
+          // totalAmount 缺失/非法时优先用 actualAmount 兜底(月费 totalAmount 本就 = actualAmount),
+          // 避免把真实费用提交成 0 元;两者都缺才归 0
+          totalAmount: Number.isFinite(Number(item.totalAmount))
+            ? Number(item.totalAmount)
+            : Number.isFinite(Number(item.actualAmount))
+              ? Number(item.actualAmount)
+              : 0,
           discountTemp: item.discount ? formatNum(item.discount) : undefined,
           discountAmountTemp: item.discountAmount ? formatNum(item.discountAmount) : ''
         }
@@ -898,6 +939,13 @@ const init = async (row, detail = false, status) => {
         arr[3] = {
           ...arr[3],
           ...item,
+          // totalAmount 缺失/非法时优先用 actualAmount 兜底(月费 totalAmount 本就 = actualAmount),
+          // 避免把真实费用提交成 0 元;两者都缺才归 0
+          totalAmount: Number.isFinite(Number(item.totalAmount))
+            ? Number(item.totalAmount)
+            : Number.isFinite(Number(item.actualAmount))
+              ? Number(item.actualAmount)
+              : 0,
           discountTemp: item.discount ? formatNum(item.discount) : undefined,
           discountAmountTemp: item.discountAmount ? formatNum(item.discountAmount) : ''
         }
@@ -915,7 +963,28 @@ const init = async (row, detail = false, status) => {
     })
     dataForm.value.stageExpenses.map((item) => {
       getNameList(item)
+      // 兼容后端返回类型不一的 isFreeGift(1 / "1" / true)→ 归一为 0/1,
+      // 保证模板 v-if="item.isFreeGift == 1" 与必填校验判定一致
+      item.isFreeGift = Number(item.isFreeGift) === 1 ? 1 : 0
+      // 兼容后端返回时间戳(Long)/ 带时分秒字符串 → 归一为 YYYY-MM-DD
+      //(TgDatePicker 的 value-format 为 date,拿到时间戳/长字符串会显示空)
+      item.freeStartTime = normalizeGiftDate(item.freeStartTime)
+      item.freeEndTime = normalizeGiftDate(item.freeEndTime)
     })
+    // 诊断:重新发起/编辑回显时后端返回的阶段性收费项(排查赠送时间不回显问题,看控制台)
+    console.info(
+      '[入住办理] stageExpenses 回显:',
+      JSON.parse(
+        JSON.stringify(
+          dataForm.value.stageExpenses.map((it: any) => ({
+            itemName: it.itemName,
+            isFreeGift: it.isFreeGift,
+            freeStartTime: it.freeStartTime,
+            freeEndTime: it.freeEndTime
+          }))
+        )
+      )
+    )
     if (!res.startTenantId) {
       dataForm.value.startTenantId = getTenantId()
     }
@@ -981,14 +1050,11 @@ const submitForm = async () => {
   let result = true
   // 长住才校验
   if (dataForm.value.inStatusType == '1') {
-    try {
-      if (arr[3].amount == '' || arr[3].amount == null || arr[3].amount == undefined) {
-        //没有选择服务费
-        arr.splice(3, 1) //删除
-      }
-    } catch (e) {}
-    // 校验床位护理餐饮
-    arr.map((item) => {
+    // 床位/护理/餐饮必填;服务费(type=7)可选,未选择时跳过校验
+    // ⚠️ 不再 arr.splice(3, 1) 物理删除——arr 必须保持固定 4 项结构
+    //(getOverheadByType/totalMonthFee/编辑回填 arr[3] 都依赖索引,删项会永久错乱)
+    arr.forEach((item) => {
+      if (item.type == 7 && !item.itemId) return
       if (!item.itemId) {
         item.errMsg = item.label1 + '不能为空'
         result = false
@@ -1051,7 +1117,8 @@ const submitForm = async () => {
   }
 
   if (dataForm.value.inStatusType == '1') {
-    data.expenseBO.monthlyExpenses.push(...arr)
+    // 只提交已选择的固定费用项(服务费未选则不提交;filter 返回新数组,不破坏 arr 4 项结构)
+    data.expenseBO.monthlyExpenses.push(...arr.filter((item) => item.itemId))
   }
   data.expenseBO.monthlyExpenses.forEach((item) => {
     item.list = undefined
@@ -1061,6 +1128,20 @@ const submitForm = async () => {
     item.list = undefined
     item.isDayCalculate = item.isDayCalculate === 1 ? 1 : 0
   })
+  // 是否赠送=是 时,赠送开始/结束时间必填(兜底校验,防 el-form validate 因动态 prop 未命中而放行)
+  for (let i = 0; i < dataForm.value.stageExpenses.length; i++) {
+    const giftItem = dataForm.value.stageExpenses[i]
+    if (giftItem.isFreeGift == 1) {
+      if (!giftItem.freeStartTime) {
+        message.error(`请选择第${i + 1}个收费项目的赠送开始时间`)
+        return
+      }
+      if (!giftItem.freeEndTime) {
+        message.error(`请选择第${i + 1}个收费项目的赠送结束时间`)
+        return
+      }
+    }
+  }
   // 去除阶段性的actualAmount
   data.expenseBO.stageExpenses.forEach((item) => {
     item.list = undefined
@@ -1295,6 +1376,16 @@ const getNameList = async (item) => {
   }
 }
 
+/** 赠送时间归一:数字时间戳(13位毫秒/10位秒)或 "YYYY-MM-DD HH:mm:ss" → "YYYY-MM-DD";空保持空 */
+const normalizeGiftDate = (val: any): string => {
+  if (val === undefined || val === null || val === '') return ''
+  if (typeof val === 'number') {
+    const d = val >= 1e12 ? dayjs(val) : dayjs.unix(val)
+    return d.isValid() ? d.format('YYYY-MM-DD') : ''
+  }
+  return String(val).slice(0, 10)
+}
+
 const deathTime = computed(() => {
   if (dataForm.value.checkInTime && dataForm.value.times) {
     // 要比原来的天数少1天
@@ -1488,11 +1579,16 @@ const handleDelMonth = (index: number) => {
 
 const totalMonthFee = computed(() => {
   let total = 0
+  // 安全累加:任何 totalAmount 是 undefined / null / 非数字都按 0 计(避免 NaN 污染总和 → formatNum 输出空)
+  const safeAdd = (v: any) => {
+    const n = Number(v)
+    return Number.isFinite(n) ? n : 0
+  }
   dataForm.value.monthlyExpenses.map((item) => {
-    total += Number(item.totalAmount)
+    total += safeAdd(item.totalAmount)
   })
   arr.map((item) => {
-    total += Number(item.totalAmount)
+    total += safeAdd(item.totalAmount)
   })
   return total
 })

+ 49 - 1
src/views/elderly/fee/bill-pay/preview.vue

@@ -84,6 +84,10 @@
               </tr> -->
             </table>
           </div>
+          <!-- 机构签章:叠在表格右下角,旋转 -45° -->
+          <div v-if="sealUrl" class="seal-overlay">
+            <img :src="sealUrl" class="seal-img" alt="机构公章" />
+          </div>
         </div>
       </el-tab-pane>
       <el-tab-pane label="打印类型B(不含固定费明细)" name="second">
@@ -149,6 +153,10 @@
               </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>
@@ -163,6 +171,8 @@
 import { getExpenseOrderDetail } from '@/api/elderly/fee/bill-pay'
 import dayjs from 'dayjs'
 import { useUserStore } from '@/store/modules/user'
+import { getTenant } from '@/api/system/tenant'
+import { getTenantId } from '@/utils/auth'
 import {
   fixedNum,
   formatCeil,
@@ -202,6 +212,9 @@ const longTermAmount = ref(0)
 const payText = ref('')
 const roundDown = ref(0)
 
+// 当前机构公章(来自租户接口 expenseCachetUrl);为空字符串时签章区不渲染
+const sealUrl = ref('')
+
 const formatPayCreatedTime = (createdTime) => {
   if (!createdTime) return ''
   const timestamp = String(createdTime).length === 10 ? Number(createdTime) * 1000 : Number(createdTime)
@@ -211,7 +224,12 @@ const formatPayCreatedTime = (createdTime) => {
 /** 打开弹窗 */
 const open = async (id, row) => {
   dialogVisible.value = true
-  const res = await getExpenseOrderDetail(id)
+  // 并行:账单详情 + 当前机构公章(容错,租户接口失败不影响主流程)
+  const [res, tenant] = await Promise.all([
+    getExpenseOrderDetail(id),
+    getTenant(getTenantId() as number).catch(() => null)
+  ])
+  sealUrl.value = tenant?.expenseCachetUrl || ''
   let flag = false
   dataForm.value = res
   res.items = res.items.filter((item) => item.isShow == 1)
@@ -362,6 +380,7 @@ const handleClosed = () => {
   payText.value = ''
   activeName.value = 'first'
   longTermAmount.value = 0
+  sealUrl.value = ''
   dialogVisible.value = false
 }
 
@@ -709,6 +728,11 @@ const printTab2 = {
 }
 </script>
 <style lang="scss" scoped>
+.first,
+.second {
+  position: relative; // 让 .seal-overlay 相对本容器定位
+}
+
 .first {
   .header {
     .title {
@@ -765,4 +789,28 @@ const printTab2 = {
 .pay-text-cell {
   white-space: pre-line;
 }
+
+// 机构签章:绝对定位叠在表格右下角,旋转 -45°
+// - transform-origin: 100% 100% 让旋转中心在公章右下角,
+//   旋转后公章主体向左上"倾倒",自然落在表格右下区域
+// - pointer-events: none 不挡表格交互(hover/点击备注等不受影响)
+// - opacity: 0.85 略透,不完全遮住备注列文字
+.seal-overlay {
+  position: absolute;
+  right: 0;
+  bottom: 15mm;
+  width: 30mm;
+  height: 30mm;
+  z-index: 2;
+  pointer-events: none;
+
+  .seal-img {
+    width: 100%;
+    height: 100%;
+    object-fit: contain;
+    transform: rotate(-45deg);
+    transform-origin: 100% 100%;
+    opacity: 0.85;
+  }
+}
 </style>

+ 60 - 0
src/views/elderly/fee/bill-pay/unpay-preview.vue

@@ -10,6 +10,17 @@
     <div class="first" id="printTab1">
       <div class="header">
         <p class="title" style="font-size: 18px">缴费通知单</p>
+        <!-- 右上角按钮:仅屏幕可见,打印时通过 .no-print + printExtraCss 隐藏 -->
+        <el-button
+          v-if="relatives.length"
+          class="no-print relative-action"
+          type="primary"
+          plain
+          size="small"
+          @click="relativeDialogVisible = true"
+        >
+          家属信息({{ relatives.length }})
+        </el-button>
         <div class="info-wrap" style="margin-top: -4px">
           <span><b>机构:</b> {{ tenantName }}</span>
           <span><b>姓名:</b> {{ dataForm.elderName }}</span>
@@ -89,6 +100,29 @@
       <el-button type="primary" v-print="printTab">打印</el-button>
     </template>
   </Dialog>
+
+  <!-- 家属信息弹窗(独立于缴费通知单预览,不在 #printTab1 范围内,自然不会被打印) -->
+  <Dialog
+    v-model="relativeDialogVisible"
+    title="家属信息"
+    width="60%"
+    @closed="handleRelativeClosed"
+  >
+    <el-table :data="relatives" border size="default">
+      <el-table-column type="index" label="序号" width="60" align="center" />
+      <el-table-column label="姓名" prop="peopleName" min-width="100" />
+      <el-table-column label="关系" prop="relation" min-width="80" />
+      <el-table-column label="联系电话" prop="phone" min-width="130" />
+      <el-table-column label="身份证号" prop="idCard" min-width="170" />
+      <el-table-column label="账单发送人" min-width="100">
+        <template #default="{ row }">
+          <el-tag :type="row.isBillSender ? 'success' : 'info'" size="small">
+            {{ row.isBillSender ? '是' : '否' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+    </el-table>
+  </Dialog>
 </template>
 <script setup lang="ts">
 import { getExpenseOrderDetail } from '@/api/elderly/fee/bill-pay'
@@ -137,6 +171,10 @@ const fixed2Num = computed(() => {
 
 const tempResValue = ref({})
 
+// 家属信息:后端响应 relatives 数组(每项含 peopleName/relation/phone/idCard/insurance/isBillSender 等)
+const relatives = ref<any[]>([])
+const relativeDialogVisible = ref(false)
+
 const getItemPreviewAmount = (item) => {
   if (item.expenseSource == 'consumer_voucher') {
     const ruleValue = settingStore.getCouponRuleValue
@@ -177,6 +215,8 @@ const open = async (id) => {
   payTotal.value = 0
   const res = await getExpenseOrderDetail(id)
   tempResValue.value = res
+  // 后端响应新增 relatives 字段:家属信息列表
+  relatives.value = Array.isArray(res.relatives) ? res.relatives : []
   res.items = res.items.filter(
     (item) =>
       (item.expenseSource == 'long_term_care_insurance' ||
@@ -271,8 +311,15 @@ const handleClosed = () => {
   totalDecimal.value = 0
   longTermAmount.value = 0
   payTotal.value = 0
+  relatives.value = []
+  relativeDialogVisible.value = false
   dialogVisible.value = false
 }
+
+/** 家属信息 Dialog 关闭 */
+const handleRelativeClosed = () => {
+  // 不动 relatives.value,只关弹窗;点开可继续看同一组家属
+}
 const tTitle = computed(() => {
   const arr = dataForm.value.billingMonth ? dataForm.value.billingMonth.split('-') : ''
   if (arr.length) {
@@ -378,6 +425,10 @@ const printExtraCss = `
   tr { page-break-inside: avoid; page-break-after: auto; }
   thead { display: table-header-group; }
   tfoot { display: table-footer-group; }
+  /* 家属信息按钮:屏幕可见,但打印时必须隐藏 */
+  .no-print { display: none !important; }
+  /* 兜底:屏幕视图的家属信息 Dialog 不在 #printTab1 内,本身就不会进 iframe */
+  @media print { .no-print { display: none !important; } }
 `
 // 打印
 const printTab = {
@@ -413,12 +464,21 @@ const printTab = {
 <style lang="scss" scoped>
 .first {
   .header {
+    position: relative; // 让右上角 .relative-action 按钮可绝对定位
+
     .title {
       font-size: 20px;
       font-weight: bold;
       text-align: center;
     }
 
+    // 右上角"家属信息"按钮:屏幕可见 / 打印隐藏
+    .relative-action {
+      position: absolute;
+      right: 0;
+      top: 4px;
+    }
+
     .info-wrap {
       position: relative;
       padding: 5px;