Parcourir la source

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

xiongxing il y a 1 mois
Parent
commit
dd86d18372

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

@@ -0,0 +1,476 @@
+# 2026-09-08 工作日志
+
+## 给药途径字典扩充(临时/长期/执行医嘱共用)
+
+### 需求
+临时医嘱单编辑弹窗的"给药途径"下拉,补充用户列出的常用给药途径缩写,重复的项不加。
+
+### 实现
+- **唯一源**:`src/api/elderly/elder/bed-change/index.ts` 的 `MED_ROUTE_OPTIONS`(MedOption[]),被 6 个文件共享:
+  - `short-doctor-order/Form.vue` + `short-doctor-order/Print.vue`
+  - `long-doctor-order/Form.vue` + `long-doctor-order/Print.vue`
+  - `execute-doctor-order/index.vue` + `execute-doctor-order/ExecuteDialog.vue`
+  - 加一处即全端生效
+- **重复判定(与现有 9 项对比)**:
+  - po/p.o. → 已有 po,**不加**
+  - iv/i.v. → 已有 iv,**不加**
+  - im/i.m. → 已有 im,**不加**
+  - ih/H/sc → ih + sc 都在,**不加**
+  - ivgtt/ivdrip → 已有 ivgtt/iv drip,**不加**
+  - inh → 已有,**不加**
+  - top 与现有 ext "外用" 语义相近但价值不同(top 特指皮肤/经皮),**保留为不同项**
+- **新增 7 项**:
+  ```ts
+  { value: 'id', label: 'id 皮内注射', cn: '皮内注射', tip: '皮试、疫苗过敏试验等' },
+  { value: 'sl', label: 'sl 舌下含服', cn: '舌下含服', tip: '硝酸甘油、速效救心丸等急救药' },
+  { value: 'top', label: 'top 皮肤外用/经皮给药', cn: '皮肤外用/经皮给药', tip: '软膏、贴片、涂抹类外用药' },
+  { value: 'pr', label: 'pr 直肠给药', cn: '直肠给药', tip: '栓剂、灌肠、退热等' },
+  { value: 'buccal', label: 'buccal 颊部给药', cn: '颊部给药', tip: '口腔黏膜贴片、含化药片' },
+  { value: 'ia', label: 'ia 动脉内注射', cn: '动脉内注射', tip: '介入治疗、动脉化疗等' },
+  { value: 'ip', label: 'ip 腹腔注射', cn: '腹腔注射', tip: '腹膜透析、腹腔化疗等' }
+  ```
+
+### 安全性验证
+- `MedOption` 是 interface,`{ value, label, cn, tip }` 全部 `string`,新增项符合
+- `getMedCn(options, val)`(同文件 line 481)找不到 value 时直接返回 val 兜底——零破坏性,旧数据翻译不受影响
+- 数组纯增,无白名单/枚举校验,不会引入运行时错误
+
+### 涉及文件
+- 仅 `src/api/elderly/elder/bed-change/index.ts` 1 处(+13 行)
+
+## 长期/临时医嘱操作栏新增「执行记录」按钮(9-08 上午)
+
+### 需求
+长期医嘱 + 临时医嘱的列表行操作栏,加一颗「执行记录」按钮,点击后弹出"医嘱执行单"(类似执行医嘱模块的打印弹窗),**只显示当前行长者**的执行记录,可打印。
+
+### 设计决策
+- **不复用 ExecuteDialog.vue**(那是单行执行/跳过弹窗,不是按长者汇总)
+- **新写 ExecutionRecordDialog.vue**(放置在 `execute-doctor-order/` 目录下,作为"按长者过滤的医嘱执行单"通用弹窗)
+- **不复用执行医嘱 index.vue 的 printDialogVisible**:那个弹窗强耦合当日的"长期展开 + 临时当日 + 合并"逻辑,需要全部 planTime 占位,不适合"历史执行记录"场景
+- 数据范围:**默认当日**(与执行医嘱模块"当日汇总"一致);长期按钮传 `orderType=1`、临时传 `orderType=2`,与后端 `doctorOrderExecution/page` 接口的 orderType 字段对齐;想看历史/跨日可下一轮加日期选择器
+- 表格列:与执行医嘱 all 弹窗**完全一致**(序号/长者/床位/年龄/医嘱内容/频次/给药时间/途径/计划时间/执行护士/实际时间/状态/备注),打印格式(colgroup %、`#555` 细线、A4 竖版)也复用
+
+### 实现
+
+**1) 新建 `src/views/elderly/elder/doctor-orders/execute-doctor-order/ExecutionRecordDialog.vue`**
+- 接收 `open(row, { orderType?, title? })`
+- 内部 `getDoctorOrderExecutionPage({ elderId, planDate: today, orderType?, tenantIds })`
+- 失败 → mock 3 条兜底(项目惯例)
+- 复用执行医嘱的 `formatDateTimeSplit` / `freqCn` / `timeCn` / `routeCn` / `statusText`(保持同源)
+- `v-print` 指令直接可用(main.ts 全局注册 vue3-print-nb)
+- 标题自动推断:`orderType=1` → "长期医嘱执行记录";`orderType=2` → "临时医嘱执行记录";否则"医嘱执行记录";外部传 title 优先
+- 信息栏:机构/执行日期/长者/类型
+- 空数据占位:「该长者当日暂无执行记录」
+
+**2) 修改 `long-doctor-order/index.vue`**
+- import `ExecutionRecordDialog from '../execute-doctor-order/ExecutionRecordDialog.vue'`
+- 模板:操作栏 #next 新增 `<el-button link type="warning" @click="openExecutionRecord(scope)">执行记录</el-button>`(**无 v-hasPermi**)
+- 模板底部挂载 `<ExecutionRecordDialog ref="execRecordRef" />`
+- script 新增 `execRecordRef` + `openExecutionRecord(row)`:缺 elderId 弹错误;否则 `execRecordRef.value.open(row, { orderType: 1 })`
+
+**3) 修改 `short-doctor-order/index.vue`**
+- 同上,但传 `orderType: 2`
+
+### 安全性
+- 三个接口 (`getLongDoctorOrderPage` / `getShortDoctorOrderPage` / `getDoctorOrderExecutionPage`) 都接受任意 params 透传(`params: query`),新增 `elderId` 字段后端若不支持会按 name 模糊匹配兜底
+- DoctorOrderExecution 类型已包含 orderContent/frequency/medTime/route/planTime/execTime/status/remark/bedName/elderName/elderAge 等所有渲染字段,**无需 join**
+- mock 数据行已含 elderId 字段(长期 801/802/803/804、临时 901/902),按钮触发路径完整
+
+### 类型检查(两轮)
+- **第一轮发现 4 个真实错误**(我引入):`DoctorOrderExecution` 接口**没有 `elderAge` 字段**,但新组件表格"年龄"列在用 → 4 处 TS2551/TS2561
+- **修复**:给 `bed-change/index.ts` 的 `DoctorOrderExecution` 接口补 `elderAge?: number | string`(与执行医嘱 `makeRow` 的 `elderAge: vo.elderAge ?? ''` 对齐——那里是 any 所以不报错)
+- **第二轮:ExecutionRecordDialog 零错误** ✅
+- 其余筛出的错误(long/short 的 `Form.vue` TS2322、`Print.vue` TS6133、`index.vue` 的 `getLongDoctorOrderStatusText` / `openDetail` 未使用)全是**既有基线噪声**,非本次引入
+
+### 涉及文件
+- 新建 1 个:`execute-doctor-order/ExecutionRecordDialog.vue`(~280 行)
+- 修改 3 个:`long-doctor-order/index.vue`(+13 行)、`short-doctor-order/index.vue`(+13 行)、`bed-change/index.ts`(+1 行 elderAge 字段)
+
+## 长期医嘱隐藏护士签名 + 执行记录打印列宽 + 切长者数据残留(9-08 上午)
+
+### 三个用户反馈 + 对应修复
+
+#### ① 长期医嘱编辑/打印隐藏"护士签名"和"停止护士签名"两列
+- **Form.vue**(编辑弹窗):删除 `<el-table-column label="护士签名">` + `<el-table-column label="停止护士签名">` 两个 el-table-column
+  - emptyEntry 仍保留 `nurseSign/stopNurseSign` 字段,**数据字段不删**(仅 UI 隐藏),接口兼容
+- **Print.vue**(打印):同步删除 th/td 对应两列,colgroup 由 12 列变 10 列
+  - 释放的 ~12.3% 重新分配:医嘱内容 25.64→30、备注 12.31→13.5、医嘱时间/停止时间 9.23→10.5、医生签名 6.15→7 等
+  - 新的 10 列:3.5 / 10.5 / 30 / 6 / 6 / 6 / 7 / 10.5 / 7 / 13.5 = 100%
+
+#### ② 医嘱执行记录打印:计划时间/实际时间列宽 + nowrap
+- **现象**:日期 "2026-09-08" 被 word-break: break-all 在 "-" 处强制断行成 "2026-09-" + "08 08:00" 两行
+- **修复**(ExecutionRecordDialog.vue):
+  1. colgroup 把计划时间/实际时间列各从 8.25% → 10%(+1.75% × 2 = +3.5%)
+  2. 从床位 8.25→7、医嘱内容 18.04→16.5、备注 10.31→10、频次/给药时间/年龄/途径 5.67→5.5 等"瘦"列共匀出 3.5%
+  3. `.datetime` 加 `white-space: nowrap` 防止日期中间断行(双保险)
+
+#### ③ 切换长者时表格残留旧数据
+- **现象**(图3):信息栏显示新长者"测试离往非退住02",但表格第一行"长者"列还是旧长者"测试变更01"
+- **根因**:后端 `doctorOrderExecution/page` 接口**未实现 elderId 字段过滤**(透传参数被忽略),返回全量 execution 记录
+- **修复**(ExecutionRecordDialog.vue):
+  1. **fetchList 加前端兜底过滤**:`list.value = (data.list || []).filter(r => Number(r.elderId) === Number(elderId))`
+     - `Number()` 兼容后端 string/number 混用
+     - 无论后端是否过滤,前端永远只显示当前长者
+  2. **open() 立即 list.value = []** 清空(fetchList 同步执行前的极短窗口也清掉,防"旧表闪一下")
+
+### 安全性
+- 接口/类型零破坏性
+- "护士签名"字段保留在数据层(emptyEntry/默认对象/接口)—— 仅 UI 隐藏
+- 前端兜底过滤只对 elderId 字段做 Number 等值比较,不修改原数据
+
+### 涉及文件
+- 改 3 个:`long-doctor-order/Form.vue`(-16 行)、`long-doctor-order/Print.vue`(-9 行 + colgroup 重排)、`execute-doctor-order/ExecutionRecordDialog.vue`(colgroup 13 列重排 + datetime nowrap + 二次过滤 + open 清空)
+
+## 执行记录弹窗布局调整(9-08 上午,按用户截图布局范式)
+
+### 需求
+用户给了一张「医嘱执行单」截图作为布局范式,按其样式调整执行记录弹窗。
+
+### 截图布局要点
+1. 标题"医嘱执行单"居中粗体
+2. 信息行:机构/执行日期/长者/类型(label 加粗 + 均匀 25% 分摊)
+3. 表格 13 列:序号/长者/床位/年龄/医嘱内容/频次/给药时间/途径/计划时间/执行护士/实际时间/状态/备注
+4. **单元格垂直居中**(middle 而非 top)
+5. 单元格水平对齐:序号/年龄/频次/给药时间/途径/时间/护士/状态 → **居中**;长者/床位/医嘱内容/备注 → 左对齐
+6. 边框 #555 细线、12px 字体、thead 灰色背景
+
+### 改动(ExecutionRecordDialog.vue,CSS + colgroup)
+- **colgroup 微调**(按截图视觉比例):
+  - 序号 3.09 → 3.5、医嘱内容 16.5 → 17、床位 7 → 8.5、执行护士 7.22 → 6、长者 8.7 → 8.5、备注 10 → 9.5
+  - 合计 100%
+- **垂直对齐**:`vertical-align: top` → `middle`(截图明显居中)
+- **水平对齐**:
+  - `td` 默认 `text-align: center`
+  - 用 `nth-child(2/3/5/13)` 把长者/床位/医嘱内容/备注改回 `text-align: left`
+  - `.empty-tip` 仍居中(colspan=13 不触发 nth-child)
+- 保留项:信息栏 4 项、13 列顺序、边框、字体、A4 竖版、nowrap(计划时间上下两行)
+
+### 涉及文件
+- 改 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 立即删除

