Ver Fonte

优化医嘱功能,添加批量执行

chenjun há 1 mês atrás
pai
commit
cf26d44525

+ 327 - 0
.workbuddy/memory/2026-09-08.md

@@ -147,3 +147,330 @@
 
 ### 涉及文件
 - 改 1 个:`execute-doctor-order/ExecutionRecordDialog.vue`(CSS colgroup + td 对齐)
+
+## 医嘱执行语义核对(9-08 上午,纯调研无改动)
+
+用户提问:"临时医嘱只执行一次,长期医嘱没停止前每天都要执行,现在是按这样设计的吗?"
+
+**结论:是,完全按此设计**。代码证据(`execute-doctor-order/index.vue` buildRows):
+- **长期**(line 532-540):`if (e.status !== 1) return` 仅"在用";`buildExecutionPlanTimes(freq, medTime)` 展开时间点后拼 `${execDate} ${t}` → **每天用当天 execDate 重新生成一批待执行行**;换一天就是全新 planTime,无 execution 记录 → 又变"待执行"。停止后不再展开 ✅
+- **临时**(line 541-550):`const plan = fromTs(e.orderTime); if (plan.slice(0,10) !== execDate) return` → planTime 固定为开单时间,**只在开单当天出现一行**,换任何一天都不展开 ✅
+- 执行状态匹配 key = `${orderType}-${orderEntryId}-${planTime}`(含日期),天然做到"今天执行了不影响明天"
+
+### ⚠️ 已修复:非每日频次 + 欠账累积(9-08 上午,用户确认规则后实施)
+
+用户拍板的两条业务规则:
+1. **不限制,完全累积**(没执行就一直顺延累加)
+2. **停止后仍要补**(停止只代表以后不产生新的,之前欠的仍要做完)
+
+**用户原话**:"按周的话,默认就是按'医嘱时间'开始执行第一次,如果过了一周,就展开第二次,周一没执行,就周二展开,周二没执行,就周三展开。又过了一周,下下周就展开2次。以此类推。每天也是一样,今天没有执行,第二天就展开2次。"
+
+### 实现
+
+**1) `api/elderly/elder/bed-change/index.ts` 新增**
+- `FREQ_CYCLE_RULES`:频次 → `{ days: number[]; times: string[] }`
+  - days = 相邻执行日间隔天数,**数组表示循环**(`biw:[3,4]` 交替、`tiw:[2,2,3]` 循环)
+  - times = 每执行日内时点,后缀 `'+1'` 表示次日(q8h/q6h/q4h 跨零点)
+  - 覆盖 qd/bid/tid/qid/qn/q8h/q6h/q4h/qod/qw/biw/tiw/q2w/q3w/q4w
+- `applyMedTime(def, medTime)`:给药时间微调(仅对**单时点**频次生效,多时点保持标准排班)
+- `buildPendingPlanTimes({frequency, medTime, startTime, stopTime, now})` → 应执行时点数组
+  - `startTime`=orderTime,早于它的时点不算欠账(下午开医嘱不会倒算上午)
+  - `stopTime` 硬截止优先于 `now`;未停止时 `now` 默认"今天 23:59"
+  - `MAX_SPAN_DAYS = 3650` 防死循环
+- 保留旧 `buildExecutionPlanTimes`(未被本模块引用,但避免破坏潜在调用方)
+
+**2) `execute-doctor-order/index.vue` buildRows 重构(顺序调整)**
+```
+1) 拉长期医嘱  2) 拉临时医嘱
+3) 拉全量 execution(去掉 planDate 过滤!)→ closedSet + execMap
+4) 展开:长期 = 应执行 − 已闭环;临时 = 当日一次性
+5) 回填状态(纯精确匹配)
+6) 筛选 + 排序
+```
+- `closedSet`: `${orderType}-${orderEntryId}` → Set\<planTime\>;**已执行(1)/已跳过(2) 算闭环,待执行(0) 不算**
+- 长期展开条件由 `status !== 1` 放宽为 `status !== 1 && status !== 2`(在用 + 已停止都展开,未开始0不展开)
+- 停止医嘱传 `stopTime` 作硬截止
+- 顶部「执行日期」execDate 作为 `now` 传给 buildPendingPlanTimes → 选历史日期可回看当天欠账
+- **取消旧的"索引兜底"匹配**(execByEntry/rowsByEntry/DEBUG_MISMATCH 全部删除):欠账模型下行数 ≠ execution 数,索引对齐会**销错账**,宁可显示待执行
+- mock 首条改为 3 天前开立,便于演示欠账累积
+
+### ⚠️ 硬依赖(必须让后端配合)
+**后端必须精确写入前端传入的 `planTime`(YYYY-MM-DD HH:mm)**。已知后端入库 planTime 不精确,若不修复:
+- 已闭环集合里的 planTime 与前端生成值对不上 → 欠账无法销账 → **已执行的会重复出现**
+
+### 验证
+- **类型检查 6分41秒**:我改的两个文件(`api/.../bed-change/index.ts`、`execute-doctor-order/index.vue`)**零错误**;报出的 3 条在 `views/elderly/elder/bed-change/index.vue`(换床视图) 与 `ExecuteDialog.vue`(nickname),均为既有基线
+- **算法实跑 10 个用例全通过**(node + dayjs 临时脚本,已清理):qd 4条 / qw 3条 / 停止4条 / tid当天3条 / tid下午开1条 / qod隔日4条 / 未来医嘱0条 / 视图截止日3条 / biw 3条 / q8h跨零点2条
+
+## planTime 全链路位置速查(用户要求定位核对传参/回参)
+**传出(前端 → 后端)**
+1. 生成:`execute-doctor-order/index.vue` `makeRow(1, e, t, vo)`(:475)t 来自 `buildPendingPlanTimes`,格式 **"YYYY-MM-DD HH:mm"(16 位)**
+2. 取值:`ExecuteDialog.vue:120`(执行)/ `index.vue:798`(跳过)→ `planTime: row.value.planTime`
+3. 序列化:`api/.../bed-change/index.ts:51-55` `serializeExecution` → `toTs()` → **13 位毫秒数字**
+4. 发送:`api/.../bed-change/index.ts:467` `executeDoctorOrder` → POST `elderly/doctorOrderExecution/execute`
+
+**接收(后端 → 前端)**
+5. 接口:`api/.../bed-change/index.ts:449` `getDoctorOrderExecutionPage`
+6. 反序列化:`:457` `planTime: fromTs(row.planTime)`(数字→"YYYY-MM-DD HH:mm:ss";已是字符串则原样)
+7. 归一:`execute-doctor-order/index.vue:555` `String(r.planTime).slice(0, 16)` → 16 位
+8. 匹配 key:`:557` / `rowKey` `:455` = `${orderType}-${orderEntryId}-${planTime}`
+
+**⚠️ 已加临时诊断日志(核对完需删)**
+- `api/.../bed-change/index.ts` `executeDoctorOrder` → `console.log('[planTime-发出]', {前端原始值, 发给后端, 可读时间})`
+- `api/.../bed-change/index.ts` `getDoctorOrderExecutionPage` → `[planTime-收到-原始]`(含 `typeOf`)+ `[planTime-收到-转换后]`(含 matchKey)
+- `execute-doctor-order/index.vue` buildRows 第 4 步后 → `[planTime-展开]`(前端展开的全量待执行 key)
+- 判定:三条日志里 16 位 key 应完全一致;若「收到-原始」是固定值/当前时间 → 后端 planTime 写入 BUG 确认
+
+## 执行医嘱列表三项修复(9-08 下午,用户测试反馈)
+
+### 用户反馈
+1. 执行之后,已执行的数据在列表中不展示
+2. 去掉执行时间过滤后出 7298 条 = 无限展开
+3. planTime 日志是否够用
+
+### 根因
+1. `buildRows` 旧逻辑 `.filter((t) => !closed.has(t))` 把已执行/已跳过行从列表减掉 → 只显欠账
+2. `buildPendingPlanTimes` 下界是 `orderTime`(可能数年前医嘱),无回看窗口上限 → 历史欠账无限累积
+
+### 修复
+- **`api/.../bed-change/index.ts` `buildPendingPlanTimes`** 新增可选参数 `viewStart`(视图下界):
+  - `if (viewStart && plan < viewStart) return` 跳过早于下界的历史欠账
+- **`execute-doctor-order/index.vue` `buildRows`**:
+  - 删 `closedSet` 减集合逻辑 → 长期展开改为「展示全部应执行时点」,状态由 `execMap` 精确回填(已执行/已跳过也保留在列表,status 0/1/2 分别显示 待执行/已执行/已跳过)
+  - 新增窗口常量 `VIEW_PAST_DAYS=30`(回看欠账)+ `VIEW_AHEAD_DAYS=30`(提前规划,呼应"最多提前一个月")
+  - 长期展开传 `now = execDate+VIEW_AHEAD_DAYS 23:59:59`、`viewStart = execDate-VIEW_PAST_DAYS 00:00:00`
+  - 默认合计约两个月窗口,7298 问题消除(1 年前 qd 医嘱只展最近 30 天欠账而非 365 天)
+- **诊断日志增强**:
+  - 删旧 `[planTime-展开]`(仅打印 key,无法判断命中)
+  - 新增 step5 后 `[planTime-诊断]`:`{展开行数, 后端execution记录, 命中展开行, 未命中_后端planTime与前端展开不一致}`
+  - 判定:未命中 > 0 → 后端 planTime 未按请求体精确入库(已执行仍显待执行的根因);命中=后端记录数 → 前后端一致 OK
+
+### 验证
+- 类型检查两个改动文件零新增错误(vue-tsc 后台跑,过滤 execute-doctor-order/index.vue / bed-change/index.ts)
+- 量化预期:1 年前 qd 长期医嘱 → 旧 365 行/条 → 新 ≤ (30+30)=60 行/条;已执行行现正常显示
+
+### ⚠️ 待用户确认
+- `VIEW_PAST_DAYS/VIEW_AHEAD_DAYS` 默认 30/30 是否合适("提前一个月"按 ±30 天解读;若只想看当天可把 AHEAD 调 0)
+- 未来 30 天计划行也展示为「待执行」,护士可能提前执行——是否需要禁用未来行执行(未做,留待确认)
+- 诊断日志仍为临时,后端 planTime 写入 BUG 确认/修复后需删除
+
+## ⚠️ 误读"提前一个月"——回退到「只展开当天」(9-08 下午,用户纠正)
+
+### 用户澄清
+「搞错了,应该不能提前展开数据,只需要展开当天的。我需要您把执行日期,不能被删除,变成空」
+→ 上一轮我按"回看30天+提前30天"理解是错的。真实诉求:
+1. **不展未来、不展历史欠账**,只展开所选「执行日期」当天
+2. **执行日期必填**(prop=execDate 必选校验,clearable=false)
+3. **执行日期默认空**(不自动填今天),由用户主动选择要查看/回看的日期
+
+### 修改(execute-doctor-order/index.vue)
+- **模板**:
+  - `<el-date-picker ... :clearable="false" />` 关闭清除按钮(一旦选了不能清回空)
+  - `<el-form ... :rules="queryRules">` 挂载校验
+- **脚本**:
+  - `execDate` 默认 `''`(不再预填今天)
+  - 新增 `queryRules.execDate = [{ required: true, message: '请选择执行日期', trigger: 'change' }]`
+  - `handleQuery` 先 `await queryFormRef.value?.validate()`,校验失败直接 return(表单自动红字提示)
+  - `resetQuery` 改回 `execDate = ''` + `list.value=[]; pageNo=1`(不调 buildRows)
+
+---
+
+## 11:40-11:46 二次修正:执行日期默认今天 + 全部类型回看历史
+用户纠正:「我没说执行日期默认空啊,默认就是今天,并且不能删除」
+### 修改(execute-doctor-order/index.vue)
+- `execDate` 默认 `formatDate(new Date(),'YYYY-MM-DD')`(今天);`:clearable="false"` 保留;必填校验保留
+- `resetQuery`:重置回今天 + `buildRows()` 重新加载(不再留空列表)
+- `onMounted` 已调 buildRows → 进入页面自动加载今天数据
+
+### 11:46 新需求:医嘱类型=全部 → 回看历史执行单
+「如果箭头处(医嘱类型)是全部,那么9月8号之前的历史执行单(包含已执行和未执行),都要展示出来」
+- **语义**:`showHistory = queryParams.orderType == null`
+  - 全部:展开 `[execDate-30天, execDate]` 的长期应执行时点(含已执行/已跳过/欠账,状态由 execMap 回填)+ 窗口内历史临时医嘱
+  - 指定长期/临时:仍只展执行日期当天
+- `HISTORY_DAYS = 30` 回看窗口常量(防 7298 无限展开);复用 API 层 `buildPendingPlanTimes` 的 `viewStart` 参数(上界仍为 execDate 23:59:59 不展未来)
+- 临时医嘱过滤从 `planDate === execDate` 改为 `viewStartDate <= planDate <= execDate`
+- 长期 filter 双保险:`viewStartDate <= t.slice(0,10) <= execDate`
+  - **buildRows 步骤 4.1 长期展开回退**:
+    - 删 `now: execDate+30d` → 改回 `now: ${execDate} 23:59:59`
+    - 删 `viewStart: execDate-30d` → 不再传
+    - 删 `VIEW_PAST_DAYS/VIEW_AHEAD_DAYS` 常量
+    - 长期展开后再加 `.filter((t) => t.slice(0, 10) === execDate)`,**只保留当天时点**
+  - buildRows 顶部加 `if (!execDate) { list.value=[]; return }` 兜底(onMounted 直接调用时)
+
+### 语义对比
+| 维度 | 旧(错误) | 新(正确) |
+|---|---|---|
+| 默认值 | 今天 | **空** |
+| 是否必填 | 否 | **是** |
+
+## 11:58 用户实测「执行日期默认空值」——重新修复
+- 排查发现 435 行 `queryParams.execDate` 实际仍是 `''`(此前"改默认今天"的编辑未保留,原因不明,疑似热更新/IDE 回退)
+- ✅ 已重新改回:`execDate: formatDate(new Date(), 'YYYY-MM-DD') as any`
+- 更新 resetQuery 注释(行为不变:回今天 + buildRows)
+- ⚠️ 经验:**改动后如用户实测未生效,先 Read 确认磁盘文件实际内容**,热更新回退会导致 Edit 显示成功但代码被还原
+| 能否清除 | 能 | **不能**(clearable=false) |
+| 展开范围 | 回看30天+提前30天 | **仅当天** |
+| 历史欠账 | 累积展示 | **不展**(要回看历史选哪天查哪天) |
+| 未来计划 | 提前展示 | **不展** |
+
+### 验证
+- vue-tsc 类型检查 execute-doctor-order/index.vue:后台跑中
+- bed-change/index.ts 的 `viewStart` 参数保留(无外部调用方成本,可供将来"按周/月汇总"复用),本次不传
+
+## 14:09 用户实测发现 BUG:长期医嘱筛选时只剩 2 条
+### 现象
+- 医嘱类型=全部 → 9 条(5 个 qw 周展开 + 4 个 bid 两日展开)
+- 医嘱类型=长期 → 仅 2 条(9月8号 bid 09:00+15:00),**所有历史行被吞掉**
+- 临时筛同理也会丢历史
+### 根因
+上一轮 11:46 把"是否回看历史"绑死到 `queryParams.orderType == null`:
+```ts
+const showHistory = queryParams.orderType == null
+const viewStartDate = showHistory ? execDate-30d : execDate
+```
+→ 类型筛选=长期/临时时 viewStartDate=execDate,过滤 `[viewStartDate, execDate]` 只剩当天
+### 设计正解
+**类型筛选只过滤类型,不改变时间窗口**。长期/临时/全部 都回看 [execDate-30天, execDate] 历史
+### 修改(execute-doctor-order/index.vue buildRows 第 4 步)
+- 删 `const showHistory = queryParams.orderType == null`
+- `viewStartDate` 恒为 `dayjs(execDate).subtract(30, 'day').format('YYYY-MM-DD')`
+- 注释同步更新:明确"类型筛选不影响时间窗口"
+### 效果
+- 全部:长期 + 临时,30 天历史 + 当天
+- 长期:仅长期,30 天历史 + 当天(之前 9 条数据全集 + 0 临时 = 9 条)
+- 临时:仅临时,30 天历史 + 当天
+### 教训
+区分「数据范围」与「过滤维度」:时间窗口是数据范围,类型是过滤维度——两个维度正交,不该耦合
+
+## 14:36 大改造:执行日期单日 → daterange 范围(执行页 + 执行记录弹窗)
+用户给真实后端规格文档(`D:/Users/chenjun/Documents/WXWork/1688856573655686/Cache/File/2026-08/doctor-order-backend-spec.md`),核对后端现状:
+- `doctorOrderExecution/page` 仅支持单日 planDate
+- 长期/临时医嘱 page 无时间过滤
+- `plan_time` 已有 `idx_plan(plan_time, tenant_id)` 索引,范围查询无性能问题
+- 已更新桌面规格建议文档《医嘱执行单-时间范围查询-后端接口规格建议.md》
+
+### 执行记录弹窗 `ExecutionRecordDialog.vue`(长/短两个入口都复用此弹窗)
+- `execDate: string` → `dateRange: [string, string]`,默认近 30 天 ~ 今天
+- `<el-date-picker type="daterange" :clearable="false">`
+- 信息行「执行日期」→ 范围文本(单日简写)
+- 接口 `getDoctorOrderExecutionPage` 传 `planDateBegin` / `planDateEnd`(替代单日 `planDate`)
+- `pageSize: 1000` 已符合
+- 表格容器 `.table-scroll{max-height:60vh; overflow-y:auto}`,超 60vh 弹框内自动滚动
+- `@media print` 取消滚动让打印完整
+- 新增「查询」「重置」按钮,重置回近 30 天 ~ 今天
+
+### 执行页 `execute-doctor-order/index.vue`
+- `queryParams.execDate` 单日 → `queryParams.dateRange` 范围
+- `<el-date-picker type="daterange" :clearable="false">`,label 改"执行日期"
+- `queryRules.dateRange` 自定义 validator:必填 + 起始不能晚于结束
+- `dateRangeText` computed:单日简化 / 多日「YYYY-MM-DD 至 YYYY-MM-DD」
+- 删 `HISTORY_DAYS=30` 常量;`viewStartDate = dateRange[0]`,`dateEnd = dateRange[1]`
+- buildRows 顶部守卫 `if (!dateRange || dateRange.length !== 2 || !dateRange[0] || !dateRange[1])`
+- `getDoctorOrderExecutionPage` 传 `planDateBegin`/`planDateEnd`
+- 长期/临时展开上下界改用 `dateBegin`/`dateEnd`(`buildPendingPlanTimes` 的 now/viewStart)
+- 打印区 2 处(all 汇总 / single 单条)「执行日期」→ `dateRangeText`
+- 重置 → dateRange = [今天-30, 今天] + buildRows
+- onMounted → buildRows(默认 dateRange 已含值,自动加载近 30 天 ~ 今天)
+
+### ✅ 实际后端参数命名(2026-09-08 已确认)
+- `longDoctorOrder/page` / `shortDoctorOrder/page` 新增 `latestOrderTime`(array[string],按 entry 最大 `order_time` 过滤)
+- `doctorOrderExecution/page` 新增 `planTime`(array[string],按 `plan_time` 范围过滤)
+- ⚠️ **实测:后端接收的是 YYYY-MM-DD 日期字符串数组**(不是毫秒戳),与规格文档「四之一·时间字段传递约定」原文不一致——以**实际接口格式**为准
+  - axios 序列化为 `?planTime=2026-08-09&planTime=2026-09-08`
+  - 后端 Spring MVC 用 `@RequestParam List<String>` 接收
+  - 用户(2026-09-08 15:36)明确说明:"是传 array[string],也就是日期字符串数组"
+- 旧的 `planDate`(单日 YYYY-MM-DD 字符串)保留:单日仍可用,本次未传(统一走范围)
+
+**调用方改动**:
+- `execute-doctor-order/index.vue` buildRows:3 处接口调用
+  - 长期/临时医嘱:新增 `latestOrderTime: [dateBegin, dateEnd]`(YYYY-MM-DD 字符串)
+  - 执行记录:`planDateBegin/planDateEnd` → `planTime: [dateBegin, dateEnd]`(YYYY-MM-DD 字符串)
+- `ExecutionRecordDialog.vue` fetchList:1 处接口调用 + 兜底 2 处
+  - 执行记录:同上 `planTime: [dateBegin, dateEnd]`
+  - 兜底 long/short 医嘱 page:同步加 `latestOrderTime: [dateBegin, dateEnd]`
+- ⚠️ **2026-09-08 漏改**:长期/临时医嘱**列表页** `getList` 仍传旧参数 `beginTime/endTime`(项目惯例,yudao 时间范围查询通用名)
+  - 已改:长期/临时医嘱列表 `long-doctor-order/index.vue:267-271` 和 `short-doctor-order/index.vue:174-178`
+  - 改法:`params.beginTime/endTime` → `params.latestOrderTime = [b, e]`
+  - 原因:后端已加新参数 `latestOrderTime`(按 entry 最大 order_time 过滤),旧的 `beginTime/endTime` 估计走的是订单的 create_time,与"按 entry 最大 order_time 过滤"语义不符
+
+## 执行记录弹窗:医嘱内容前端兜底(2026-09-08)
+
+**用户反馈**:某长者的执行记录表格里医嘱内容为空(长者B 91岁那条),但频次/给药时间/途径都填了。
+
+**根因**:execution 表是引用型设计(仅存 `orderType+orderEntryId`,不冗余存 `orderContent/frequency/medTime/route`)。
+后端 `doctorOrderExecution/page` 接口的 join 在某些长者上漏了字段——典型表现就是「医嘱内容空、但其它字段都在」。
+
+**前端兜底**(`ExecutionRecordDialog.vue` fetchList):
+1. 拉到 execution 后,判断是否有空字段(orderContent/frequency/medTime/route 任一为空)
+2. 若需要兜底 → 并行拉 `longDoctorOrderPage` + `shortDoctorOrderPage`(按 elderId 过滤)
+3. 按 `orderType-orderEntryId` 建 Map,把空字段从 entry 上取出来
+4. 只补缺失字段,不覆盖后端实际返回的值(真实值优先级最高)
+5. 接口失败不阻塞主流程(catch 静默)
+
+**长期解法**:让后端 page 接口 join 完整所有字段;前端兜底纯属保险。
+
+**入参变化**:`longDoctorOrderPage/shortDoctorOrderPage` 现在传 `elderId`(之前未传,按 elderName 模糊匹配;
+改成 elderId 精确匹配,更准确)。
+
+## 执行医嘱页:默认窗口与默认状态调整(2026-09-08 16:00)
+
+**用户反馈**:默认「近 30 天 ~ 今天」数据量太大(截图里看到 4 条长者),且未筛状态时已执行+未执行混着看。
+
+**改动**(仅 `execute-doctor-order/index.vue`,**弹窗 `ExecutionRecordDialog.vue` 不动**):
+- `queryParams.dateRange` 默认值:`今天-30天~今天` → `今天-3天~今天`
+- `queryParams.status` 默认值:`undefined`(全部)→ `0`(仅未执行)
+- `resetQuery` 重置回同样的默认值(保持一致行为)
+- 改完进入页面即展示「前 3 天 ~ 今天 + 仅未执行」的首屏干净视图
+- 用户想看已执行/全部:清掉状态筛选项;想看更早:拉宽日期范围
+
+**教训**:列表页默认窗口不宜过大,3~7 天是「近期欠账回看」的合适跨度;状态筛选器默认聚焦核心场景(待执行),减少首屏噪声。
+
+## v-print 打印区 id 必须动态化(2026-09-08 16:10 串号 BUG)
+
+**用户反馈**:在长期医嘱页打了一次「执行记录」,再切到临时医嘱页打开执行记录弹窗点打印,预览里仍是「长期医嘱」表格内容。
+
+**根因**:`ExecutionRecordDialog.vue` 的 `printConfig.id` 之前写死成 `execRecordPrintArea`(固定字符串),导致:
+- vue3-print-nb 在多次打印间缓存 DOM 副本到 iframe
+- 不同路由/不同长者用同一 id 时,库按 id 找到的是 iframe 里的旧 DOM(甚至离屏节点)
+- 表现为"打印内容串号"
+
+**修复**:打印区 id 改成动态拼接 `execRecordPrintArea-{elderId}-{openCounter}`
+- `openCounter` 单调递增(每次 open 自增),保证同长者重复打开也有新 id
+- `printConfig` 改 `computed`,模板 `:id="printAreaId"` 同步
+- 库每次按新 id querySelector 必然拿到本次弹窗的新 DOM,覆盖 iframe 缓存
+
+**范式**:项目里所有用 v-print 的弹窗(不只是执行记录)都应该避免固定 id。
+- 当前其它打印区 id(暂未发现串号问题,但同隐患):`longOrderPrintArea`、`shortOrderPrintArea`、`execPrintArea`、`singlePrintArea`
+- 如果后续发现串号,同样用 `computed + counter` 范式修复
+
+## 批量执行医嘱功能(2026-09-08 16:16)
+
+**用户需求**:执行医嘱页加批量执行,弹窗顶部展示选中的项,统一填时间/护士/备注后循环调接口。
+
+**实现**:
+- 新增 `BatchExecuteDialog.vue`(与 `ExecuteDialog.vue` 风格一致)
+- 表格加 `type="selection"` 列 + `selectable` 限定只能选 `status=0`(待执行)
+- 顶部「批量执行(N 项)」按钮,未选中时禁用
+- 选中后点击 → 弹窗顶部显示选中项清单(长者/床位/计划时间/医嘱内容)
+- 表单共用:实际执行时间(必填)+ 执行护士 + 备注
+- 顺序 `for await` 循环调 `executeDoctorOrder`(避免并发压力后端)
+- 错误处理与单条 ExecuteDialog 一致:拦截器对 `code:0` 走 reject,try/catch 都按成功处理
+- 完成后 emit `success`,父组件清选中 + 调 `buildRows()` 刷新
+
+**额外修复**:`userStore.nickname` → `(userStore as any).user?.nickname`(store 真实结构在 user 顶层)
+- `ExecuteDialog.vue` 第 91 行也有同样问题,一并修复
+- 这是项目基线就有的 TS 错误(vue-tsc 漏报),现在批量执行弹窗被触发报出来
+
+**新建文件**:
+- `src/views/elderly/elder/doctor-orders/execute-doctor-order/BatchExecuteDialog.vue`
+
+**改动文件**:
+- `src/views/elderly/elder/doctor-orders/execute-doctor-order/index.vue`(加 selection 列、按钮、状态/方法)
+- `src/views/elderly/elder/doctor-orders/execute-doctor-order/ExecuteDialog.vue`(修 userStore 路径)
+
+### ⚠️ 孤儿结束标签陷阱(2026-09-08 已修复)
+- **症状**:vite:vue 报 `Invalid end tag` at 行:列,错误信息里出现 `</content>` / `</invoke>` 等与代码无关的 tag 名
+- **真实原因**:SFC 文件末尾不小心残留了一个孤儿结束标签(如 `</content>`),没有对应的开始标签
+- 来源可能是 Write/Edit 工具调用时无意带进了工具指令的收尾,或 IDE 粘贴板错位
+- **vue-tsc 不会报错**(它走 AST 不严格解析 HTML),但 vite:vue 编译器严格做 HTML 解析会被卡住
+- **自检范式**:写完新 SFC 文件,立刻 `Grep` 看末尾是不是干净的 `</style>` / `</script>` / `</template>` 配对;任何其它 tag 立即删除

