Kaynağa Gözat

增加变更导出按钮

chenjun 4 hafta önce
ebeveyn
işleme
4139f8a0b7

+ 250 - 0
docs/wecom-approval-notify-design.md

@@ -0,0 +1,250 @@
+# 企业微信应用消息推送「审核通知」设计方案
+
+版本:v0.1(评审稿,已按官方文档核验)
+日期:2026-09-11
+范围:后端自动推送 · 企微自建应用消息 · **集团单一企微(单 corpId)**
+
+---
+
+## 1. 背景与目标
+
+项目(养老院管理系统,若依 RuoYi 体系:前端 `kyj-yanglao-web-new` + Java 服务端)当前的审核/审批:
+
+- 流程由 BPM 工作流引擎驱动,前端通过 `/bpm/task/approve`、`/bpm/task/reject`、`/bpm/task/return` 触发「通过/拒绝/退回」。
+- 已有**站内通知中心**:前端读取未读数 `/system/notify-message/getBpmListCount`、标记已读 `/system/notify-message/updateReadById`。
+
+**问题**:站内通知仅在 Web 端可见,审核通过/拒绝/待办变更无法及时触达相关人员(经办人、发起人、审批人)。
+
+**目标**:在审核结果产生时(后端),通过**企业微信自建应用消息**,将审核通知实时推送到关联人员的企业微信;采用后端自动推送,不依赖前端唤起页面。
+
+**关键前提**:集团为**单一企业微信(单 corpId)**,`userid` 全集团唯一,因此可按 userid **精准推送给对应人员**,一套 `corpId/secret/agentId` 全局复用,无需按租户/机构拆分多套企微配置。
+
+---
+
+## 2. 现状梳理
+
+| 能力 | 现状 | 位置(前端) |
+| --- | --- | --- |
+| 审核动作入口 | 通过/拒绝/退回 | `src/views/bpm/processInstance/detail/ProcessInstanceOperationButton.vue`(`handleAudit`) |
+| 审核接口 | `/bpm/task/approve`、`/bpm/task/reject`、`/bpm/task/return` | `src/api/bpm/task/index.ts` |
+| 待办/已办列表 | 是 | `src/views/bpm/task/todo/index.vue` |
+| 站内通知 | 未读数 + 已读 | `/system/notify-message/*` |
+| 后端技术栈 | 若依 RuoYi(Spring Boot) | 不在本仓库 |
+
+前端审核动作只是把结果提交给后端,**最终的审批状态流转和通知都在服务端**。因此企微推送必须落在后端。
+
+---
+
+## 3. 总体架构
+
+```
+        申请人                 审批人              企微 App(单 corpId)
+          │                      │                    │
+          │ 提交单据(BPM启动)      │                    │
+          │ ───────────────────► │                    │
+          │                      │                    │
+          │        审批通过/拒绝  │                    │
+          │                      │ ─────────────────► │
+          │                      审核结果完成            │
+          │                       └── 后端流程引擎触发通知
+          │                            │
+          │                            ▼
+          │              ┌─────────────────────────┐
+          │              │  NotifyMessage 记录站内通知│
+          │              └─────────────────────────┘
+          │                            │
+          │                            ▼
+          │              ┌─────────────────────────┐
+          │              │ QyWeixinClient          │
+          │              │ - 取 access_token(缓存)  │
+          │              │ - 按 userid 精准发应用消息 │
+          │              └─────────────────────────┘
+          │                            │
+          │                            ▼
+          │                      企微 App 推给对应人员
+```
+
+### 最优挂接点(推荐)
+
+不直接改 `approve/reject` 各自的方法,而是在**审核完成时统一产生的通知出口**处理,做到一次接入、覆盖所有审核场景:
+
+- 方案 A(推荐):在服务端「审核通知落地」的公共位置挂接发送逻辑 —— 即当前 BPM 审批完成后写站内通知(`notify-message`)的地方,追加企微推送。所有通过/拒绝/退回都经过此处。
+- 方案 B:在 BPM 流程事件监听器上挂接(例如审批任务完成 / 流程实例结束事件),事件驱动,与通知中心解耦。
+
+两种方案都满足「后端自动推送、前端零改动」。方案 A 复用既有通知点位更简单;方案 B 更符合流程事件化、可扩展(未来其它消息也可复用)。
+
+---
+
+## 4. 企业微信应用消息机制(前置说明)
+
+### 4.1 三要素(一套,全局复用)
+
+| 配置项 | 含义 | 获取位置 |
+| --- | --- | --- |
+| `corpId` | 企业 ID(全集团唯一) | 企微管理后台 → 我的企业 → 企业信息 |
+| `corpSecret` | 自建应用的密钥 | 应用管理 → 目标应用 → Secret |
+| `agentId` | 应用 AgentId | 应用管理 → 目标应用 → AgentId |
+
+### 4.2 调用链路(两步)
+
+1. 获取 access_token(有效期 7200s,需缓存复用,勿每次请求):
+   `GET https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={corpid}&corpsecret={secret}`
+2. 发送应用消息(给指定 userid,可多选):
+   `POST https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}`
+   body:
+   ```json
+   {
+     "touser": "zhangsan|lisi",
+     "msgtype": "markdown",
+     "agentid": 1000002,
+     "markdown": { "content": "..." },
+     "safe": 0
+   }
+   ```
+
+### 4.3 前置条件(必须满足,否则发不出去)
+
+- 目标用户必须是企微通讯录成员,且 `touser` 用其 **userid**(非手机号/姓名)。
+- 服务端出口 IP 需加入该应用「企业可信 IP」白名单,否则 gettoken 失败。
+- 自建应用需勾选「消息发送」权限;接收人需在该应用可见范围内。
+- 应用可见范围至少要覆盖需要被通知的人员(经办人/审批人)。
+- **集团为单一企微(单 corpId)**:`userid` 全集团唯一,可直接按 userid 精准推送给对应人员,一套配置全局有效,无需按租户/机构拆分。
+
+---
+
+## 5. 后端改动点(若依 RuoYi / Spring Boot)
+
+> 后端不在本仓库,此处给出落地清单;实际编码在拿到后端仓库后进行。
+
+### 5.1 配置 `application.yml`
+
+```yaml
+wecom:
+  app:
+    enabled: true
+    corp-id: wx1234567890abcdef
+    agent-id: 1000002
+    corp-secret: YOUR_CORP_SECRET
+    # userid 前缀/映射可选,若系统用户表存的是手机号而非 userid
+    user-id-fallback: mobile
+```
+
+### 5.2 新增 `QyWeixinClient`
+
+```java
+@Component
+public class QyWeixinClient {
+    // 1. getAccessToken():按 agentId 维度 Redis 缓存 7200s,失效再拉取(单 corpId 下基本一份 token)
+    // 2. sendMarkdown(targetUseridSet, mdContent):非空 userid 才发,多个用 "|" 拼接
+    // 3. 记录返回的 invaliduser/unlicenseduser 日志,便于排查 userid 映射
+    // 异常处理:RestClientException 记日志,不向上抛,保证不影响主审批流程
+}
+```
+
+### 5.3 通知条件判断与接收人计算
+
+- 仅当审核**结果产生**(通过/拒绝/退回)且配置 `enabled=true`、存在**接收人 userid** 时才发送。
+- 接收人=申请人(单据发起人)+ 上一审批人。userid 来源(单 corpId 下 userid 全集团唯一,直接可用于 `touser`):
+  - 用户表若有 userid 字段直接取;
+  - 否则按配置 `user-id-fallback: mobile`,用手机号在企微通讯录换取 userid(`/cgi-bin/user/getuserid`)。手机号必须开通并唯一。
+- 由于单 corpId,**无需**按租户/机构维护多套企微参数与 user↔corpId 映射。
+
+### 5.4 挂接点(方案 A:通知出口处)
+
+在服务端「BPM 审批写站内通知(notify-message)」的方法内,审批结果确定后追加:
+
+```java
+// 伪代码
+notifyMessageService.createBpm(...);   // 既有站内通知,保留不动
+
+if (wecomProperties.getApp().isEnabled()) {
+    Set<String> userIds = resolveReceiverUserIds(instance, task);
+    if (!userIds.isEmpty()) {
+        wecomClient.sendMarkdown(userIds, buildApprovalMd(instance, task, result));
+    }
+}
+```
+
+### 5.5 消息模板(Markdown,示例)
+
+```markdown
+**养老院审批通知**
+
+> 单据:退住申请
+> 编号:DS-2026-0001
+> 发起人:张三
+> 审批人:李四
+> 结果:✅ 已通过 / ❌ 已拒绝 / ↩️ 已退回
+
+审批意见:……
+时间:2026-09-11 14:30
+```
+
+### 5.6 幂等与失败处理
+
+- token 按 agentId 缓存且处理并发,避免重复获取。
+- 发送失败(网络/接口返回 errcode)仅记录日志并告警,**不影响主审批事务**(异步或非事务内同步发送,推荐用 `@Async`)。
+- 可选开启 `enable_duplicate_check=1` 防重复推送(默认 1800s 内同内容去重)。
+- 支持开关 `enabled:false` 一键关闭,灰度可配置按 tenant 或单据类型白名单。
+
+---
+
+## 6. 配置清单(供实施联调用)
+
+| 步骤 | 内容 | 负责人 |
+| --- | --- | --- |
+| 1 | 企微后台创建自建应用,取得 corpId / agentId / secret | 企微管理员 |
+| 2 | 应用可见范围勾选需被通知人员 | 企微管理员 |
+| 3 | 服务器出口 IP 加入「企业可信 IP」 | 运维 + 企微管理员 |
+| 4 | 确认用户表具备 userid(或手机号可映射) | 后端开发 |
+| 5 | 后端接配置 + QyWeixinClient + 通知出口挂接 | 后端开发 |
+| 6 | 联调:发起一单审批,验证审批人/申请人企微能收消息 | 后端 + 测试 |
+| 7 | 灰度与开关 | 运维 |
+
+---
+
+## 7. 边界与风险
+
+- **userid 准确性**是本方案最大风险:userid 与实际用户不匹配、接收人不在应用可见范围,会导致个别用户收不到(响应 `invaliduser`)。需在联调阶段核对映射。单 corpId 下全集团 userid 唯一,映射相对清晰。
+- token 需按应用缓存且处理并发;Redis 或本地内存皆可,过期自动刷新。
+- 大量审批并发时,企微频率限制(每应用对同一成员 ≤30 次/分、≤1000 次/时)需留意,必要时异步排队;建议避开整点 0/30 分调用。
+- markdown 消息需企微客户端 2.4.5+;若应用开启「在微工作台中始终进入主页」,非文本消息会被转成文本并截断(≤20 字节)。
+- 后端自动推送意味着**后端仓库需要新代码**;本前端仓库无需改动(除非未来要提供"审核通知开关/接收人配置"页面)。
+- 敏感信息:secret、access_token 不可落入前端仓库或日志(官方明确要求 token 仅存后端)。
+
+---
+
+## 8. 官方文档核验结论(2025/09/24 版)
+
+已根据企微官方开发文档逐项核对,**当前方案整体无误**,并补充以下细节:
+
+### 8.1 已确认与方案一致
+
+| 方案写法 | 官方结论 | 是否一致 |
+| --- | --- | --- |
+| `gettoken?corpid=&corpsecret=` 取 token | GET `https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=ID&corpsecret=SECRET`,需应用为启用状态 | ✅ |
+| access_token 有效期 7200s,需缓存复用 | `expires_in: 7200`(2 小时),须缓存、不能频繁调用否则被频率拦截 | ✅ |
+| `message/send?access_token=` 发消息 | POST `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=ACCESS_TOKEN` | ✅ |
+| `touser` 用 userid,多个用 `\|` 分隔 | 最多 1000 个;也支持 `toparty`/`totag`/`@all` | ✅ |
+| `agentid` 为整型必填 | 企业内部应用在设置页查看 | ✅ |
+| `msgtype: markdown` | 官方支持 markdown 消息(有语法子集) | ✅ |
+| 后端自动推送 | 官方明示「**请勿将 access_token 返回给前端,全部由后台发起企微 API 请求**」——印证后端方案正确 | ✅ |
+
+### 8.2 需补充的细节
+
+1. **token 缓存**:单 corpId 下应用仅一个,`access_token` 全局一份即可(仍按 agentId 语义缓存,便于扩展)。多企业场景才需按企业隔离。
+2. **接收人必须在应用可见范围**:否则该 userid 返回在 `invaliduser`;若全部无权限则 errcode=81013。发送方法需记录 `invaliduser/unlicenseduser` 便于排查。
+3. **markdown 客户端要求**:需企微客户端 2.4.5+;若应用设为「在微工作台中始终进入主页」,会转纯文本(≤20 字节截断)。
+4. **频率与错峰**:每应用对同一成员 ≤30 次/分、≤1000 次/时;建议避开每小时 0/30 分整点调用。
+5. **防重复**:可开 `enable_duplicate_check=1`(默认 1800s 同内容去重),避免重复推送。
+6. **返回大小写**:返回包 userid 统一转小写,接收人分派时按小写处理更稳妥。
+7. **secret 需应用启用状态**:配置联调前确认自建应用为「已启用」。
+
+---
+
+## 9. 待确认问题(评审后)
+
+1. 接收人集合:只推给申请人?还是申请人 + 全部历史审批人?是否包含"当前待办审批人"?
+2. 推送事件范围:仅「通过/拒绝/退回」这类结果,还是也要「新待办提醒」?
+3. 是否需要在 Web 端提供一个"审核通知设置"(接收人、开关)页面?
+4. 后端仓库位置:评审通过后请提供后端 Git 地址或本地路径,以便直接实现。