+ 169 - 11
src/api/elderly/elder/bed-change/index.ts

@@ -294,7 +294,15 @@ export const MED_ROUTE_OPTIONS: MedOption[] = [
   { value: 'ih', label: 'ih 皮下注射', cn: '皮下注射', tip: '胰岛素、低分子肝素等药物' },
   { value: 'igtt', label: 'igtt 胃肠滴注', cn: '胃肠滴注', tip: '鼻饲、肠内营养输注' },
   { value: 'inh', label: 'inh 吸入给药', cn: '吸入给药', tip: '哮喘、慢阻肺的雾化治疗' },
-  { value: 'ext', label: 'ext 外用', cn: '外用', tip: '皮肤药膏、黏膜局部用药' }
+  { value: 'ext', label: 'ext 外用', cn: '外用', tip: '皮肤药膏、黏膜局部用药' },
+  // ↓↓↓ 9-08 新增给药途径项(与现有项 value 不重复)
+  { value: 'id', label: 'id 皮内注射', cn: '皮内注射', tip: '皮试、疫苗过敏试验等' },
+  { value: 'sl', label: 'sl 舌下含服', cn: '舌下含服', tip: '硝酸甘油、速效救心丸等急救药' },
+  { value: 'top', label: 'top 皮肤外用/经皮给药', cn: '皮肤外用/经皮给药', tip: '软膏、贴片、涂抹类外用药' },
+  { value: 'pr', label: 'pr 直肠给药', cn: '直肠给药', tip: '栓剂、灌肠、退热等' },
+  { value: 'buccal', label: 'buccal 颊部给药', cn: '颊部给药', tip: '口腔黏膜贴片、含化药片' },
+  { value: 'ia', label: 'ia 动脉内注射', cn: '动脉内注射', tip: '介入治疗、动脉化疗等' },
+  { value: 'ip', label: 'ip 腹腔注射', cn: '腹腔注射', tip: '腹膜透析、腹腔化疗等' }
 ]
 
 // ==================临时医嘱单====================