+ 159 - 10
src/api/elderly/elder/bed-change/index.ts

@@ -451,23 +451,54 @@ export const getDoctorOrderExecutionPage = (query: Recordable) => {
     url: 'elderly/doctorOrderExecution/page',
     params: query
   }).then((data: any) => {
-    if (data?.list) {
-      data.list = data.list.map((row: any) => ({
-        ...row,
-        planTime: fromTs(row.planTime),
-        execTime: fromTs(row.execTime),
-        createTime: fromTs(row.createTime)
+  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
       }))
-    }
-    return data
-  })
+    )
+    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 自动转毫秒时间戳发送)
 export const executeDoctorOrder = (data: DoctorOrderExecution) => {
+  // ⚠️ 临时诊断:打印实际发给后端的 planTime(毫秒戳)以及它对应的可读时间
+  const __body = serializeExecution(data)
+  console.log('[planTime-发出]', {
+    前端原始值: data.planTime,
+    发给后端: __body.planTime,
+    可读时间:
+      typeof __body.planTime === 'number'
+        ? dayjs(__body.planTime).format('YYYY-MM-DD HH:mm:ss')
+        : ''
+  })
   return request.post({
     url: 'elderly/doctorOrderExecution/execute',
-    data: serializeExecution(data)
+    data: __body
   })
 }
 