+ 25 - 0
src/api/elderly/apply/check-in.ts

@@ -84,3 +84,28 @@ export const deleteSafetyRiskNotice = (id) => {
     url: `/elderly-risk-disclosure-statement/delete?id=${id}`
   })
 }
+
+// 导出
+export const exportCustomer = async (params: any) => {
+  return await request.download({ url: `/bpm/process-instance/nurse-change-export-excel`, params })
+}
+
+// 导出
+export const exportCustomerWait = async (params: any) => {
+  return await request.download({ url: `/bpm/process-instance/check-in-wait-export-excel`, params })
+}
+// 导出
+export const exportCustomerPrice = async (params: any) => {
+  return await request.download({ url: `/bpm/process-instance/price-change-export-excel`, params })
+}
+
+// 导出
+export const exportCustomerBed = async (params: any) => {
+  return await request.download({ url: `/bpm/process-instance/bed-change-export-excel`, params })
+}
+
+// 导出
+export const exportCustomerFood = async (params: any) => {
+  return await request.download({ url: `/bpm/process-instance/catering-change-export-excel`, params })
+}
+

+ 36 - 9
src/views/changes/ChangeFood/index.vue

@@ -8,7 +8,7 @@
       :inline="true"
       label-width="90px"
     >
-      <TenantSelect v-model="queryParams.tenantIds" placeholder="请选择机构名称" prop="tenantIds"/>
+      <TenantSelect v-model="queryParams.tenantIds" placeholder="请选择机构名称" prop="tenantIds" />
       <el-form-item label="长者姓名">
         <el-input
           @keyup.enter="handleQuery"