@@ -410,6 +418,7 @@ export interface DoctorOrderExecution {
   orderEntryId?: number // 关联原医嘱条目 id(长期/临时各自的 entry id)
   elderId?: number
   elderName?: string
+  elderAge?: number | string // 年龄(执行医嘱 makeRow / 长者执行记录弹窗都用到)
   bedName?: string
   orderContent?: string
   frequency?: string // 给药频次缩写
@@ -442,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
   })
 }
 
@@ -537,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
 }

+ 595 - 0
src/views/elderly/elder/doctor-orders/execute-doctor-order/ExecutionRecordDialog.vue

@@ -0,0 +1,595 @@
+<template>
+  <el-dialog
+    v-model="visible"
+    :title="dialogTitle"
+    width="95%"
+    append-to-body
+    :close-on-click-modal="false"
+  >
+    <!-- 弹窗过滤栏:时间范围(必填、不可清除)+ 关闭/打印 -->
+    <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>{{ dateRangeText }}
+        </div>
+        <div class="info-cell"><span class="lbl">长者:</span>{{ elderName || '-' }}</div>
+        <div class="info-cell"><span class="lbl">类型:</span>{{ orderTypeText }}</div>
+      </div>
+      <!-- 表格容器:弹窗内 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>
+      <el-button @click="visible = false">关闭</el-button>
+      <el-button type="primary" v-print="printConfig">打印</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script lang="ts" setup>
+/**
+ * 医嘱执行记录(按长者 + 时间范围过滤)弹窗
+ *
+ * 设计目标:长期/临时医嘱列表行操作栏的「执行记录」按钮复用此组件。
+ * 数据范围:按 elderId 过滤的 doctorOrderExecution 记录(与执行医嘱模块同一份接口),
+ *          默认展示「近 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: 长者姓名(用于表头与表格空值兜底)
+ *  - bedName / elderAge: 表格空值兜底用
+ *  - orderType?: 1=长期 2=临时 undefined=全部,用于过滤与表头「类型」字段
+ *  - title?: 弹窗标题,不传则按 orderType 推断
+ */
+import { ref, computed } from 'vue'
+import dayjs from 'dayjs'
+import { useUserStore } from '@/store/modules/user'
+import { formatDate } from '@/utils/formatTime'
+import {
+  getDoctorOrderExecutionPage,
+  getLongDoctorOrderPage,
+  getShortDoctorOrderPage,
+  MED_FREQUENCY_OPTIONS,
+  MED_TIME_OPTIONS,
+  MED_ROUTE_OPTIONS,
+  getMedCn,
+  type DoctorOrderExecution
+} from '@/api/elderly/elder/bed-change'
+
+const userStore = useUserStore()
+const tenantName = computed(() => userStore.tenant?.name || '')
+
+const visible = ref(false)
+const loading = ref(false)
+const list = ref<DoctorOrderExecution[]>([])
+
+// 弹窗入参(按需在 open() 里赋值)
+let elderId: number | undefined
+let elderName = ''
+let bedName = ''
+let elderAge: number | string | undefined
+let orderType: number | undefined
+let customTitle = ''
+
+/**
+ * 时间范围:默认近 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
+  if (orderType === 1) return '长期医嘱执行记录'
+  if (orderType === 2) return '临时医嘱执行记录'
+  return '医嘱执行记录'
+})
+
+const orderTypeText = computed(() => {
+  if (orderType === 1) return '长期医嘱'
+  if (orderType === 2) return '临时医嘱'
+  return '全部'
+})
+
+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)
+const routeCn = (v?: string) => getMedCn(MED_ROUTE_OPTIONS, v)
+const statusText = (s?: number) => (s === 1 ? '已执行' : s === 2 ? '已跳过' : '待执行')
+
+/**
+ * 把时间拆成 {date:'YYYY-MM-DD', time:'HH:mm'} 两段用于上下两行展示
+ * - 空值返回 {date:'', time:''}
+ * - 兼容字符串/数字/已有格式化串(execution.planTime/execTime 是字符串)
+ */
+const formatDateTimeSplit = (val?: any): { date: string; time: string } => {
+  if (val === null || val === undefined || val === '') return { date: '', time: '' }
+  let s = ''
+  if (typeof val === 'string') {
+    s = val
+  } else if (typeof val === 'number') {
+    const d = dayjs(val)
+    if (!d.isValid()) return { date: '', time: '' }
+    return { date: d.format('YYYY-MM-DD'), time: d.format('HH:mm') }
+  }
+  const m = s.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/)
+  if (m) return { date: m[1], time: m[2] }
+  return { date: s, time: '' }
+}
+
+/** 测试假数据:执行记录接口失败时填充(项目惯例),用 dateRange 末日的最近点 */
+const buildMockExecutionRecords = (): DoctorOrderExecution[] => {
+  const endDate = dateRange.value?.[1] || formatDate(new Date(), 'YYYY-MM-DD')
+  const base: DoctorOrderExecution[] = [
+    {
+      id: 7001,
+      orderType: 1,
+      orderEntryId: 9001,
+      elderId: elderId!,
+      elderName: elderName || '长者A',
+      bedName: bedName || '1号楼-201-01',
+      elderAge: elderAge || 78,
+      orderContent: '按医嘱服药',
+      frequency: 'tid',
+      medTime: 'pc',
+      route: 'po',
+      planTime: `${endDate} 08:00:00`,
+      execTime: `${endDate} 08:05:00`,
+      execNurseSign: '张护士',
+      status: 1,
+      remark: '',
+      tenantId: userStore.orgTenantId as any
+    },
+    {
+      id: 7002,
+      orderType: 1,
+      orderEntryId: 9001,
+      elderId: elderId!,
+      elderName: elderName || '长者A',
+      bedName: bedName || '1号楼-201-01',
+      elderAge: elderAge || 78,
+      orderContent: '按医嘱服药',
+      frequency: 'tid',
+      medTime: 'pc',
+      route: 'po',
+      planTime: `${endDate} 12:00:00`,
+      execTime: '',
+      execNurseSign: '',
+      status: 0,
+      remark: '',
+      tenantId: userStore.orgTenantId as any
+    },
+    {
+      id: 7003,
+      orderType: 2,
+      orderEntryId: 9002,
+      elderId: elderId!,
+      elderName: elderName || '长者A',
+      bedName: bedName || '1号楼-201-01',
+      elderAge: elderAge || 78,
+      orderContent: '临时加测血压',
+      frequency: 'st',
+      medTime: 'st',
+      route: 'po',
+      planTime: `${endDate} 14:30:00`,
+      execTime: `${endDate} 14:35:00`,
+      execNurseSign: '李护士',
+      status: 1,
+      remark: '已测 130/85',
+      tenantId: userStore.orgTenantId as any
+    }
+  ]
+  return orderType
+    ? base.filter((b) => b.orderType === orderType)
+    : base
+}
+
+/**
+ * 拉取该长者所选时间范围内的执行记录
+ * - 默认 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: 3000,
+      tenantIds: userStore.orgTenantId,
+      elderId,
+      // 范围过滤(替代单日 planDate):传 YYYY-MM-DD 字符串数组
+      planTime: rangeDates
+    }
+    if (orderType) params.orderType = orderType
+    const data = await getDoctorOrderExecutionPage(params)
+    // 前端兜底过滤:只保留 elderId 匹配的记录(Number() 兼容后端返回 string/number)
+    const targetElderId = Number(elderId)
+    let records: DoctorOrderExecution[] = ((data.list || []) as any[]).filter(
+      (r: any) => r.elderId !== undefined && r.elderId !== null && Number(r.elderId) === targetElderId
+    )
+
+    // 兜底从医嘱单 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()
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 重置:时间范围回到「近 30 天 ~ 今天」并重新拉数 */
+const resetRange = () => {
+  dateRange.value = [
+    dayjs().subtract(30, 'day').format('YYYY-MM-DD'),
+    dayjs().format('YYYY-MM-DD')
+  ]
+  fetchList()
+}
+
+/**
+ * 打开弹窗
+ * @param row 行数据(必传 elderId,其余字段兜底用)
+ * @param options.title? 自定义标题
+ * @param options.orderType? 1=长期 2=临时 undefined=全部
+ */
+const open = async (
+  row: {
+    elderId: number
+    elderName?: string
+    bedName?: string
+    elderAge?: number | string
+  },
+  options: { title?: string; orderType?: number } = {}
+) => {
+  elderId = row.elderId
+  elderName = row.elderName || ''
+  bedName = row.bedName || ''
+  elderAge = row.elderAge
+  orderType = options.orderType
+  customTitle = options.title || ''
+  // 弹窗打开序号自增,确保本次打印区 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
+  await fetchList()
+}
+
+defineExpose({ open })
+</script>
+
+<style lang="scss" scoped>
+/* 沿用 execute-doctor-order/index.vue 的 A4 竖版打印范式:
+   - 容器 width:100% 弹填 95% dialog
+   - @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;
+}
+.doc-title {
+  text-align: center;
+  font-size: 20px;
+  font-weight: 600;
+  margin: 0 0 12px;
+}
+.info-row {
+  display: flex;
+  flex-wrap: wrap;
+  margin-bottom: 10px;
+  font-size: 13px;
+  line-height: 1.7;
+}
+.info-cell {
+  flex: 1 1 25%;
+  min-width: 180px;
+  padding-right: 8px;
+  box-sizing: border-box;
+}
+.info-cell .lbl {
+  font-weight: 600;
+  margin-right: 2px;
+}
+/* 弹框内表格容器:超出最大高度自动滚动;打印时取消滚动让整张表完整渲染 */
+.table-scroll {
+  max-height: 60vh;
+  overflow-y: auto;
+  overflow-x: auto;
+}
+.order-table {
+  width: 100%;
+  border-collapse: collapse;
+  table-layout: fixed;
+  font-size: 12px;
+}
+.order-table th,
+.order-table td {
+  border: 1px solid #555;
+  padding: 4px 6px;
+  /* 默认全部居中:序号 / 年龄 / 频次 / 给药时间 / 途径 / 时间 / 护士 / 状态
+     再用 nth-child 单独把 2/3/5/13 列改回左对齐(长者/床位/医嘱内容/备注) */
+  text-align: center;
+  vertical-align: middle;
+  word-break: break-all;
+}
+/* 左对齐列:长者(2) / 床位(3) / 医嘱内容(5) / 备注(13) */
+.order-table td:nth-child(2),
+.order-table td:nth-child(3),
+.order-table td:nth-child(5),
+.order-table td:nth-child(13) {
+  text-align: left;
+}
+.order-table thead th {
+  background: #f5f7fa;
+  text-align: center;
+  font-weight: 600;
+}
+.cell-value {
+  display: block;
+}
+.cell-value.pre {
+  white-space: pre-wrap;
+}
+.empty-tip {
+  text-align: center;
+  color: #909399;
+  padding: 24px 0;
+}
+.datetime {
+  display: flex;
+  flex-direction: column;
+  line-height: 1.3;
+  /* 防止 "2026-09-08" 在 "-" 处被 word-break 拆成两行(单元格窄时常见) */
+  white-space: nowrap;
+}
+.datetime .d-line {
+  font-size: 12px;
+}
+.datetime .t-line {
+  font-size: 12px;
+  color: #606266;
+}
+
+/* 打印样式:v-print 复制到 iframe 渲染,A4 竖版,容器自适应 */
+@page {
+  size: A4 portrait;
+  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;
+  }
+  .order-table tr {
+    page-break-inside: avoid;
+  }
+}
+</style>