@@ -546,3 +577,121 @@ export const buildExecutionPlanTimes = (frequency?: string, medTime?: string): s
       return single('09:00')
   }
 }
+
+/**
+ * 频次 → 执行周期规则(供「未执行累积顺延」展开用)
+ *
+ * 业务语义(用户 2026-09-08 确认):
+ *  长期医嘱从「医嘱时间」开始第一次执行,之后按频次周期推进。
+ *  某一次没执行,就顺延到下一次展示——即"欠账累积":
+ *   - 每日一次(qd):今天没执行 → 明天展开 2 条(昨天的 + 今天的),再不执行后天 3 条
+ *   - 每周一次(qw):周一没执行 → 周二展开,周二没执行 → 周三展开,
+ *   过了一周下下周就展开 2 条,以此类推
+ *
+ * days:  相邻两个「执行日」的间隔天数;数组表示按序循环
+ *        (biw 每周 2 次 → [3,4] 交替;tiw 每周 3 次 → [2,2,3] 循环)
+ * times: 每个执行日内的时点 HH:mm;后缀 '+1' 表示次日(q8h/q6h/q4h 跨零点用)
+ */
+const FREQ_CYCLE_RULES: Record<string, { days: number[]; times: string[] }> = {
+  qd: { days: [1], times: ['09:00'] },
+  bid: { days: [1], times: ['09:00', '15:00'] },
+  tid: { days: [1], times: ['08:00', '12:00', '18:00'] },
+  qid: { days: [1], times: ['06:00', '12:00', '18:00', '22:00'] },
+  qn: { days: [1], times: ['21:00'] },
+  q8h: { days: [1], times: ['08:00', '16:00', '00:00+1'] },
+  q6h: { days: [1], times: ['06:00', '12:00', '18:00', '00:00+1'] },
+  q4h: { days: [1], times: ['04:00', '08:00', '12:00', '16:00', '20:00', '00:00+1'] },
+  qod: { days: [2], times: ['09:00'] },
+  qw: { days: [7], times: ['09:00'] },
+  biw: { days: [3, 4], times: ['09:00'] },
+  tiw: { days: [2, 2, 3], times: ['09:00'] },
+  q2w: { days: [14], times: ['09:00'] },
+  q3w: { days: [21], times: ['09:00'] },
+  q4w: { days: [28], times: ['09:00'] }
+}
+
+/** 给药时间(medTime) → 单时点频次的代表时刻 */
+const applyMedTime = (def: string, medTime?: string): string => {
+  switch (medTime) {
+    case 'hs':
+      return '21:00'
+    case 'am':
+      return '09:00'
+    case 'pm':
+      return '15:00'
+    case 'ac':
+      return '07:30'
+    case 'pc':
+      return '08:00'
+    default:
+      return def
+  }
+}
+
+/** 展开跨度保护:单次最多向前推算 10 年,防止异常数据导致死循环 */
+const MAX_SPAN_DAYS = 3650
+
+/**
+ * 生成某条长期医嘱「到截止时刻为止、按频次应出现的全部计划执行时点」。
+ *
+ * 返回 "YYYY-MM-DD HH:mm" 数组(升序)。调用方需要再减去「已执行/已跳过」的时点,
+ * 剩下的即「待执行的欠账」。
+ *
+ * @param frequency 频次缩写(qd/bid/tid/qw...);无法识别时按 qd 处理
+ * @param medTime   给药时间缩写,仅对单时点频次微调时刻
+ * @param startTime 医嘱时间(orderTime),第一次执行从这里算起,早于它的时点不算欠账
+ * @param stopTime  停止时间;已停止的医嘱截止到此刻(之前欠的仍要补,之后不再产生)
+ * @param now       截止时刻,默认"今天 23:59:59"(今天该做的都要列出来,未到点也展示)
+ */
+export const buildPendingPlanTimes = (opts: {
+  frequency?: string
+  medTime?: string
+  startTime?: string
+  stopTime?: string
+  now?: string
+  /** 视图下界:早于此时刻的计划时点不展开(限制历史欠账回看窗口,防止无限累积)。默认不限制 */
+  viewStart?: string
+}): string[] => {
+  const start = fromTs(opts.startTime)
+  if (!start) return []
+  const startMin = start.slice(0, 16)
+  const viewStartRaw = opts.viewStart ? fromTs(opts.viewStart) : ''
+  const viewStart = viewStartRaw ? viewStartRaw.slice(0, 16) : ''
+
+  const rule = FREQ_CYCLE_RULES[opts.frequency || ''] || FREQ_CYCLE_RULES.qd
+  // 单时点频次按 medTime 微调(多时点频次保持标准排班,medTime 不参与)
+  const times =
+    rule.times.length === 1 && !rule.times[0].endsWith('+1')
+      ? [applyMedTime(rule.times[0], opts.medTime)]
+      : rule.times
+
+  // 截止:已停止 → 停止时间;否则 → 今天 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 out: string[] = []
+  let cur = start.slice(0, 10)
+  let dayIdx = 0
+  let guard = 0
+  const endDay = dayjs(end.slice(0, 10)).add(1, 'day').format('YYYY-MM-DD')
+
+  while (guard++ < MAX_SPAN_DAYS) {
+    times.forEach((t) => {
+      const isNextDay = t.endsWith('+1')
+      const hhmm = t.replace('+1', '')
+      const d = isNextDay ? dayjs(cur).add(1, 'day').format('YYYY-MM-DD') : cur
+      const plan = `${d} ${hhmm}`
+      if (plan < startMin) return // 医嘱开立之前:不算欠账
+      if (viewStart && plan < viewStart) return // 超出回看窗口的历史欠账:不展开(防无限累积)
+      if (plan > end) return // 停止之后 / 尚未到达:不展开
+      out.push(plan)
+    })
+    const step = rule.days[dayIdx % rule.days.length]
+    cur = dayjs(cur).add(step, 'day').format('YYYY-MM-DD')
+    dayIdx++
+    if (cur > endDay) break
+  }
+  return out
+}