@@ -44,6 +44,10 @@
         <Icon icon="ep:plus" class="mr-5px" />
         新增
       </el-button>
+      <el-button :loading="exportLoading" plain type="success" @click="handleExport">
+        <Icon class="mr-5px" icon="ep:download" />
+        导出
+      </el-button>
     </div>
     <el-table v-loading="loading" :data="list" :header-cell-style="tableHeaderColor">
       <el-table-column header-align="center" align="center" label="序号" width="60">
@@ -53,7 +57,13 @@
           }}
         </template>
       </el-table-column>
-      <el-table-column header-align="center" align="center" label="所属机构" prop="tenantName"  v-if="userStore.getOrgTenant.multiple"/>
+      <el-table-column
+        header-align="center"
+        align="center"
+        label="所属机构"
+        prop="tenantName"
+        v-if="userStore.getOrgTenant.multiple"
+      />
       <el-table-column prop="elderlyName" header-align="center" align="center" label="长者姓名" />
       <el-table-column
         prop="originalName"
@@ -119,14 +129,16 @@
 </template>
 
 <script setup lang="ts">
-import { DICT_TYPE, getDictObj, getStrDictOptions } from '@/utils/dict'
-import { getAskLeList } from '@/api/elderly/elder/outwardRegustration'
+import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
 import AddForm from '@/views/changes/ChangeFood/AddForm.vue'
 import DetailForm from '@/views/changes/ChangeFood/DetailForm.vue'
 import { getCateringChange } from '@/api/elderly/changes/food'
-import {cancelSyncData} from "@/api/elderly/changes/courtyard";
+import { cancelSyncData } from '@/api/elderly/changes/courtyard'
 import { useUserStore } from '@/store/modules/user'
 const message = useMessage() // 消息弹窗
+import download from '@/utils/download'
+import { exportCustomerFood } from '@/api/elderly/apply/check-in'
+const exportLoading = ref(false) // 列表的加载中
 const { t } = useI18n() // 国际化
 const userStore = useUserStore()
 const loading = ref(true) // 列表的加载中
@@ -165,6 +177,21 @@ const handleQuery = async () => {
   getList()
 }
 