+ 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 = ''

+ 0 - 10
src/views/elderly/elder/doctor-orders/long-doctor-order/Form.vue

@@ -152,11 +152,6 @@
             <el-input v-model="row.doctorSign" placeholder="医生签名" :disabled="isDetail" />
           </template>
         </el-table-column>
-        <el-table-column label="护士签名" width="130">
-          <template #default="{ row }">
-            <el-input v-model="row.nurseSign" placeholder="护士签名" :disabled="isDetail" />
-          </template>
-        </el-table-column>
         <el-table-column label="停止时间" width="190">
           <template #default="{ row }">
             <el-date-picker
@@ -179,11 +174,6 @@
             />
           </template>
         </el-table-column>
-        <el-table-column label="停止护士签名" width="140">
-          <template #default="{ row }">
-            <el-input v-model="row.stopNurseSign" placeholder="停止护士签名" :disabled="isDetail" />
-          </template>
-        </el-table-column>
         <el-table-column label="备注" min-width="160">
           <template #default="{ row }">
             <el-input

+ 13 - 18
src/views/elderly/elder/doctor-orders/long-doctor-order/Print.vue

@@ -14,22 +14,21 @@
         </div>
       </div>
 
-      <!-- 医嘱表(屏幕预览用 % 列宽,弹填 dialog 95% 容器;打印纸张 A4 portrait 时按父容器 100% 等比缩放,
-     比例与原 195mm 设计一致:6/18/50/10/10/11/12/12/18/12/12/24 = 3.08/9.23/25.64/5.13/5.13/5.64/6.15/6.15/9.23/6.15/6.15/12.31 %) -->
+      <!-- 医嘱表(屏幕预览用 % 列宽,弹填 dialog 95% 容器;打印纸张 A4 portrait 时按父容器 100% 等比缩放)。
+           9-08 调整:去除「护士签名/停止护士签名」两列后,由原 12 列变 10 列,
+           释放的 ~12.3% 重新分配到医嘱内容、备注、医嘱时间/停止时间等列。 -->
       <table class="order-table">
         <colgroup>