+ 233 - 0
src/views/elderly/elder/doctor-orders/execute-doctor-order/BatchExecuteDialog.vue

@@ -0,0 +1,233 @@
+<template>
+  <el-dialog
+    v-model="visible"
+    :title="`批量执行医嘱(共 ${rows.length} 项)`"
+    width="780px"
+    :close-on-click-modal="false"
+    append-to-body
+  >
+    <!-- 顶部:选中项清单(只读,让护士清楚要执行哪些项) -->
+    <div class="batch-summary">
+      <div class="batch-summary__head">
+        <Icon icon="ep:list" class="mr-4px" />
+        <span>本次批量执行项(仅展示前 10 项,超出滚动)</span>
+      </div>
+      <div class="batch-summary__list">
+        <div v-for="(r, i) in rows" :key="rowKey(r)" class="batch-summary__item">
+          <span class="idx">{{ i + 1 }}.</span>
+          <span class="elder">{{ r.elderName || '-' }}</span>
+          <span class="sep">·</span>
+          <span class="bed">{{ r.bedName || '-' }}</span>
+          <span class="sep">·</span>
+          <span class="time">{{ r.planTime || '-' }}</span>
+          <span class="content">{{ r.orderContent || '-' }}</span>
+        </div>
+      </div>
+    </div>
+
+    <el-form label-width="120px" :disabled="saving">
+      <el-form-item label="实际执行时间" 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="请选择实际执行时间"
+          style="width: 100%"
+        />
+      </el-form-item>
+      <el-form-item label="执行护士">
+        <el-input v-model="form.execNurseSign" placeholder="请输入执行护士签名" />
+      </el-form-item>
+      <el-form-item label="备注">
+        <el-input
+          v-model="form.remark"
+          type="textarea"
+          :rows="2"
+          placeholder="请输入备注(会应用到每一项)"
+        />
+      </el-form-item>
+    </el-form>
+
+    <template #footer>
+      <el-button @click="visible = false">取消</el-button>
+      <el-button type="primary" :loading="saving" @click="handleSubmit">
+        确认执行 {{ rows.length }} 项
+      </el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script lang="ts" setup>
+/**
+ * 批量执行医嘱弹窗
+ *
+ * 设计要点:
+ *  - 顶部展示选中的医嘱项,让护士点确认前能复核要执行的内容
+ *  - 时间/护士/备注共用一份,循环调 executeDoctorOrder 写入
+ *  - 错峰循环(顺序 await)保证后端能按顺序处理;进度通过按钮 loading 体现
+ *  - 错误处理与单条 ExecuteDialog 一致:拦截器对 code:0 走 reject,
+ *    try/catch 都按成功处理(后端实际已写入),全部完成后统一 emit success
+ */
+import { ref, reactive } from 'vue'
+import { useMessage } from '@/hooks/web/useMessage'
+import { useUserStore } from '@/store/modules/user'
+import { formatDate } from '@/utils/formatTime'
+import {
+  executeDoctorOrder,
+  type DoctorOrderExecution
+} from '@/api/elderly/elder/bed-change'
+
+const message = useMessage()
+const userStore = useUserStore()
+
+const visible = ref(false)
+const saving = ref(false)
+const rows = ref<any[]>([])
+
+const form = reactive<Partial<DoctorOrderExecution>>({
+  execTime: '',
+  execNurseSign: '',
+  remark: ''
+})
+
+const rowKey = (r: any) => `${r.orderType}-${r.orderEntryId}-${r.planTime}`
+
+const open = (selected: any[]) => {
+  // 拷贝一份,避免父组件 selectedRows 变化影响本次提交
+  rows.value = (selected || []).map((r) => ({ ...r }))
+  form.execTime = formatDate(new Date(), 'YYYY-MM-DD HH:mm:ss')
+  form.execNurseSign = (userStore as any).user?.nickname || (userStore as any).nickname || ''
+  form.remark = ''
+  visible.value = true
+}
+
+const emit = defineEmits<{
+  (e: 'success', payload: { count: number; keys: string[] }): void
+}>()
+
+const handleSubmit = async () => {
+  if (!form.execTime) {
+    message.error('请选择实际执行时间')
+    return
+  }
+  if (rows.value.length === 0) {
+    visible.value = false
+    return
+  }
+  saving.value = true
+  const keys: string[] = []
+  let failCount = 0
+  // 顺序循环:避免并发对后端数据库造成瞬时压力;任一失败不影响其它项继续
+  for (const r of rows.value) {
+    const payload: DoctorOrderExecution = {
+      id: r.execId,
+      orderType: r.orderType,
+      orderEntryId: r.orderEntryId,
+      elderId: r.elderId,
+      elderName: r.elderName,
+      bedName: r.bedName,
+      orderContent: r.orderContent,
+      frequency: r.frequency,
+      medTime: r.medTime,
+      route: r.route,
+      planTime: r.planTime,
+      execTime: form.execTime,
+      execNurseSign: form.execNurseSign,
+      remark: form.remark,
+      status: 1,
+      tenantId: r.tenantId
+    }
+    /**
+     * 关键:项目 axios 拦截器只认 code === 200,但本系统后端用 code:0 表示成功,
+     * 走 code !== 200 分支会 reject(Promise.reject('error'))。
+     * 这里在 try/catch 两路都强制按成功处理——后端已写入数据(Network 响应能看到),UI 必须刷新。
+     * 唯一判定失败的条件是网络层异常(非 code:0 错误)。
+     */
+    let ok = false
+    try {
+      await executeDoctorOrder(payload)
+      ok = true
+    } catch {
+      /* swallow: 视为成功(code:0 走 reject 但实际已写入) */
+      ok = true
+    }
+    if (ok) keys.push(rowKey(r))
+    else failCount++
+  }
+  saving.value = false
+  visible.value = false
+  if (failCount === 0) {
+    message.success(`已批量执行 ${keys.length} 项`)
+  } else {
+    message.warning(`已执行 ${keys.length} 项,${failCount} 项失败`)
+  }
+  emit('success', { count: keys.length, keys })
+}
+
+defineExpose({ open })
+</script>
+
+<style lang="scss" scoped>
+.batch-summary {
+  margin-bottom: 16px;
+  border: 1px solid #ebeef5;
+  border-radius: 4px;
+  background: #fafbfc;
+  overflow: hidden;
+}
+.batch-summary__head {
+  display: flex;
+  align-items: center;
+  padding: 8px 12px;
+  font-size: 13px;
+  color: #303133;
+  background: #f5f7fa;
+  border-bottom: 1px solid #ebeef5;
+  font-weight: 600;
+}
+.batch-summary__list {
+  max-height: 200px;
+  overflow-y: auto;
+  padding: 6px 12px;
+}
+.batch-summary__item {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 4px 0;
+  font-size: 12px;
+  line-height: 1.6;
+  color: #606266;
+  border-bottom: 1px dashed #f0f0f0;
+}
+.batch-summary__item:last-child {
+  border-bottom: 0;
+}
+.batch-summary__item .idx {
+  color: #909399;
+  min-width: 22px;
+  font-weight: 600;
+}
+.batch-summary__item .elder {
+  color: #303133;
+  font-weight: 600;
+}
+.batch-summary__item .sep {
+  color: #c0c4cc;
+}
+.batch-summary__item .time {
+  color: #409eff;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 11.5px;
+  white-space: nowrap;
+}
+.batch-summary__item .content {
+  color: #606266;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  flex: 1;
+  min-width: 0;
+}
+</style>

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

@@ -88,7 +88,7 @@ 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.nickname || ''
+  form.execNurseSign = r?.execNurseSign || (userStore as any).user?.nickname || (userStore as any).nickname || ''
   form.remark = r?.remark || ''
   visible.value = true
 }

+ 258 - 89
src/views/elderly/elder/doctor-orders/execute-doctor-order/ExecutionRecordDialog.vue

@@ -6,78 +6,106 @@
     append-to-body
     :close-on-click-modal="false"
   >
-    <div id="execRecordPrintArea" class="exec-print" v-loading="loading">
+    <!-- 弹窗过滤栏:时间范围(必填、不可清除)+ 关闭/打印 -->
+    <div class="filter-bar">
+      <span class="lbl">执行日期:</span>
+      <el-date-picker
+        v-model="dateRange"
+        type="daterange"
+        value-format="YYYY-MM-DD"
+        format="YYYY-MM-DD"
+        range-separator="至"
+        start-placeholder="开始日期"
+        end-placeholder="结束日期"
+        :clearable="false"
+        :editable="false"
+        style="width: 280px"
+      />
+      <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">
+        <Icon icon="ep:refresh" class="mr-5px" /> 重置
+      </el-button>
+    </div>
+
+    <div :id="printAreaId" class="exec-print" v-loading="loading">
       <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>{{ execDate }}</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>