+/** 导出按钮操作 */
+const handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    // 发起导出
+    exportLoading.value = true
+    const data = await exportCustomerFood(queryParams)
+    download.excel(data, '餐饮变更.xls')
+  } catch {
+  } finally {
+    exportLoading.value = false
+  }
+}
+
 /** 重置按钮操作 */
 const resetQuery = () => {
   queryParams.elderName = ''
@@ -176,20 +203,20 @@ const resetQuery = () => {
 /** 添加/修改操作 */
 const formRef = ref()
 const openForm = (row: any = {}, isDetail: boolean = false) => {
-  if(queryParams.tenantIds.length == 0 || queryParams.tenantIds.length > 1 && !row.tenantId){
+  if (queryParams.tenantIds.length == 0 || (queryParams.tenantIds.length > 1 && !row.tenantId)) {
     message.error('新增只能选择一个机构')
     return
   }
   formRef.value.open(row.tenantId || queryParams.tenantIds[0], row.id, isDetail, row.status)
 }
 
-const detailFormCK = (row,isD) => {
+const detailFormCK = (row, isD) => {
   detailRef.value.open(row.id, row.processInstanceId, row.status)
 }
 
 const openReturnHospitalFormRef = async (id) => {
   try {
-    console.log("任务ID",id)
+    console.log('任务ID', id)
     const res = await message.prompt('请输入撤销原因', '提示')
     if (res.action == 'confirm') {
       // // 发起撤销
@@ -220,7 +247,7 @@ const cancelDelete = async (id: number) => {
 const route = useRoute()
 /** 初始化 **/
 onMounted(() => {
-  if(route.query && route.query.elderName){
+  if (route.query && route.query.elderName) {
     queryParams.elderName = route.query.elderName as string
   }
   getList()

+ 9 - 1
src/views/elderly/assess/synthetic-ability/Form.vue

@@ -716,7 +716,7 @@ const stateForm = reactive<syntheticAbilityFormType>({
     id: '',
     evaluationAgency: '1', // 1本院评估, 2第三方评估
     thirdPartyName: '', // 第三方名称
-    evaluateReasons: '', //评估原因
+    evaluateReasons: '3', //评估原因,默认常规评估
     questionnaireName: '', // 能力评估表
     questionnaireId: '', // 评估表id
     questionnaireType: '', // 评估类型
@@ -952,6 +952,14 @@ const resetCheckBox = (arg, type, input1? : string, input2?: string) => {
 /** 打开弹窗 */
 const open = async (tId, id) => {
   resetForm()
+  // 意外事件默认选中"无"
+  const accidentNone = getStrDictOptions(DICT_TYPE.ACCIDENT_ARR).find((d) => d.label === '无')
+  if (accidentNone) {
+    dataForm.value.accidentalFall = accidentNone.value
+    dataForm.value.accidentalLoss = accidentNone.value
+    dataForm.value.accidentalChoking = accidentNone.value
+    dataForm.value.accidentalSuicide = accidentNone.value
+  }
   dialogVisible.value = true
   loading.value = true
   getNurseLevelList()

+ 187 - 66
src/views/elderly/assess/synthetic-ability/Print.vue

@@ -14,15 +14,9 @@
             <td>A.1.2 评估基准日期</td>
             <td>{{ formatDate(dataForm.evaluatorDate, 'YYYY-MM-DD') }}</td>
           </tr>
-          <tr v-if="templateType == 0 || templateType == 1">
-            <td>A.1.3 评估性质</td>
-            <td>
-              {{ getDictLabel(DICT_TYPE.EVALUATION_ARR, dataForm.questionnaireType) }}
-            </td>
-          </tr>
-          <tr v-if="templateType == 0 || templateType == 1">
+          <tr v-if="dataForm.evaluationAgency == '1' && (templateType == 0 || templateType == 1)">
             <td>A.1.4 评估原因</td>
-            <td>{{ dataForm.evaluateReasons }}</td>
+            <td>{{ getDictLabel(DICT_TYPE.EVALUATION_REASON, dataForm.evaluateReasons) }}</td>
           </tr>
         </table>
       </div>
@@ -59,35 +53,19 @@
           >
             <tr>
               <td colspan="2">A.2.4 身高</td>
-              <td>______{{ dataForm.height }}_______cm</td>
+              <td>{{ dataForm.elderHeight ? dataForm.elderHeight + ' cm' : '______cm' }}</td>
             </tr>
             <tr>
               <td colspan="2">A.2.5 体重</td>
-              <td>______{{ dataForm.weight }}_______kg</td>
+              <td>{{ dataForm.elderWeight ? dataForm.elderWeight + ' kg' : '______kg' }}</td>
             </tr>
             <tr>
               <td colspan="2">A.2.6 民族</td>
-              <td>
-                <div class="flex">
-                  <div class="left">
-                    <span class="item-info">1 汉族</span>
-                    <span class="item-info">2 少数民族_____________</span>
-                  </div>
-                  <div class="right"></div>
-                </div>
-              </td>
+              <td>{{ getDictLabel(DICT_TYPE.NATION_ARR, dataForm.nationId) }}</td>
             </tr>
             <tr>
               <td colspan="2">A.2.7 宗教信仰</td>
-              <td>
-                <div class="flex">
-                  <div class="left">
-                    <span class="item-info">1 无</span>
-                    <span class="item-info">2 有_____________</span>
-                  </div>
-                  <div class="right"></div>
-                </div>
-              </td>
+              <td>{{ getDictLabel(DICT_TYPE.RELIGIOUS_BELIEF_ARR, dataForm.religiousBelief) }}</td>
             </tr>
             <tr>
               <td colspan="2">A.2.8 公民身份号码</td>
@@ -107,10 +85,7 @@
                     >
                   </div>
                   <div class="right">{{
-                    findValueIndex(
-                      DICT_TYPE.DEGREE_EDUCATION_ARR,
-                      dataForm.elder ? dataForm.elder.degreeEducation : ''
-                    )
+                    findValueIndex(DICT_TYPE.DEGREE_EDUCATION_ARR, dataForm.degreeEducation)
                   }}</div>
                 </div>
               </td>
@@ -129,10 +104,7 @@
                     >
                   </div>
                   <div class="right">{{
-                    findValueIndex(
-                      DICT_TYPE.LIVE_ARR,
-                      dataForm.elder ? dataForm.elder.liveSituation : ''
-                    )
+                    findValueIndex(DICT_TYPE.LIVE_ARR, dataForm.liveSituation)
                   }}</div>
                 </div>
               </td>
@@ -151,10 +123,7 @@
                     >
                   </div>
                   <div class="right">{{
-                    findValueIndex(
-                      DICT_TYPE.MARITAL_STATUS_ARR,
-                      dataForm.elder ? dataForm.elder.maritalStatus : ''
-                    )
+                    findValueIndex(DICT_TYPE.MARITAL_STATUS_ARR, dataForm.maritalStatus)
                   }}</div>
                 </div>
               </td>
@@ -173,10 +142,7 @@
                     >
                   </div>
                   <div class="right">{{
-                    findValueIndex(
-                      DICT_TYPE.MEDICAL_INSURANCE_ARR,
-                      dataForm.elder ? dataForm.elder.medicalInsurance : ''
-                    )
+                    findValueIndex(DICT_TYPE.MEDICAL_INSURANCE_ARR, dataForm.medicalInsurance)
                   }}</div>
                 </div>
               </td>
@@ -195,10 +161,7 @@
                     >
                   </div>
                   <div class="right">{{
-                    findValueIndex(
-                      DICT_TYPE.ECONOMY_SOURCE_ARR,
-                      dataForm.elder ? dataForm.elder.economySource : ''
-                    )
+                    findValueIndex(DICT_TYPE.ECONOMY_SOURCE_ARR, dataForm.economySource)
                   }}</div>
                 </div>
               </td>
@@ -223,11 +186,80 @@
             </tr>
             <tr v-if="templateType != 8">
               <td>A3.14.5 其他</td>
-              <td>{{ dataForm.economySource }}</td>
+              <td>{{ dataForm.accidentalOther }}</td>
             </tr>
           </template>
         </table>
       </div>
+      <!-- 补充信息表:信息提供者、疾病诊断、长期服药、健康相关问题(仅能力评估大表填写这些字段) -->
+      <div
+        class="page"
+        v-if="dataForm.evaluationAgency == '1' && (templateType == 0 || templateType == 1)"
+      >
+        <h4>A.3 补充信息表</h4>
+        <table style="width: 100%" border="2">
+          <tr>
+            <td style="width: 180px">信息提供者姓名</td>
+            <td>{{ dataForm.informationProviderName }}</td>
+            <td style="width: 140px">与老年人关系</td>
+            <td>{{ dataForm.relation }}</td>
+          </tr>
+          <tr>
+            <td>联系人姓名</td>
+            <td>{{ dataForm.contactName }}</td>
+            <td>联系人电话</td>
+            <td>{{ dataForm.contactPhone }}</td>
+          </tr>
+          <tr>
+            <td>疾病诊断(可多选)</td>
+            <td colspan="3">{{
+              dictLabels(DICT_TYPE.DISEASE_DIAGNOSIS, dataForm.diseaseDiagnosis)
+            }}</td>
+          </tr>
+          <tr>
+            <td>长期服药情况</td>
+            <td colspan="3">{{ medicationText(dataForm.medicationSituation) }}</td>
+          </tr>
+          <tr>
+            <td>压力性损伤</td>
+            <td>{{ getDictLabel(DICT_TYPE.PRESSURE_INJURY, dataForm.pressureInjury) }}</td>
+            <td>关节活动度</td>
+            <td>{{ motionOfJointText(dataForm.motionOfJoint) }}</td>
+          </tr>
+          <tr>
+            <td>伤口情况(可多选)</td>
+            <td>{{ dictLabels(DICT_TYPE.WOUND_SITUATION, dataForm.woundCondition) }}</td>
+            <td>特殊护理情况(可多选)</td>
+            <td>{{ dictLabels(DICT_TYPE.SPECIAL_CARE, dataForm.specialCareSituation) }}</td>
+          </tr>
+          <tr>
+            <td>疼痛感</td>
+            <td>{{ getDictLabel(DICT_TYPE.PAIN, dataForm.pain) }}</td>
+            <td>牙齿缺失情况(可多选)</td>
+            <td>{{ absenceOfTeethText(dataForm.absenceOfTeeth) }}</td>
+          </tr>
+          <tr>
+            <td>义齿佩戴情况(可多选)</td>
+            <td>{{ dictLabels(DICT_TYPE.DENTISTRY_WEAR, dataForm.dentureWearingCondition) }}</td>
+            <td>吞咽困难的情形和症状(可多选)</td>
+            <td>{{ dictLabels(DICT_TYPE.SYMPTOMS_DYSPHAGIA, dataForm.dysphagia) }}</td>
+          </tr>
+          <tr>
+            <td>营养不良(BMI低于正常值)</td>
+            <td>{{ getDictLabel(DICT_TYPE.COMMON_STATUS4, dataForm.malnutrition) }}</td>
+            <td>清理呼吸道无效</td>
+            <td>{{
+              getDictLabel(DICT_TYPE.COMMON_STATUS4, dataForm.ineffectiveAirwayClearance)
+            }}</td>
+          </tr>
+          <tr>
+            <td>昏迷</td>
+            <td>{{ getDictLabel(DICT_TYPE.COMMON_STATUS4, dataForm.coma) }}</td>
+            <td>其他(请补充)</td>
+            <td>{{ dataForm.otherSupplements }}</td>
+          </tr>
+        </table>
+      </div>
       <template v-if="dataForm.evaluationAgency == '1'">
         <div class="page" v-for="(item, index) in temArr" :key="item.id + index">
           <h4>{{ item.name }}</h4>
@@ -735,23 +767,32 @@ const open = async (id) => {
   try {
     loading.value = true
     const res = await getSyntheticAbilityInfoById(id)
+    // 基础信息字段优先取评估记录顶层,长者档案兜底
+    const resElder = res.elder || {}
+    const pick = (key: string) => {
+      for (const v of [res[key], resElder[key]]) {
+        if (v !== undefined && v !== null && v !== '') return v
+      }
+      return ''
+    }
     dataForm.value = {
+      ...resElder,
       ...res,
-      elderSex: res.elder.elderSex,
-      papersType: res.elder.papersType,
-      idCard: res.elder.idCard,
-      birthday: res.elder.birthday,
-      elderAge: res.elder.elderAge,
-      nationId: res.elder.nationId,
-      elderHeight: res.elder.elderHeight,
-      elderWeight: res.elder.elderWeight,
-      religiousBelief: res.elder.religiousBelief,
-      degreeEducation: res.elder.degreeEducation,
-      maritalStatus: res.elder.maritalStatus,
-      liveSituation: res.elder.liveSituation,
-      economySource: res.elder.economySource,
-      medicalInsurance: res.elder.medicalInsurance,
-      elderName: res.elder.elderName
+      elderSex: pick('elderSex'),
+      papersType: pick('papersType'),
+      idCard: pick('idCard'),
+      birthday: pick('birthday'),
+      elderAge: pick('elderAge'),
+      nationId: pick('nationId'),
+      elderHeight: pick('elderHeight'),
+      elderWeight: pick('elderWeight'),
+      religiousBelief: pick('religiousBelief'),
+      degreeEducation: pick('degreeEducation'),
+      maritalStatus: pick('maritalStatus'),
+      liveSituation: pick('liveSituation'),
+      economySource: pick('economySource'),
+      medicalInsurance: pick('medicalInsurance'),
+      elderName: pick('elderName')
     }
     syntheticAbilityInfoListTemp = res.syntheticAbilityInfoList
     // 打印的标题
@@ -847,10 +888,90 @@ const findValue = (arr, value, key = 'id', name = 'label') => {
   return ''
 }
 
-// 找到对应值的index
+// 解析 JSON 字符串数组(兼容已是数组或逗号分隔字符串的情况)
+const parseArr = (val: any): any[] => {
+  if (!val) return []
+  if (Array.isArray(val)) return val
+  try {
+    const arr = JSON.parse(val)
+    return Array.isArray(arr) ? arr : []
+  } catch (e) {
+    return String(val)
+      .split(',')
+      .filter((v) => v !== '')
+  }
+}
+
+// 字典值数组转标签文本,支持 '99-自定义内容' 形式
+const dictLabels = (dictType: string, val: any): string => {
+  return parseArr(val)
+    .map((v: any) => {
+      const idx = String(v).indexOf('-')
+      if (idx > -1) {
+        const code = String(v).slice(0, idx)
+        const extra = String(v).slice(idx + 1)
+        return (getDictLabel(dictType, code) || code) + ':' + extra
+      }
+      return getDictLabel(dictType, v) || v
+    })
+    .join('、')
+}
+
+// 牙齿缺失情况(0-3 为表单固定值,2-x/3-x 为字典值)
+const absenceOfTeethText = (val: any): string => {
+  const map = { 0: '无缺损', 1: '牙体缺损(如龋齿、楔状缺损)', 2: '牙列缺损', 3: '牙列缺失' }
+  return parseArr(val)
+    .map((v: any) => {
+      const idx = String(v).indexOf('-')
+      if (idx > -1) {
+        const code = String(v).slice(0, idx)
+        const extra = String(v).slice(idx + 1)
+        const label = map[code] || code
+        if (code === '2')
+          return label + ':' + (getDictLabel(DICT_TYPE.DEFECT_OF_DENTITION, extra) || extra)
+        if (code === '3')
+          return label + ':' + (getDictLabel(DICT_TYPE.DENTISTRY_MISSING, extra) || extra)
+        return label + ':' + extra
+      }
+      return map[v] || v
+    })
+    .join('、')
+}
+
+// 关节活动度(1-部位 形式)
+const motionOfJointText = (val: any): string => {
+  if (!val) return ''
+  const idx = String(val).indexOf('-')
+  const code = idx > -1 ? String(val).slice(0, idx) : String(val)
+  const extra = idx > -1 ? String(val).slice(idx + 1) : ''
+  if (code === '0') return '无,没有影响日常生活功能'
+  if (code === '1') return '是,影响日常生活功能' + (extra ? ',部位:' + extra : '')
+  if (code === '2') return '无法判断'
+  return String(val)
+}
+
+// 长期服药情况(对象数组:药品名称/服药方法/用药剂量/用药频次)
+const medicationText = (val: any): string => {
+  return parseArr(val)
+    .map((item: any) =>
+      typeof item === 'object'
+        ? [item.drugName, item.takeMedicine, item.dosage, item.frequency]
+            .filter(Boolean)
+            .join(' / ')
+        : String(item)
+    )
+    .filter(Boolean)
+    .join(';')
+}
+
+// 找到对应值的序号(按字典 value 匹配,兼容按 label 匹配)
 const findValueIndex = (arr, value) => {
   if (value) {
-    let index = getDictOptions(arr).findIndex((item) => item.label == value)
+    const options = getDictOptions(arr)
+    let index = options.findIndex((item) => item.value == value)
+    if (index == -1) {
+      index = options.findIndex((item) => item.label == value)
+    }
     if (index > -1) {
       return index + 1
     }

+ 60 - 28
src/views/elderly/elder/bed-change/index.vue

@@ -9,28 +9,37 @@
       :inline="true"
       label-width="80px"
     >
-      <TenantSelect v-model="queryParams.tenantIds" placeholder="请选择机构名称" prop="tenantIds"/>
+      <TenantSelect v-model="queryParams.tenantIds" placeholder="请选择机构名称" prop="tenantIds" />
       <el-form-item label="长者姓名" prop="elderName">
-        <el-input @keyup.enter="handleQuery" v-model="queryParams.elderName" placeholder="请输入长者姓名" class="!w-240px" />
+        <el-input
+          @keyup.enter="handleQuery"
+          v-model="queryParams.elderName"
+          placeholder="请输入长者姓名"
+          class="!w-240px"
+        />
       </el-form-item>
       <el-form-item label="床位号" prop="expectId">
-        <SelectRoom v-model="queryParams.expectId" placeholder="请输入床位号" class="!w-240px" :tId="queryParams.tenantIds"/>
+        <SelectRoom
+          v-model="queryParams.expectId"
+          placeholder="请输入床位号"
+          class="!w-240px"
+          :tId="queryParams.tenantIds"
+        />
       </el-form-item>
 
       <el-form-item label="生效时间" prop="expectId">
-          <el-date-picker
-            style="width: 240px"
-            v-model="queryParams.changeDate"
-            :clearable="true"
-            type="daterange"
-            placeholder="选择时间"
-            value-format="YYYY-MM-DD"
-            format="YYYY-MM-DD"
-            date-format="YYYY-MM-DD"
-          />
+        <el-date-picker
+          style="width: 240px"
+          v-model="queryParams.changeDate"
+          :clearable="true"
+          type="daterange"
+          placeholder="选择时间"
+          value-format="YYYY-MM-DD"
+          format="YYYY-MM-DD"
+          date-format="YYYY-MM-DD"
+        />
       </el-form-item>
 
-
       <el-form-item label="审批状态" prop="type">
         <el-select v-model="queryParams.type" placeholder="请选择" class="!w-240px">
           <el-option
@@ -50,17 +59,23 @@
 
   <!-- 列表 -->
   <ContentWrap>
-    <TabBarBtn @add="openForm()" >
-<!--      <template #next>-->
-<!--        <el-button-->
-<!--          type="warning"-->
-<!--          plain-->
-<!--          @click="openExchangeForm()"-->
-<!--          v-hasPermi="['bed-change:exchange']"-->
-<!--        >-->
-<!--          <Icon icon="ep:plus" class="mr-5px" /> 床位对调-->
-<!--        </el-button>-->
-<!--      </template>-->
+    <TabBarBtn @add="openForm()">
+      <template #next>
+        <el-button :loading="exportLoading" plain type="success" @click="handleExport">
+          <Icon class="mr-5px" icon="ep:download" />
+          导出
+        </el-button>
+      </template>
+      <!--      <template #next>-->
+      <!--        <el-button-->
+      <!--          type="warning"-->
+      <!--          plain-->
+      <!--          @click="openExchangeForm()"-->
+      <!--          v-hasPermi="['bed-change:exchange']"-->
+      <!--        >-->
+      <!--          <Icon icon="ep:plus" class="mr-5px" /> 床位对调-->
+      <!--        </el-button>-->
+      <!--      </template>-->
     </TabBarBtn>
     <Table2
       v-loading="loading"
@@ -91,9 +106,11 @@ import { getBedChangePage } from '@/api/elderly/elder/bed-change'
 import { BedChangeColumns } from '../column'
 import { cancelprocessInstance } from '@/api/elderly/common'
 import Form from './Form.vue'
-import ExchangeForm from './ExchangeForm.vue'
+import download from '@/utils/download'
+const exportLoading = ref(false) // 列表的加载中
 import Detail from './Detail.vue'
 import { useUserStore } from '@/store/modules/user'
+import { exportCustomerBed } from '@/api/elderly/apply/check-in'
 defineOptions({ name: 'BedChange' })
 const userStore = useUserStore()
 const message = useMessage() // 消息弹窗
@@ -124,6 +141,21 @@ const getList = async () => {
   }
 }
 
+/** 导出按钮操作 */
+const handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    // 发起导出
+    exportLoading.value = true
+    const data = await exportCustomerBed(queryParams)
+    download.excel(data, '床位变更.xls')
+  } catch {
+  } finally {
+    exportLoading.value = false
+  }
+}
+
 /** 搜索按钮操作 */
 const handleQuery = async () => {
   if (!queryFormRef.value) return
@@ -151,7 +183,7 @@ const resetQuery = () => {
 /** 添加/修改操作 */
 const formRef = ref()
 const openForm = (row: any = {}) => {
-  if(queryParams.tenantIds.length == 0 || queryParams.tenantIds.length > 1 && !row.tenantId){
+  if (queryParams.tenantIds.length == 0 || (queryParams.tenantIds.length > 1 && !row.tenantId)) {
     message.error('新增只能选择一个机构')
     return
   }
@@ -184,7 +216,7 @@ const handleDelete = async (id: number) => {
 const route = useRoute()
 /** 初始化 **/
 onMounted(() => {
-  if(route.query && route.query.elderName){
+  if (route.query && route.query.elderName) {
     queryParams.elderName = route.query.elderName as string
   }
   getList()

+ 30 - 5
src/views/elderly/elder/nurse-change/index.vue

@@ -9,7 +9,7 @@
       :inline="true"
       label-width="80px"
     >
-      <TenantSelect v-model="queryParams.tenantIds" placeholder="请选择机构名称" prop="tenantIds"/>
+      <TenantSelect v-model="queryParams.tenantIds" placeholder="请选择机构名称" prop="tenantIds" />
       <el-form-item @keyup.enter="handleQuery" label="长者姓名" prop="elderName">
         <el-input v-model="queryParams.elderName" placeholder="请输入长者姓名" class="!w-240px" />
       </el-form-item>
@@ -32,7 +32,14 @@
 
   <!-- 列表 -->
   <ContentWrap>
-    <TabBarBtn @add="openForm()" />
+    <TabBarBtn @add="openForm()">
+      <template #next>
+        <el-button :loading="exportLoading" plain type="success" @click="handleExport">
+          <Icon class="mr-5px" icon="ep:download" />
+          导出
+        </el-button>
+      </template>
+    </TabBarBtn>
     <Table2
       v-loading="loading"
       :data="list"
@@ -63,9 +70,12 @@ import { cancelprocessInstance } from '@/api/elderly/common'
 import { useUserStore } from '@/store/modules/user'
 import Form from './Form.vue'
 import Detail from './Detail.vue'
+import { exportCustomer } from '@/api/elderly/apply/check-in'
 defineOptions({ name: 'NurseChange' })
 const userStore = useUserStore()
 const message = useMessage() // 消息弹窗
+import download from '@/utils/download'
+const exportLoading = ref(false) // 列表的加载中
 const { t } = useI18n() // 国际化
 const loading = ref(true) // 列表的加载中
 const total = ref(0) // 列表的总页数
@@ -100,6 +110,21 @@ const handleQuery = async () => {
   getList()
 }
 
+/** 导出按钮操作 */
+const handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    // 发起导出
+    exportLoading.value = true
+    const data = await exportCustomer(queryParams)
+    download.excel(data, '护理变更.xls')
+  } catch {
+  } finally {
+    exportLoading.value = false
+  }
+}
+
 /** 重置按钮操作 */
 const resetQuery = () => {
   queryFormRef.value.resetFields()
@@ -109,11 +134,11 @@ const resetQuery = () => {
 /** 添加/修改操作 */
 const formRef = ref()
 const openForm = (row: any = {}, detail: boolean = false) => {
-  if(queryParams.tenantIds.length == 0 || queryParams.tenantIds.length > 1 && !row.tenantId){
+  if (queryParams.tenantIds.length == 0 || (queryParams.tenantIds.length > 1 && !row.tenantId)) {
     message.error('新增只能选择一个机构')
     return
   }
-  formRef.value.open(row.tenantId || queryParams.tenantIds[0], row.id, detail, row.status,)
+  formRef.value.open(row.tenantId || queryParams.tenantIds[0], row.id, detail, row.status)
 }
 
 // 详情
@@ -142,7 +167,7 @@ const handleDelete = async (id: number) => {
 const route = useRoute()
 /** 初始化 **/
 onMounted(() => {
-  if(route.query && route.query.elderName){
+  if (route.query && route.query.elderName) {
     queryParams.elderName = route.query.elderName as string
   }
   getList()

+ 36 - 6
src/views/elderly/elder/price-change/index.vue

@@ -9,9 +9,14 @@
       :inline="true"
       label-width="80px"
     >
-    <TenantSelect v-model="queryParams.tenantIds" placeholder="请选择机构名称" prop="tenantIds"/>
+      <TenantSelect v-model="queryParams.tenantIds" placeholder="请选择机构名称" prop="tenantIds" />
       <el-form-item label="长者姓名" prop="elderName">
-        <el-input @keyup.enter="handleQuery" v-model="queryParams.elderName" placeholder="请输入长者姓名" class="!w-240px" />
+        <el-input
+          @keyup.enter="handleQuery"
+          v-model="queryParams.elderName"
+          placeholder="请输入长者姓名"
+          class="!w-240px"
+        />
       </el-form-item>
       <el-form-item label="审批状态" prop="status">
         <el-select v-model="queryParams.status" placeholder="请选择" class="!w-240px">
@@ -32,7 +37,14 @@
 
   <!-- 列表 -->
   <ContentWrap>
-    <TabBarBtn @add="openForm()" />
+    <TabBarBtn @add="openForm()">
+      <template #next>
+        <el-button :loading="exportLoading" plain type="success" @click="handleExport">
+          <Icon class="mr-5px" icon="ep:download" />
+          导出
+        </el-button>
+      </template>
+    </TabBarBtn>
     <Table2
       v-loading="loading"
       :data="list"
@@ -61,11 +73,14 @@ import { getPriceChangePage } from '@/api/elderly/elder/price-change'
 import { PriceChangeColumns } from '../column'
 import { cancelprocessInstance } from '@/api/elderly/common'
 import Form from './Form.vue'
+const message = useMessage() // 消息弹窗
+import download from '@/utils/download'
+const exportLoading = ref(false) // 列表的加载中
 import Detail from './Detail.vue'
 import { useUserStore } from '@/store/modules/user'
+import { exportCustomerPrice } from '@/api/elderly/apply/check-in'
 defineOptions({ name: 'PriceChange' })
 const userStore = useUserStore()
-const message = useMessage() // 消息弹窗
 const { t } = useI18n() // 国际化
 const loading = ref(true) // 列表的加载中
 const total = ref(0) // 列表的总页数
@@ -92,6 +107,21 @@ const getList = async () => {
   }
 }
 
+/** 导出按钮操作 */
+const handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    // 发起导出
+    exportLoading.value = true
+    const data = await exportCustomerPrice(queryParams)
+    download.excel(data, '价格变更.xls')
+  } catch {
+  } finally {
+    exportLoading.value = false
+  }
+}
+
 /** 搜索按钮操作 */
 const handleQuery = async () => {
   if (!queryFormRef.value) return
@@ -110,7 +140,7 @@ const resetQuery = () => {
 /** 添加/修改操作 */
 const formRef = ref()
 const openForm = (row: any = {}) => {
-  if(queryParams.tenantIds.length == 0 || queryParams.tenantIds.length > 1 && !row.tenantId){
+  if (queryParams.tenantIds.length == 0 || (queryParams.tenantIds.length > 1 && !row.tenantId)) {
     message.error('新增只能选择一个机构')
     return
   }
@@ -143,7 +173,7 @@ const handleDelete = async (id: number) => {
 const route = useRoute()
 /** 初始化 **/
 onMounted(() => {
-  if(route.query && route.query.elderName){
+  if (route.query && route.query.elderName) {
     queryParams.elderName = route.query.elderName as string
   }
   getList()

+ 32 - 4
src/views/elderly/elder/wait-elder/index.vue

@@ -9,9 +9,15 @@
       :inline="true"
       label-width="80px"
     >
-      <TenantSelect v-model="queryParams.tenantIds" placeholder="请选择机构名称" prop="tenantIds"/>
+      <TenantSelect v-model="queryParams.tenantIds" placeholder="请选择机构名称" prop="tenantIds" />
       <el-form-item label="长者姓名" prop="elderName">
-        <el-input @keyup.enter="handleQuery" clearable v-model="queryParams.elderName" placeholder="请输入长者姓名" class="!w-240px" />
+        <el-input
+          @keyup.enter="handleQuery"
+          clearable
+          v-model="queryParams.elderName"
+          placeholder="请输入长者姓名"
+          class="!w-240px"
+        />
       </el-form-item>
       <el-form-item label="审批状态" prop="bpmStatus">
         <el-select v-model="queryParams.bpmStatus" placeholder="请选择" class="!w-240px" clearable>
@@ -32,6 +38,11 @@
 
   <!-- 列表 -->
   <ContentWrap>
+    <el-button :loading="exportLoading" plain type="success" @click="handleExport" style="margin-bottom: 10px">
+      <Icon class="mr-5px" icon="ep:download" />
+      导出
+    </el-button>
+
     <Table2
       v-loading="loading"
       :data="list"
@@ -49,18 +60,20 @@
     />
   </ContentWrap>
 
-
   <Detail ref="detailRef" />
 </template>
 <script lang="ts" setup>
-
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import { getCheckInWaitPage } from '@/api/bpm/processInstance'
 import { WaitElderColumns } from '../column'
 import Detail from './Detail.vue'
 import { useUserStore } from '@/store/modules/user'
 defineOptions({ name: 'WaitElder' })
+import download from '@/utils/download'
+import { exportCustomer, exportCustomerWait } from '@/api/elderly/apply/check-in'
 const userStore = useUserStore()
+const message = useMessage() // 消息弹窗
+const exportLoading = ref(false) // 列表的加载中
 const loading = ref(true) // 列表的加载中
 const total = ref(0) // 列表的总页数
 const list = ref([]) // 列表的数据
@@ -73,6 +86,21 @@ const queryParams = reactive({
 })
 const queryFormRef = ref() // 搜索的表单
 
+/** 导出按钮操作 */
+const handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    // 发起导出
+    exportLoading.value = true
+    const data = await exportCustomerWait(queryParams)
+    download.excel(data, '轮候长者.xls')
+  } catch {
+  } finally {
+    exportLoading.value = false
+  }
+}
+
 /** 查询列表 */
 const getList = async () => {
   loading.value = true