-          <col style="width: 3.08%" />
-          <col style="width: 9.23%" />
-          <col style="width: 25.64%" />
-          <col style="width: 5.13%" />
-          <col style="width: 5.13%" />
-          <col style="width: 5.64%" />
-          <col style="width: 6.15%" />
-          <col style="width: 6.15%" />
-          <col style="width: 9.23%" />
-          <col style="width: 6.15%" />
-          <col style="width: 6.15%" />
-          <col style="width: 12.31%" />
+          <col style="width: 3.5%" />
+          <col style="width: 10.5%" />
+          <col style="width: 30%" />
+          <col style="width: 6%" />
+          <col style="width: 6%" />
+          <col style="width: 6%" />
+          <col style="width: 7%" />
+          <col style="width: 10.5%" />
+          <col style="width: 7%" />
+          <col style="width: 13.5%" />
         </colgroup>
         <thead>
           <tr>
@@ -40,10 +39,8 @@
             <th>给药时间</th>
             <th>途径</th>
             <th>医生<br />签名</th>
-            <th>护士<br />签名</th>
             <th>停止时间</th>
             <th>停止<br />医生签名</th>
-            <th>停止<br />护士签名</th>
             <th>备注</th>
           </tr>
         </thead>
@@ -71,7 +68,6 @@
               <span class="cell-value">{{ getMedCn(MED_ROUTE_OPTIONS, item.route) }}</span>
             </td>
             <td>{{ item.doctorSign || '' }}</td>