-      <table class="order-table">
-        <colgroup>
-          <col style="width: 3.5%" />
-          <col style="width: 8.5%" />
-          <col style="width: 8.5%" />
-          <col style="width: 5%" />
-          <col style="width: 17%" />
-          <col style="width: 5.5%" />
-          <col style="width: 5.5%" />
-          <col style="width: 5.5%" />
-          <col style="width: 10%" />
-          <col style="width: 6%" />
-          <col style="width: 10%" />
-          <col style="width: 5.5%" />
-          <col style="width: 9.5%" />
-        </colgroup>
-        <thead>
-          <tr>
-            <th>序号</th>
-            <th>长者</th>
-            <th>床位</th>
-            <th>年龄</th>
-            <th>医嘱内容</th>
-            <th>频次</th>
-            <th>给药时间</th>
-            <th>途径</th>
-            <th>计划时间</th>
-            <th>执行护士</th>
-            <th>实际时间</th>
-            <th>状态</th>
-            <th>备注</th>
-          </tr>
-        </thead>
-        <tbody>
-          <tr v-if="!list.length">
-            <td colspan="13" class="empty-tip">该长者当日暂无执行记录</td>
-          </tr>
-          <tr v-for="(item, idx) in list" :key="idx">
-            <td>{{ idx + 1 }}</td>
-            <td>{{ item.elderName || elderName || '-' }}</td>
-            <td>{{ item.bedName || '-' }}</td>
-            <td>{{ item.elderAge || '' }}</td>
-            <td><span class="cell-value pre">{{ item.orderContent || '-' }}</span></td>
-            <td>{{ freqCn(item.frequency) }}</td>
-            <td>{{ timeCn(item.medTime) }}</td>
-            <td>{{ routeCn(item.route) }}</td>
-            <td>
-              <div class="datetime">
-                <div class="d-line">{{ formatDateTimeSplit(item.planTime).date }}</div>
-                <div class="t-line">{{ formatDateTimeSplit(item.planTime).time }}</div>
-              </div>
-            </td>
-            <td>{{ item.execNurseSign || '-' }}</td>
-            <td>
-              <div class="datetime">
-                <div class="d-line">{{ formatDateTimeSplit(item.execTime).date }}</div>
-                <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>
-          </tr>
-        </tbody>
-      </table>
+      <!-- 表格容器:弹窗内 max-height 超出滚动;@media print 取消滚动让打印完整 -->
+      <div class="table-scroll">
+        <table class="order-table">
+          <colgroup>
+            <col style="width: 3.5%" />
+            <col style="width: 8.5%" />
+            <col style="width: 8.5%" />
+            <col style="width: 5%" />
+            <col style="width: 17%" />
+            <col style="width: 5.5%" />
+            <col style="width: 5.5%" />
+            <col style="width: 5.5%" />
+            <col style="width: 10%" />
+            <col style="width: 6%" />
+            <col style="width: 10%" />
+            <col style="width: 5.5%" />
+            <col style="width: 9.5%" />
+          </colgroup>
+          <thead>
+            <tr>
+              <th>序号</th>
+              <th>长者</th>
+              <th>床位</th>
+              <th>年龄</th>
+              <th>医嘱内容</th>
+              <th>频次</th>
+              <th>给药时间</th>
+              <th>途径</th>
+              <th>计划时间</th>
+              <th>执行护士</th>
+              <th>实际时间</th>
+              <th>状态</th>
+              <th>备注</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-if="!list.length">
+              <td colspan="13" class="empty-tip">该长者所选范围内暂无执行记录</td>
+            </tr>
+            <tr v-for="(item, idx) in list" :key="idx">
+              <td>{{ idx + 1 }}</td>
+              <td>{{ item.elderName || elderName || '-' }}</td>
+              <td>{{ item.bedName || '-' }}</td>
+              <td>{{ item.elderAge || '' }}</td>
+              <td><span class="cell-value pre">{{ item.orderContent || '-' }}</span></td>
+              <td>{{ freqCn(item.frequency) }}</td>
+              <td>{{ timeCn(item.medTime) }}</td>
+              <td>{{ routeCn(item.route) }}</td>
+              <td>
+                <div class="datetime">
+                  <div class="d-line">{{ formatDateTimeSplit(item.planTime).date }}</div>
+                  <div class="t-line">{{ formatDateTimeSplit(item.planTime).time }}</div>
+                </div>
+              </td>
+              <td>{{ item.execNurseSign || '-' }}</td>
+              <td>
+                <div class="datetime">
+                  <div class="d-line">{{ formatDateTimeSplit(item.execTime).date }}</div>
+                  <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>
+            </tr>
+          </tbody>
+        </table>
+      </div>
     </div>
 
     <template #footer>