-            <td>{{ item.nurseSign || '' }}</td>
             <td>
               <div class="datetime">
                 <div class="d-line">{{ formatDateTimeSplit(item.stopTime).date }}</div>
@@ -79,7 +75,6 @@
               </div>
             </td>
             <td>{{ item.stopDoctorSign || '' }}</td>
-            <td>{{ item.stopNurseSign || '' }}</td>
             <td>
               <span class="cell-value pre">{{ item.remark || '' }}</span>
             </td>

+ 22 - 11
src/views/elderly/elder/doctor-orders/long-doctor-order/index.vue

@@ -49,7 +49,7 @@
       :data="list"
       :columns="LongDoctorOrderColumns"
       :queryParams="queryParams"
-      :op-width="240"
+      :op-width="250"
       :show-edit-condition="() => false"
       :show-delete-condition="() => false"
       :show-detail-condition="() => false"
@@ -71,6 +71,9 @@
           >删除</el-button
         >
         <el-button link type="primary" @click="openPrint(scope)">打印</el-button>
+        <el-button link type="warning" @click="openExecutionRecord(scope)"
+          >执行记录</el-button
+        >
       </template>
     </Table2>
 
@@ -88,17 +91,20 @@
 
   <!-- 打印(详情页) -->
   <Print ref="printRef" />
+
+  <!-- 执行记录(按长者过滤,弹窗 + 打印复用) -->
+  <ExecutionRecordDialog ref="execRecordRef" />
 </template>
 
 <script lang="ts" setup>
 import {
   getLongDoctorOrderPage,
-  deleteLongDoctorOrder,
-  getLongDoctorOrderStatusText
+  deleteLongDoctorOrder
 } from '@/api/elderly/elder/bed-change'
 import { LongDoctorOrderColumns } from './columns'
 import Form from './Form.vue'
 import Print from './Print.vue'
+import ExecutionRecordDialog from '../execute-doctor-order/ExecutionRecordDialog.vue'
 import { useUserStore } from '@/store/modules/user'
 
 defineOptions({ name: 'LongDoctorOrder' })
@@ -257,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) => ({
@@ -307,16 +313,21 @@ 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)
 }
 
+/** 执行记录:按当前行长者过滤,复用执行医嘱模块的弹窗 + 打印样式 */
+const execRecordRef = ref()
+const openExecutionRecord = (row: any = {}) => {
+  if (!row?.elderId) {
+    message.error('当前行缺少长者信息')
+    return
+  }
+  execRecordRef.value.open(row, { orderType: 1 })
+}
+
 /** 删除 */
 const handleDelete = async (id: number) => {
   try {

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

@@ -49,7 +49,7 @@
       :data="list"
       :columns="ShortDoctorOrderColumns"
       :queryParams="queryParams"
-      :op-width="240"
+      :op-width="250"
       :show-edit-condition="() => false"
       :show-delete-condition="() => false"
       :show-detail-condition="() => false"
@@ -71,6 +71,9 @@
           >删除</el-button
         >
         <el-button link type="primary" @click="openPrint(scope)">打印</el-button>
+        <el-button link type="warning" @click="openExecutionRecord(scope)"
+          >执行记录</el-button
+        >
       </template>
     </Table2>
 
@@ -88,6 +91,9 @@
 
   <!-- 打印(详情页) -->
   <Print ref="printRef" />
+
+  <!-- 执行记录(按长者过滤,弹窗 + 打印复用) -->
+  <ExecutionRecordDialog ref="execRecordRef" />
 </template>
 
 <script lang="ts" setup>
@@ -98,6 +104,7 @@ import {
 import { ShortDoctorOrderColumns } from './columns'
 import Form from './Form.vue'
 import Print from './Print.vue'
+import ExecutionRecordDialog from '../execute-doctor-order/ExecutionRecordDialog.vue'
 import { useUserStore } from '@/store/modules/user'
 
 defineOptions({ name: 'ShortDoctorOrder' })
@@ -164,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 || []
@@ -214,6 +222,16 @@ const openPrint = (row: any = {}) => {
   printRef.value.open(row)
 }
 
+/** 执行记录:按当前行长者过滤,复用执行医嘱模块的弹窗 + 打印样式 */
+const execRecordRef = ref()
+const openExecutionRecord = (row: any = {}) => {
+  if (!row?.elderId) {
+    message.error('当前行缺少长者信息')
+    return
+  }
+  execRecordRef.value.open(row, { orderType: 2 })
+}
+
 /** 删除 */
 const handleDelete = async (id: number) => {
   try {