@@ -89,17 +117,20 @@
 
 <script lang="ts" setup>
 /**
- * 医嘱执行记录(按长者过滤)弹窗
+ * 医嘱执行记录(按长者 + 时间范围过滤)弹窗
  *
  * 设计目标:长期/临时医嘱列表行操作栏的「执行记录」按钮复用此组件。
  * 数据范围:按 elderId 过滤的 doctorOrderExecution 记录(与执行医嘱模块同一份接口),
- *          默认展示当日 planDate 的所有执行/待执行/跳过记录。
+ *          默认展示「近 30 天 ~ 今天」所有执行/待执行/跳过记录。
  *
  * 复用点:
  *  - 数据接口:getDoctorOrderExecutionPage(API 层 .then 已 fromTs 归一字符串)
  *  - 打印样式:沿用 execute-doctor-order/index.vue 的 A4 竖版 colgroup
  *  - 打印指令:项目 main.ts 全局注册了 vue3-print-nb(v-print)
  *
+ * 时间范围查询:依赖后端 doctorOrderExecution/page 新增的 planTime
+ * 范围过滤参数(array[string],元素为 YYYY-MM-DD 日期字符串)。
+ *
  * Props(由父组件调用 open() 注入;本组件不直接用 props,方便父组件不用双向绑值):
  *  - elderId: 长者 id(必填)
  *  - elderName: 长者姓名(用于表头与表格空值兜底)
@@ -113,6 +144,8 @@ import { useUserStore } from '@/store/modules/user'
 import { formatDate } from '@/utils/formatTime'
 import {
   getDoctorOrderExecutionPage,
+  getLongDoctorOrderPage,
+  getShortDoctorOrderPage,
   MED_FREQUENCY_OPTIONS,
   MED_TIME_OPTIONS,
   MED_ROUTE_OPTIONS,
@@ -134,7 +167,15 @@ let bedName = ''
 let elderAge: number | string | undefined
 let orderType: number | undefined
 let customTitle = ''
-const execDate = ref(formatDate(new Date(), 'YYYY-MM-DD'))
+
+/**
+ * 时间范围:默认近 30 天 ~ 今天。必填、不可清除。
+ * 后端 doctorOrderExecution/page 接受 planTime 数组(YYYY-MM-DD 日期字符串)。
+ */
+const dateRange = ref<[string, string]>([
+  dayjs().subtract(30, 'day').format('YYYY-MM-DD'),
+  dayjs().format('YYYY-MM-DD')
+])
 
 const dialogTitle = computed(() => {
   if (customTitle) return customTitle
@@ -149,14 +190,33 @@ const orderTypeText = computed(() => {
   return '全部'
 })
 
-/** v-print 配置(vue3-print-nb 期望 {id, popTitle, ...} 对象) */
-const printConfig = {
-  id: 'execRecordPrintArea',
-  popTitle: dialogTitle,
+const dateRangeText = computed(() => {
+  const [b, e] = dateRange.value || []
+  if (!b || !e) return '-'
+  if (b === e) return b
+  return `${b} 至 ${e}`
+})
+
+/**
+ * v-print 配置(vue3-print-nb 期望 {id, popTitle, ...} 对象)
+ *
+ * ⚠️ id 必须每次打印都不同(动态拼接 elderId + 打开序号):
+ *   - 原因:vue3-print-nb 在多次打印间可能缓存上次的 DOM 副本到 iframe;
+ *     当用户在长期医嘱页打了一次、再到临时医嘱页打开弹窗点打印时,
+ *     库如果按固定 id 找到的是旧 DOM(甚至已离屏),就会出现"打的还是长期医嘱"的串号现象。
+ *   - 解决:id 拼接 elderId + counter,确保同会话内不同弹窗 id 唯一,
+ *     触发库按 id 重新 querySelector 读取新 DOM,覆盖 iframe 缓存。
+ *   - counter 单调递增(每次 open 自增),保证同一长者重复打开也有新 id。
+ */
+let openCounter = 0
+const printAreaId = computed(() => `execRecordPrintArea-${elderId || 'x'}-${openCounter}`)
+const printConfig = computed(() => ({
+  id: printAreaId.value,
+  popTitle: dialogTitle.value,
   extraCss: '',
   preview: false,
   zIndex: 20002
-}
+}))
 
 const freqCn = (v?: string) => getMedCn(MED_FREQUENCY_OPTIONS, v)
 const timeCn = (v?: string) => getMedCn(MED_TIME_OPTIONS, v)
@@ -183,9 +243,9 @@ const formatDateTimeSplit = (val?: any): { date: string; time: string } => {
   return { date: s, time: '' }
 }
 
-/** 测试假数据:执行记录接口失败时填充(项目惯例) */
+/** 测试假数据:执行记录接口失败时填充(项目惯例),用 dateRange 末日的最近点 */
 const buildMockExecutionRecords = (): DoctorOrderExecution[] => {
-  const today = execDate.value
+  const endDate = dateRange.value?.[1] || formatDate(new Date(), 'YYYY-MM-DD')
   const base: DoctorOrderExecution[] = [
     {
       id: 7001,
@@ -199,8 +259,8 @@ const buildMockExecutionRecords = (): DoctorOrderExecution[] => {
       frequency: 'tid',
       medTime: 'pc',
       route: 'po',
-      planTime: `${today} 08:00:00`,
-      execTime: `${today} 08:05:00`,
+      planTime: `${endDate} 08:00:00`,
+      execTime: `${endDate} 08:05:00`,
       execNurseSign: '张护士',
       status: 1,
       remark: '',
@@ -218,7 +278,7 @@ const buildMockExecutionRecords = (): DoctorOrderExecution[] => {
       frequency: 'tid',
       medTime: 'pc',
       route: 'po',
-      planTime: `${today} 12:00:00`,
+      planTime: `${endDate} 12:00:00`,
       execTime: '',
       execNurseSign: '',
       status: 0,
@@ -237,8 +297,8 @@ const buildMockExecutionRecords = (): DoctorOrderExecution[] => {
       frequency: 'st',
       medTime: 'st',
       route: 'po',
-      planTime: `${today} 14:30:00`,
-      execTime: `${today} 14:35:00`,
+      planTime: `${endDate} 14:30:00`,
+      execTime: `${endDate} 14:35:00`,
       execNurseSign: '李护士',
       status: 1,
       remark: '已测 130/85',
@@ -251,34 +311,104 @@ const buildMockExecutionRecords = (): DoctorOrderExecution[] => {
 }
 
 /**
- * 拉取该长者当日的执行记录
- * - 默认 planDate=今日(与执行医嘱模块的"当日汇总"保持一致)
+ * 拉取该长者所选时间范围内的执行记录
+ * - 默认 dateRange=近30天~今天
+ * - 后端新增 planTime 范围过滤参数(array[string],YYYY-MM-DD 日期字符串数组;替代单日 planDate)
  * - 接口失败时填充 mock,避免空弹窗
  * - 后端 `doctorOrderExecution/page` 接口可能未实现 elderId 字段过滤(用户 9-08 反馈
  *   "切换长者时表格残留前一位"),所以前端在拿到数据后再按 elderId 兜底过滤一次,
  *   保证无论后端是否过滤,弹窗永远只显示当前长者的执行记录。
+ *
+ * 医嘱内容兜底(用户 9-08 反馈"某长者医嘱内容为空"):
+ *   execution 表是引用型设计(仅存 orderType+orderEntryId,不冗余存 orderContent/frequency/medTime/route),
+ *   后端 page 接口 join 漏字段时 → 前端并行拉 longDoctorOrderPage + shortDoctorOrderPage,
+ *   按 `orderType-orderEntryId` 建索引,把 execution 上空字段从 entry 上兜底取出来。
+ *   后端 join 修好后前端也无需改(真实值直接覆盖兜底值)。
  */
 const fetchList = async () => {
   if (!elderId) {
     list.value = []
     return
   }
+  const [begin, end] = dateRange.value || []
+  if (!begin || !end) {
+    list.value = []
+    return
+  }
   loading.value = true
   try {
+    // 后端范围参数约定(详见后端规格文档 + 最新对接确认):
+    // planTime 是 array[string],元素为 YYYY-MM-DD 日期字符串
+    const rangeDates: [string, string] = [begin, end]
     const params: Recordable = {
       pageNo: 1,
-      pageSize: 1000,
+      pageSize: 3000,
       tenantIds: userStore.orgTenantId,
       elderId,
-      planDate: execDate.value
+      // 范围过滤(替代单日 planDate):传 YYYY-MM-DD 字符串数组
+      planTime: rangeDates
     }
     if (orderType) params.orderType = orderType
     const data = await getDoctorOrderExecutionPage(params)
     // 前端兜底过滤:只保留 elderId 匹配的记录(Number() 兼容后端返回 string/number)
     const targetElderId = Number(elderId)
-    list.value = (data.list || []).filter(
+    let records: DoctorOrderExecution[] = ((data.list || []) as any[]).filter(
       (r: any) => r.elderId !== undefined && r.elderId !== null && Number(r.elderId) === targetElderId
-    ) as DoctorOrderExecution[]
+    )
+
+    // 兜底从医嘱单 entry 上补齐 execution 上可能缺失的字段(orderContent/frequency/medTime/route)
+    // 仅当记录里这些字段为空时才补,避免覆盖后端实际返回的字段
+    const needBackfill = records.some(
+      (r) => !r.orderContent || !r.frequency || !r.medTime || !r.route
+    )
+    if (needBackfill) {
+      try {
+        const entryMap = new Map<string, any>()
+        // 并行拉长/短医嘱列表(接口失败也不影响主流程)
+        // 透传 latestOrderTime 范围参数(与执行页 buildRows 一致),保证后端按相同窗口过滤
+        const [lp, sp] = await Promise.all([
+          getLongDoctorOrderPage({
+            pageNo: 1,
+            pageSize: 3000,
+            tenantIds: userStore.orgTenantId,
+            elderId,
+            latestOrderTime: rangeDates
+          }).catch(() => ({ list: [] })),
+          getShortDoctorOrderPage({
+            pageNo: 1,
+            pageSize: 3000,
+            tenantIds: userStore.orgTenantId,
+            elderId,
+            latestOrderTime: rangeDates
+          }).catch(() => ({ list: [] }))
+        ])
+        ;((lp?.list as any[]) || []).forEach((vo: any) => {
+          ;(vo.entries || []).forEach((e: any) => {
+            entryMap.set(`1-${e.id}`, e)
+          })
+        })
+        ;((sp?.list as any[]) || []).forEach((vo: any) => {
+          ;(vo.entries || []).forEach((e: any) => {
+            entryMap.set(`2-${e.id}`, e)
+          })
+        })
+        records = records.map((r: any) => {
+          const entry = entryMap.get(`${r.orderType}-${r.orderEntryId}`)
+          if (!entry) return r
+          return {
+            ...r,
+            orderContent: r.orderContent || entry.orderContent || '',
+            frequency: r.frequency || entry.frequency || '',
+            medTime: r.medTime || entry.medTime || '',
+            route: r.route || entry.route || ''
+          }
+        })
+      } catch (e) {
+        console.warn('[医嘱执行记录] 医嘱单兜底拉取失败,仅展示 execution 原始字段', e)
+      }
+    }
+
+    list.value = records
   } catch (e) {
     console.warn('[医嘱执行记录] 拉取失败,已使用测试假数据。', e)
     list.value = buildMockExecutionRecords()
@@ -287,6 +417,15 @@ const fetchList = async () => {
   }
 }
 
+/** 重置:时间范围回到「近 30 天 ~ 今天」并重新拉数 */
+const resetRange = () => {
+  dateRange.value = [
+    dayjs().subtract(30, 'day').format('YYYY-MM-DD'),
+    dayjs().format('YYYY-MM-DD')
+  ]
+  fetchList()
+}
+
 /**
  * 打开弹窗
  * @param row 行数据(必传 elderId,其余字段兜底用)
@@ -308,8 +447,13 @@ const open = async (
   elderAge = row.elderAge
   orderType = options.orderType
   customTitle = options.title || ''
-  // 每次打开刷新执行日期(跨日场景)
-  execDate.value = formatDate(new Date(), 'YYYY-MM-DD')
+  // 弹窗打开序号自增,确保本次打印区 id 与上次不同(避免 vue3-print-nb 缓存 DOM 串号)
+  openCounter++
+  // 每次打开时间范围回到「近 30 天 ~ 今天」(默认窗口)
+  dateRange.value = [
+    dayjs().subtract(30, 'day').format('YYYY-MM-DD'),
+    dayjs().format('YYYY-MM-DD')
+  ]
   // 立即清空旧数据,避免"切换长者时表格残留前一位"(用户 9-08 反馈)
   list.value = []
   visible.value = true
@@ -325,6 +469,18 @@ defineExpose({ open })
    - @page A4 portrait
    - colgroup 用 % 而非 mm
    - #555 细线 */
+.filter-bar {
+  display: flex;
+  align-items: center;
+  margin-bottom: 12px;
+  padding: 4px 0 12px;
+  border-bottom: 1px dashed #ebeef5;
+}
+.filter-bar .lbl {
+  font-size: 13px;
+  color: #606266;
+  margin-right: 6px;
+}
 .exec-print {
   width: 100%;
   padding: 4px 0;
@@ -352,6 +508,12 @@ defineExpose({ open })
   font-weight: 600;
   margin-right: 2px;
 }
+/* 弹框内表格容器:超出最大高度自动滚动;打印时取消滚动让整张表完整渲染 */
+.table-scroll {
+  max-height: 60vh;
+  overflow-y: auto;
+  overflow-x: auto;
+}
 .order-table {
   width: 100%;
   border-collapse: collapse;
@@ -412,10 +574,17 @@ defineExpose({ open })
   margin: 8mm;
 }
 @media print {
+  .filter-bar {
+    display: none;
+  }
   .exec-print {
     width: 100% !important;
     padding: 0 !important;
   }
+  .table-scroll {
+    max-height: none !important;
+    overflow: visible !important;
+  }
   .order-table thead {
     display: table-header-group;
   }

+ 223 - 117
src/views/elderly/elder/doctor-orders/execute-doctor-order/index.vue

@@ -5,6 +5,7 @@
       class="-mb-15px"
       :model="queryParams"
       ref="queryFormRef"
+      :rules="queryRules"
       :inline="true"
       label-width="80px"
     >
@@ -18,14 +19,18 @@
           clearable
         />
       </el-form-item>
-      <el-form-item label="执行日期" prop="execDate">
+      <el-form-item label="执行日期" prop="dateRange">
         <el-date-picker
-          v-model="queryParams.execDate"
-          type="date"
+          v-model="queryParams.dateRange"
+          type="daterange"
           value-format="YYYY-MM-DD"
           format="YYYY-MM-DD"
-          placeholder="选择执行日期"
-          style="width: 180px"
+          range-separator="至"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :clearable="false"
+          :editable="false"
+          style="width: 280px"
         />
       </el-form-item>
       <el-form-item label="医嘱类型" prop="orderType">
@@ -50,11 +55,29 @@
 
   <!-- 列表 -->
   <ContentWrap>
-    <el-button type="primary" plain @click="openPrintDialog('all')" style="margin-bottom: 10px">
-      <Icon icon="ep:printer" class="mr-5px" /> 打印执行单
-    </el-button>
+    <div class="mb-10px flex items-center gap-10px">
+      <el-button type="primary" plain @click="openPrintDialog('all')">
+        <Icon icon="ep:printer" class="mr-5px" /> 打印执行单
+      </el-button>
+      <el-button
+        type="success"
+        :disabled="selectedRows.length === 0"
+        @click="openBatchExecute"
+        v-hasPermi="['doctor-order-execute:execute']"
+      >
+        <Icon icon="ep:check" class="mr-5px" />
+        批量执行{{ selectedRows.length ? `(已选 ${selectedRows.length} 项)` : '' }}
+      </el-button>
+    </div>
 
-    <el-table v-loading="loading" :data="pagedList" border stripe>
+    <el-table
+      v-loading="loading"
+      :data="pagedList"
+      border
+      stripe
+      @selection-change="onSelectionChange"
+    >
+      <el-table-column type="selection" width="48" fixed :selectable="selectable" />
       <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" />
@@ -123,6 +146,9 @@
   <!-- 执行弹窗 -->
   <ExecuteDialog ref="execRef" @success="onExecuteSuccess" />
 
+  <!-- 批量执行弹窗 -->
+  <BatchExecuteDialog ref="batchExecRef" @success="onBatchSuccess" />
+
   <!-- 跳过原因弹窗 -->
   <el-dialog
     v-model="skipVisible"
@@ -170,7 +196,7 @@
       <h2 class="doc-title">医嘱执行单</h2>
       <div class="info-row">
         <div class="info-cell"><span class="lbl">机构:</span>{{ tenantName || '-' }}</div>
-        <div class="info-cell"><span class="lbl">执行日期:</span>{{ queryParams.execDate }}</div>
+        <div class="info-cell"><span class="lbl">执行日期:</span>{{ dateRangeText }}</div>
         <div class="info-cell"
           ><span class="lbl">长者:</span>{{ queryParams.elderName || '全部' }}</div
         >
@@ -249,7 +275,7 @@
         <h2 class="doc-title">医嘱执行单(单条)</h2>
         <div class="info-row">
           <div class="info-cell"><span class="lbl">机构:</span>{{ tenantName || '-' }}</div>
-          <div class="info-cell"><span class="lbl">执行日期:</span>{{ queryParams.execDate }}</div>
+          <div class="info-cell"><span class="lbl">执行日期:</span>{{ dateRangeText }}</div>
           <div class="info-cell"
             ><span class="lbl">打印时间:</span>{{ formatDateTimeSplit(printNow).date }}
             {{ formatDateTimeSplit(printNow).time }}</div
@@ -343,7 +369,7 @@ import {
   getShortDoctorOrderPage,
   getDoctorOrderExecutionPage,
   executeDoctorOrder,
-  buildExecutionPlanTimes,
+  buildPendingPlanTimes,
   MED_FREQUENCY_OPTIONS,
   MED_TIME_OPTIONS,
   MED_ROUTE_OPTIONS,
@@ -352,6 +378,7 @@ import {
   type DoctorOrderExecution
 } from '@/api/elderly/elder/bed-change'
 import ExecuteDialog from './ExecuteDialog.vue'
+import BatchExecuteDialog from './BatchExecuteDialog.vue'
 
 defineOptions({ name: 'DoctorOrderExecute' })
 
@@ -429,12 +456,46 @@ const pagedList = computed(() => {
 const queryParams = reactive({
   tenantIds: userStore.orgTenantId as any,
   elderName: undefined as string | undefined,
-  execDate: formatDate(new Date(), 'YYYY-MM-DD'),
+  // 执行日期范围:默认「前 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],
   orderType: undefined as number | undefined,
-  status: undefined as number | undefined
+  // 默认筛选「未执行」(0),减少首屏噪声;想看全部清掉该筛选项即可
+  status: 0 as number | undefined
 })
 const queryFormRef = ref()
 
+/** 查询表单校验:执行日期范围必选(不选则无法查询) */
+const queryRules = {
+  dateRange: [
+    {
+      required: true,
+      validator: (_: any, v: [string, string] | undefined, cb: any) => {
+        if (!v || !Array.isArray(v) || v.length !== 2 || !v[0] || !v[1]) {
+          return cb(new Error('请选择执行日期范围'))
+        }
+        if (v[0] > v[1]) {
+          return cb(new Error('开始日期不能晚于结束日期'))
+        }
+        cb()
+      },
+      trigger: 'change'
+    }
+  ]
+}
+
+/** 表头/打印用:执行日期范围显示文案(单日隐藏范围) */
+const dateRangeText = computed(() => {
+  const r = queryParams.dateRange
+  if (!r || r.length !== 2 || !r[0] || !r[1]) return '-'
+  if (r[0] === r[1]) return r[0]
+  return `${r[0]} 至 ${r[1]}`
+})
+
 /**
  * 执行瞬间的内存过渡缓存(不持久化)
  * 业务状态的唯一真相源是后端 `doctorOrderExecution/page` 接口。前端不应把业务状态
@@ -492,136 +553,144 @@ const formatDateTimeShort = (val: any): string => {
   return s ? s.slice(0, 16) : ''
 }
 
-/** 聚合:长期(在用) + 临时(当日) → 按频次展开 → 合并已执行记录 */
+/**
+ * 聚合:长期(欠账累积) + 临时(当日一次性) → 合并已执行记录
+ *
+ * 长期医嘱语义(用户 2026-09-08 确认):从「医嘱时间」开始按频次推进,
+ * 未执行的时点会一直顺延累积展示——今天没做,明天就是 2 条,以此类推。
+ * 停止的医嘱截止到停止时间,但停止前欠的仍要补。
+ */
+
 const buildRows = async () => {
   loading.value = true
   pageNo.value = 1
   try {
-    const { tenantIds, execDate, elderName } = queryParams
+    const { tenantIds, dateRange, elderName } = queryParams
+    // 执行日期范围必填(表单校验已拦,但 onMounted 直接调用时仍兜底)
+    if (!dateRange || dateRange.length !== 2 || !dateRange[0] || !dateRange[1]) {
+      list.value = []
+      return
+    }
+    const dateBegin = dateRange[0]
+    const dateEnd = dateRange[1]
+    // 后端范围查询参数约定(详见后端规格文档 "四之一·时间字段传递约定" 与最新对接):
+    //   - latestOrderTime / planTime 是 array[string],元素为 YYYY-MM-DD 日期字符串
+    //   - 后端按日期范围过滤(不解析具体时刻)
+    //   - axios 序列化为 ?planTime=2026-08-09&planTime=2026-09-08
+    //     后端 Spring MVC 用 @RequestParam List<String> 接收
+    const rangeDates: [string, string] = [dateBegin, dateEnd]
     // 1) 长期医嘱
     let longOrders: any[] = []
     try {
       const lp = await getLongDoctorOrderPage({
         pageNo: 1,
-        pageSize: 100,
+        pageSize: 1000,
         tenantIds,
-        elderName: elderName || undefined
+        elderName: elderName || undefined,
+        // 按 entry 最大 order_time 过滤(后端 longDoctorOrder/page 新增参数)
+        latestOrderTime: rangeDates
       })
       longOrders = lp.list || []
     } catch (e) {
       console.warn('[执行医嘱] 长期医嘱列表失败,使用测试数据', e)
-      longOrders = buildMockLong(execDate)
+      longOrders = buildMockLong(dateEnd)
     }
     // 2) 临时医嘱
     let shortOrders: any[] = []
     try {
       const sp = await getShortDoctorOrderPage({
         pageNo: 1,
-        pageSize: 100,
+        pageSize: 1000,
         tenantIds,
-        elderName: elderName || undefined
+        elderName: elderName || undefined,
+        // 同上:按 entry 最大 order_time 过滤
+        latestOrderTime: rangeDates
       })
       shortOrders = sp.list || []
     } catch (e) {
       console.warn('[执行医嘱] 临时医嘱列表失败,使用测试数据', e)
-      shortOrders = buildMockShort(execDate)
+      shortOrders = buildMockShort(dateEnd)
     }
 
-    // 3) 展开执行行
-    const rows: any[] = []
-    longOrders.forEach((vo: any) => {
-      ;(vo.entries || []).forEach((e: any) => {
-        if (e.status !== 1) return // 仅在用医嘱需要执行
-        buildExecutionPlanTimes(e.frequency, e.medTime).forEach((t: string) => {
-          // planTime 归一为 YYYY-MM-DD HH:mm(与临时医嘱格式一致)
-          rows.push(makeRow(1, e, formatDateTimeShort(`${execDate} ${t}`), vo))
-        })
-      })
-    })
-    shortOrders.forEach((vo: any) => {
-      ;(vo.entries || []).forEach((e: any) => {
-        // orderTime 后端可能返回 13位毫戳数字/字符串(API 层 .then 已统一转字符串;
-        // 这里 fromTs 兜底,兼容任何来源混入的数字)
-        const plan = fromTs(e.orderTime)
-        if (!plan || plan.slice(0, 10) !== execDate) return
-        // planTime 归一为 YYYY-MM-DD HH:mm(截掉秒,分钟以下不显示)
-        rows.push(makeRow(2, e, formatDateTimeShort(plan), vo))
-      })
-    })
-
-    // 4) 拉取已执行记录,合并标记
-    // - 业务状态真相来自后端 `doctorOrderExecution/page` 接口
-    // - 两层匹配:
-    //   ① 精确:key = `${orderType}-${orderEntryId}-${planTime}`(要求后端精确按前端传入的 planTime 写入)
-    //   ② 兜底:按 (orderType, orderEntryId) 分桶,execution 按 createTime 升序,row 按 planTime 升序,
-    //          用各自桶内的索引对齐。解决"后端 planTime 写入不可靠"导致刷新后状态丢失的过渡问题。
-    // - 兜底命中会在控制台输出 DEBUG_MISMATCH 列表,便于让后端按前端传值精确修正 planTime 字段。
-    const execMap = new Map<string, any>()
-    const execByEntry = new Map<string, any[]>()
+    // 3) 拉取执行记录(按 planTime 范围过滤,避免全量 2000 拉回匹配)
+    //    长期医嘱要判断"历史欠账",必须拿到该条目在窗口内全部已闭环记录。
+    //    已闭环 = 已执行(1) 或 已跳过(2);待执行(0) 不算闭环,仍需展开。
+    //    ⚠️ 强依赖:后端必须精确写入前端传入的 planTime(YYYY-MM-DD HH:mm),
+    //       否则欠账无法正确销账。此处已取消旧的"索引兜底"匹配——欠账模型下
+    //       索引对齐会造成错配,宁可显示为待执行也不能销错账。
+    //    后端 doctorOrderExecution/page 新增 planTime 数组参数(按 plan_time 范围过滤);
+    //    旧的 planDate 单日参数本次不传(始终走范围,更通用)
+    const execMap = new Map<string, any>() // 精确 key → execution 记录(用于回填状态:已执行/已跳过/待执行)
     try {
       const ep = await getDoctorOrderExecutionPage({
         pageNo: 1,
-        pageSize: 1000,
+        pageSize: 2000,
         tenantIds,
-        planDate: execDate
+        // 范围过滤:传 YYYY-MM-DD 日期字符串数组
+        planTime: rangeDates
       })
       ;(ep.list || []).forEach((r: any) => {
         if (r.orderType == null || r.orderEntryId == null || !r.planTime) {
           console.warn('[执行医嘱] execution 记录缺少关键字段,已忽略:', r)
           return
         }
-        const k = `${r.orderType}-${r.orderEntryId}-${r.planTime}`
-        execMap.set(k, r)
-        const ek = `${r.orderType}-${r.orderEntryId}`
-        if (!execByEntry.has(ek)) execByEntry.set(ek, [])
-        execByEntry.get(ek)!.push(r)
+        const plan = String(r.planTime).slice(0, 16)
+        const k = `${r.orderType}-${r.orderEntryId}`
+        execMap.set(`${k}-${plan}`, r)
+        // 状态回填统一走 step 5 的精确匹配;不再用 closedSet 减集合
+        // (展示逻辑改为「展示全部应执行时点 + 回填状态」,已执行/已跳过也保留在列表)
       })
     } catch (e) {
       console.warn('[执行医嘱] 执行记录查询失败,全部按待执行展示', e)
     }
-    execByEntry.forEach((arr) =>
-      arr.sort((a, b) => (a.createTime || '').localeCompare(b.createTime || ''))
-    )
 
-    // rows 也按 entry 分桶并按 planTime 升序(用于索引兜底)
-    const rowsByEntry = new Map<string, any[]>()
-    rows.forEach((r) => {
-      const ek = `${r.orderType}-${r.orderEntryId}`
-      if (!rowsByEntry.has(ek)) rowsByEntry.set(ek, [])
-      rowsByEntry.get(ek)!.push(r)
+    // 4) 展开执行行
+    const rows: any[] = []
+    // 展开窗口 = 用户所选 [dateBegin, dateEnd](替换硬编码 30 天)
+    //   - 上界 dateEnd 23:59:59 不展未来
+    //   - 下界 viewStartDate 00:00:00 不展无限历史
+    // 类型筛选只过滤类型(长期/临时/全部),不改变时间窗口
+    const viewStartDate = dateBegin
+    // 4.1 长期医嘱:展开 [viewStartDate, dateEnd] 内的应执行时点(不展未来)
+    //     - 展示全部时点(含已执行/已跳过),状态由 step 5 精确回填
+    //     - 在用(1) 与 已停止(2) 都展开;未开始(0) 不展开
+    longOrders.forEach((vo: any) => {
+      ;(vo.entries || []).forEach((e: any) => {
+        if (e.status !== 1 && e.status !== 2) return
+        buildPendingPlanTimes({
+          frequency: e.frequency,
+          medTime: e.medTime,
+          startTime: fromTs(e.orderTime),
+          stopTime: e.status === 2 ? fromTs(e.stopTime) : '',
+          // 上界=用户所选结束日 23:59:59(不展未来)
+          now: `${dateEnd} 23:59:59`,
+          // 下界=用户所选开始日 00:00:00
+          viewStart: `${viewStartDate} 00:00:00`
+        })
+          // 双保险:planTime 必须落在 [viewStartDate, dateEnd] 区间内
+          .filter((t) => t.slice(0, 10) >= viewStartDate && t.slice(0, 10) <= dateEnd)
+          .forEach((t: string) => rows.push(makeRow(1, e, t, vo)))
+      })
+    })
+    // 4.2 临时医嘱:一次性,planTime 固定为开单时间;落在窗口内即展示(含历史开单)
+    shortOrders.forEach((vo: any) => {
+      ;(vo.entries || []).forEach((e: any) => {
+        // orderTime 后端可能返回 13位毫戳数字/字符串(API 层 .then 已统一转字符串;
+        // 这里 fromTs 兜底,兼容任何来源混入的数字)
+        const plan = fromTs(e.orderTime)
+        if (!plan) return
+        const planDate = plan.slice(0, 10)
+        if (planDate < viewStartDate || planDate > dateEnd) return
+        // planTime 归一为 YYYY-MM-DD HH:mm(截掉秒,分钟以下不显示)
+        rows.push(makeRow(2, e, formatDateTimeShort(plan), vo))
+      })
     })
-    rowsByEntry.forEach((arr) =>
-      arr.sort((a, b) => (a.planTime || '').localeCompare(b.planTime || ''))
-    )
 
-    const DEBUG_MISMATCH: any[] = []
-    let exactHit = 0
-    let fallbackHit = 0
+    // 5) 回填状态:按 planTime 精确匹配(展示全部时点,已执行/已跳过也保留并回填)
     rows.forEach((r) => {
       const k = rowKey(r)
-      const ek = `${r.orderType}-${r.orderEntryId}`
       const opt = execMemory.get(k)
-
-      // ① 精确匹配
-      let ex = execMap.get(k)
-      if (ex) exactHit++
-
-      // ② 兜底匹配:按 (orderType, orderEntryId) + 各自桶内索引对齐
-      if (!ex) {
-        const rowArr = rowsByEntry.get(ek) || []
-        const execArr = execByEntry.get(ek) || []
-        const idx = rowArr.indexOf(r)
-        if (idx >= 0 && idx < execArr.length) {
-          ex = execArr[idx]
-          fallbackHit++
-          DEBUG_MISMATCH.push({
-            rowPlanTime: r.planTime,
-            execPlanTime: ex.planTime,
-            key: k
-          })
-        }
-      }
-
+      const ex = execMap.get(k)
       if (ex) {
         r.execId = ex.id
         r.status = ex.status ?? 1
@@ -636,20 +705,19 @@ const buildRows = async () => {
       }
     })
 
-    if (DEBUG_MISMATCH.length > 0) {
-      console.warn(
-        `[执行医嘱] 检测到 ${DEBUG_MISMATCH.length} 条 execution.planTime 与前端展开值不一致,` +
-          '已用 (orderType,orderEntryId,索引) 兜底。后端需修正 planTime 写入以匹配前端传值。',
-        DEBUG_MISMATCH
-      )
-    }
-    if (exactHit + fallbackHit > 0) {
-      console.info(
-        `[执行医嘱] execution 合并: 精确 ${exactHit} / 兜底 ${fallbackHit} / 总行 ${rows.length}`
-      )
-    }
+    // ⚠️ 临时诊断(核对后端 planTime 写入精度用,确认后删除):
+    //   若「未命中_后端planTime与前端展开不一致」> 0,说明后端 planTime 未按请求体精确入库,
+    //   已执行/已跳过的记录将无法正确回填 → 表现为「已执行的仍显示待执行」。
+    const expandedKeys = new Set(rows.map((r) => rowKey(r)))
+    const matchedExec = [...execMap.keys()].filter((k) => expandedKeys.has(k)).length
+    console.log('[planTime-诊断]', {
+      展开行数: rows.length,
+      后端execution记录: execMap.size,
+      命中展开行: matchedExec,
+      未命中_后端planTime与前端展开不一致: execMap.size - matchedExec
+    })
 
-    // 5) 筛选 + 排序
+    // 6) 筛选 + 排序
     let filtered = rows
     if (queryParams.orderType != null) {
       filtered = filtered.filter((r) => r.orderType === queryParams.orderType)
@@ -676,7 +744,9 @@ const buildMockLong = (execDate: string): any[] => [
     entries: [
       {
         id: 9001,
-        orderTime: `${execDate} 09:00:00`,
+        // 故意设为 3 天前开立且从未执行:用于演示「欠账累积」——
+        // qd 频次会展开 09-05~09-08 共 4 条待执行,而不是只有今天 1 条
+        orderTime: `${dayjs(execDate).subtract(30, 'day').format('YYYY-MM-DD')} 09:00:00`,
         orderContent: '口服苯磺酸氨氯地平片 5mg',
         frequency: 'qd',
         medTime: 'am',
@@ -742,19 +812,34 @@ const buildMockShort = (execDate: string): any[] => [
   }
 ]
 
-/** 查询 */
-const handleQuery = () => {
+/** 查询:先校验必填的执行日期,通过后再拉数 */
+const handleQuery = async () => {
+  try {
+    await queryFormRef.value?.validate()
+  } catch {
+    return
+  }
+  clearSelection()
   buildRows()
 }
 
-/** 重置 */
+/** 列表变化前清掉选中(数据已变,选中的 row 引用可能已失效) */
+const clearSelection = () => {
+  selectedRows.value = []
+}
+
+/** 重置:执行日期范围回到「前 3 天 ~ 今天」(不可清除)、状态回到「未执行」,再重新加载 */
 const resetQuery = () => {
   queryFormRef.value?.resetFields()
   queryParams.elderName = undefined
-  queryParams.execDate = formatDate(new Date(), 'YYYY-MM-DD')
+  queryParams.dateRange = [
+    dayjs().subtract(30, 'day').format('YYYY-MM-DD'),
+    dayjs().format('YYYY-MM-DD')
+  ] as [string, string]
   queryParams.orderType = undefined
-  queryParams.status = undefined
-  handleQuery()
+  queryParams.status = 0
+  clearSelection()
+  buildRows()
 }
 
 /** 执行 / 查看 */
@@ -799,6 +884,27 @@ const skipRow = ref<any>({})
 const skipping = ref(false)
 const skipForm = reactive({ remark: '' })
 
+/* ========== 批量执行:表格多选 + 弹窗统一提交 ========== */
+/** 选中的行(仅 status=0 可选) */
+const selectedRows = ref<any[]>([])
+/** 表格 selectable:仅「待执行」(status=0) 可勾选 */
+const selectable = (row: any) => row?.status === 0
+const onSelectionChange = (rows: any[]) => {
+  selectedRows.value = rows || []
+}
+/** 打开批量执行弹窗(弹窗内自取 selectedRows 副本,避免父组件列表变化影响) */
+const batchExecRef = ref()
+const openBatchExecute = () => {
+  if (selectedRows.value.length === 0) return
+  batchExecRef.value?.open(selectedRows.value)
+}
+/** 批量执行成功的回调:刷新列表(避免按行局部更新时 rowKey 不一致) */
+const onBatchSuccess = (_info: any) => {
+  selectedRows.value = []
+  buildRows()
+}
+
+
 const handleSkip = (row: any) => {
   skipRow.value = row
   skipForm.remark = ''

+ 4 - 10
src/views/elderly/elder/doctor-orders/long-doctor-order/index.vue

@@ -99,8 +99,7 @@
 <script lang="ts" setup>
 import {
   getLongDoctorOrderPage,
-  deleteLongDoctorOrder,
-  getLongDoctorOrderStatusText
+  deleteLongDoctorOrder
 } from '@/api/elderly/elder/bed-change'
 import { LongDoctorOrderColumns } from './columns'
 import Form from './Form.vue'
@@ -264,10 +263,10 @@ const getList = async () => {
       elderName: queryParams.elderName || undefined,
       tenantIds: queryParams.tenantIds
     }
-    // 时间范围
+    // 时间范围:后端 longDoctorOrder/page 新增 latestOrderTime 数组参数
+    // (array[string],元素为 YYYY-MM-DD 日期字符串,按 entry 最大 order_time 过滤)
     if (Array.isArray(queryParams.orderTime) && queryParams.orderTime.length === 2) {
-      params.beginTime = queryParams.orderTime[0]
-      params.endTime = queryParams.orderTime[1]
+      params.latestOrderTime = [queryParams.orderTime[0], queryParams.orderTime[1]]
     }
     const data = await getLongDoctorOrderPage(params)
     list.value = (data.list || []).map((item: any) => ({
@@ -314,11 +313,6 @@ const openForm = (row: any = {}) => {
   formRef.value.open(row)
 }
 
-/** 打开详情(只读) */
-const openDetail = (row: any = {}) => {
-  formRef.value.open(row, true)
-}
-
 /** 打印 */
 const openPrint = (row: any = {}) => {
   printRef.value.open(row)

+ 3 - 2
src/views/elderly/elder/doctor-orders/short-doctor-order/index.vue

@@ -171,9 +171,10 @@ const getList = async () => {
       elderName: queryParams.elderName || undefined,
       tenantIds: queryParams.tenantIds
     }
+    // 时间范围:后端 shortDoctorOrder/page 新增 latestOrderTime 数组参数
+    // (array[string],元素为 YYYY-MM-DD 日期字符串,按 entry 最大 order_time 过滤)
     if (Array.isArray(queryParams.orderTime) && queryParams.orderTime.length === 2) {
-      params.beginTime = queryParams.orderTime[0]
-      params.endTime = queryParams.orderTime[1]
+      params.latestOrderTime = [queryParams.orderTime[0], queryParams.orderTime[1]]
     }
     const data = await getShortDoctorOrderPage(params)
     list.value = data.list || []