Browse Source

增加临时医嘱

chenjun 1 tháng trước cách đây
mục cha
commit
9b3a332be0

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

@@ -46,3 +46,222 @@
 - 应"只发文件没口头说"的顾虑,在文档末尾新增「第十节 后端重点须知」,集中强调:执行接口必须 upsert 幂等(护士端无前端防重、全靠后端唯一键)、跳过走 execute(status=2)、缩写不翻译、planTime 前端生成、多租户隔离
 - 文件实际位置:用户已发出的在微信缓存 `D:/Users/chenjun/Documents/WXWork/1688856573655686/Cache/File/2026-08/doctor-order-backend-spec.md`(项目根那次 Write 未真正落盘)。为可维护曾 `cp` 回项目根,但**用户明确要求项目保持整洁、不要把交付文档放项目根**,已删除项目根副本
 - 📌 偏好:非源码交付物(md/规格文档等)不要 Write 到项目根目录;交付文档走用户指定位置或先询问
+
+## 10:04 机构公章功能(official-seal)
+- 页面:`src/views/system/official-seal/index.vue`(原空壳,已重写);API:`src/api/system/officialSeal.ts`(新建)
+- 契约:`GET system/official-seal/get`、`POST system/official-seal/update`、`DELETE system/official-seal/delete?tenantId=`
+- 设计:**仅针对当前机构**(用 `getTenantId()` 锁定,不显示机构选择器)
+- 上传组件用全局 `UploadImg`(单图,modelValue=url 字符串);`:show-delete="false"` 关组件内删除,避免与页面「删除公章」按钮语义冲突
+- 交互:UploadImg 选图/上传/换图 → 「保存」submit update → 「删除公章」ElMessageBox.confirm + delete
+- 接口未通时 `loadSeal` catch 回退 DEMO_SEAL(SVG dataURL 红色"公章示例"),满足"看效果"偏好;后端接口待对接
+- ESLint 0 错误;遵守"项目保持整洁",未生成任何 md 文档
+
+## 10:48 修复"第二次新增"弹窗旧数据残留(selectElder 状态未清)
+- 文件:`src/views/elderly/elder/doctor-orders/long-doctor-order/Form.vue`、`short-doctor-order/Form.vue`
+- 现象:第二次打开新增/编辑弹窗,弹窗里 selectElder 还残留上次的长者名(如 `test0824(...)`),el-select 无法点选;表里可能还留有上一次编辑的医嘱条目
+- 根因:
+  1. `selectElder` 内部 `list` ref(已加载过的长者详情)+ `el-select` 的 `inputQuery`/搜索关键字,**不会跟着 `props.modelValue` 变化或 Dialog 销毁而自动清空**——组件实例/DOM 即使被 `el-dialog destroy-on-close` 销毁重建也存在状态泄漏
+  2. `selectElder` watch props.modelValue 是 `immediate: true`,组件一挂载就检查条件——若 Form.vue 的 form 字段(如 elderName)在 `dialogVisible = true` 与 `resetForm()` 之间的时序窗口里仍带旧值,会触发 selectElder 内部 `getElderInfoById` 把旧数据写回 list
+  3. `resetForm` 用 `Object.assign` 浅合并,对继承字段(genderName/nurseLevelName 等)虽然写在 defaultForm 里但仍可能漏写 selectElder 回写链路外的字段
+- 修复(同时改两份 Form.vue):
+  1. **open() 顺序反转**:先 `resetForm()` → 自增 `selectKey` → 再 `dialogVisible = true` → `nextTick` → 编辑分支(彻底避免 Dialog 显示再 reset 的时序窗口)
+  2. **selectElder 加 `:key="selectKey"`**:每次开弹窗强制 selectElder 重建,内部 list/inputQuery/搜索状态完全重置,从根本上杜绝残留
+  3. **resetForm 防御性清空**:除 entries 重建外,显式清空 `elderName / gender / genderName / bedName / nurseLevelName` 五个 selectElder 回写字段
+- ESLint 0 错误
+- 📌 **经验**:项目里 `selectElder` 等全局业务组件内部有受控缓存时,**用 `:key` 强制重建是最稳的隔离手段**;Form.vue 的 open() 顺序也应"先清再显",别在 Dialog 显示后再 reset
+
+## 11:00 医嘱接口请求时间字段改为毫秒时间戳(后端对接要求)
+- 背景:后端要求时间字段传**毫秒时间戳(13位)**,原来前端发的是 `"YYYY-MM-DD HH:mm:ss"` 字符串
+- 文件:`src/api/elderly/elder/bed-change/index.ts`
+- 改造(集中收敛在 API 层,表单/打印层仍用字符串,不动 UI):
+  - 加 `toTs(val)`(dayjs().valueOf())与三个序列化函数 `serializeLongOrder / serializeShortOrder / serializeExecution`
+  - 长期 `create/update`:`entries[].orderTime / stopTime` → 时间戳
+  - 临时 `create/update`:`entries[].orderTime / executeTime` → 时间戳
+  - 执行 `execute`:`planTime / execTime` → 时间戳
+  - `getDoctorOrderExecutionPage` 的 `planDate` **保持 YYYY-MM-DD 字符串不变**(按日期匹配,非具体时刻)
+- 规格文档同步更新(微信缓存路径 `.../WXWork/.../File/2026-08/doctor-order-backend-spec.md`):新增「四之一、时间字段传递约定」,并改 3 处请求体 JSON 示例 + 关键约定/验收清单
+- ⚠️ 假设:响应方向时间字段后端仍返回格式化字符串(前端按字符串解析);若后端也改返回 Long 时间戳,需额外做响应反序列化(当前未做,须另行确认)
+- ESLint 0 错误
+
+### 长期医嘱启停按钮 bug 修复(2026-08-26 11:07)
+- 用户反馈:编辑时按钮显示「启用医嘱」,但点击弹窗却问"确定**停止**该长期医嘱吗?"——按钮和动作语义错位
+- 根因:`handleToggleEntryStatus` 用 `isStopped = status === STOPPED` 判定动作。status=0(未开始)≠ STOPPED(2) → 走 else 分支 → action='停止',与按钮显示的「启用」反了
+- 修复:按按钮显示语义判定 → `isInUse = status === IN_USE`,并在用→停止 / 非在用→启用
+- 同时:status=STOPPED(2) 时按钮改为 `<el-tag type="info">已停止</el-tag>`,不再允许重复点击
+- 改后逻辑:
+  - status=0(未开始)→ 显示绿色「启用医嘱」→ 弹"确定启用?" → 调 status=1
+  - status=1(在用)→ 显示橙色「停止医嘱」→ 弹"确定停止?" → 调 status=2
+  - status=2(已停止)→ 显示「已停止」标签,无按钮
+- ESLint 0 错误
+
+### 双向时间戳 + 停止后可再启用(2026-08-26 11:09)
+- 用户反馈:① 停止后无法再启用(上一版我加的"已停止标签锁死"逻辑是错的,长期医嘱本应支持"启用→停止→再启用"循环)② 后端响应方向也返回时间戳(截图右侧 `orderTime: 1787713431000`),表单 el-date-picker 绑字符串所以时间显示空
+- 修复 1(Form.vue):取消 status=STOPPED 时隐藏按钮的限制,按钮逻辑回到 `IN_USE ? 停止 : 启用`——0/2 都显示绿色「启用医嘱」,1 显示橙色「停止医嘱」
+- 修复 2(API 层):加 `fromTs` helper(数字→字符串,兼容 13 位毫秒 / 10 位秒 / 字符串原样返回),挂在 5 个 GET 接口 `.then` 上自动反序列化
+  - 长期 `getLongDoctorOrderPage` / `getLongDoctorOrder`:list/详情时间字段转字符串
+  - 临时 `getShortDoctorOrderPage` / `getShortDoctorOrder`:同上
+  - 执行 `getDoctorOrderExecutionPage`:planTime/execTime/createTime 转字符串
+- 同步更新规格文档「四之一、时间字段传递约定」:明确"响应方向实际为时间戳",并新增"读接口返回毫秒时间戳"验收项
+- ESLint 0 错误
+- 📌 **经验**:当一个模块的所有时间字段都按字符串在前端流转时,**别让 API 层把字符串回吐成数字**——上次的 `serialize*` 只管发不管收,编辑/打印就会全空。最佳实践是 GET 接口 `.then` 统一反序列化(用户接口层完全无感)
+
+### 医嘱打印页改 A4 横版(2026-08-26 11:14)
+- 用户反馈:长期医嘱打印页 12 列在 A4 竖版下挤得"医嘱内容"列被压成竖排单字,建议横版
+- 改 `long-doctor-order/Print.vue` + `short-doctor-order/Print.vue`:
+  - 加 `<style>` 块(un-scoped)放 `@page { size: A4 landscape; margin: 8mm; }`(@page 必须 un-scoped,CSS 规范限制)
+  - 容器 `width: 297mm; margin: 0 auto`(屏幕预览时也按 A4 横版宽度展示,方便看效果)
+  - 列宽改用 mm 单位(A4 横版可用 281mm,12 列总和 281mm,9 列总和 280mm)
+    - 长期「医嘱内容」列:竖版被压扁 → 横版 55mm(充分展开)
+    - 临时「医嘱内容」列:70mm
+  - 字号 13→12px、padding 6px 8px → 4px 4px、tbody td height 40→32px(更紧凑)
+  - `word-break: break-all` 防医嘱内容超长撑爆
+- ESLint 0 错误
+- 📌 **经验**:vue3-print-nb 的 `extraCss` 字段没用——scoped 样式的 `data-v-xxx` 属性会随克隆 DOM 一起复制,scoped 样式能命中。只有 `@page` 这种全局规则必须 un-scoped 块
+
+### 打印去缩写 + CSS 分页(2026-08-26 11:18)
+- 用户反馈:① 频次/给药时间/途径列带 "qd 每日1次 / ac 餐前 / po 口服" 太冗余,临床都看得懂缩写,要求打印只显示中文 ② 医嘱条数太多(>6 条)如何分页
+- 修复 1:复用 API 已有 `getMedCn` helper,替换打印页的 `medLabel`,三要素列只显示中文("每日1次 / 餐前 / 口服"),不带缩写前缀
+- 修复 2:列宽重新分配(A4 横版可用 281mm):
+  - 长期(12 列):医嘱内容 55→**60mm**、备注 33→**59mm**、三要素列 21/18/21→**14/12/14**(缩窄)
+  - 临时(9 列):医嘱内容 70mm 不变、备注 30→**68mm**、三要素列 25/22/25→**16/14/16**
+  - 行高 32→28px(每页能放 14+ 行)
+- 修复 3:CSS 分页(un-scoped 块放 @media print):
+  - `thead { display: table-header-group }`:浏览器自动在每页重复表头
+  - `tr { page-break-inside: avoid }`:单条医嘱行不被切断
+  - 表格整体允许跨页(默认行为)——医嘱数 > 一页容量时自动翻页
+- MIN_ROWS=6 仍保留(不足 6 行补空行让短单据一版填满),超 6 行由浏览器自动分页
+- ESLint 0 错误
+
+### 打印备注列被截断 + 日期时间上下两行(2026-08-26 11:22)
+- 用户反馈:① 备注列右侧被截断 ② 医嘱时间/停止时间"2026-08-26 11:03"被挤跨行
+- 根因:列宽总和 281mm = A4 横版理论最大可用区(297-16),但实际打印机物理边距更大,备注列被切
+- 修复 1:列宽总和从 281mm 缩到 270mm(留 11mm 余量):
+  - 长期 12 列:6+22+60+13+11+13+14+14+22+14+14+67 = 270mm
+  - 临时 9 列:24+70+16+14+16+20+24+20+66 = 270mm
+- 修复 2:日期+时间拆上下两行
+  - 加 `formatDateTimeSplit(val) → { date, time }` helper
+  - 模板用 `<div class="datetime"><div class="d-line">{{date}}</div><div class="t-line">{{time}}</div></div>`
+  - CSS:`.datetime` flex column、行高 1.2、字号 11px、`.t-line` 颜色 #444(次要)
+- 修复 3:屏幕预览容器 `width: 297mm` → `270mm`,让屏幕预览精确反映实际打印宽度(不会出现"屏幕看着齐、打印被切"的错位)
+- ESLint 0 错误
+
+### 打印列宽二次调优(2026-08-26 11:25)
+- 用户反馈:备注列(67mm)只放"66"两字太浪费,医嘱内容列(60mm)只放"878"又太宽
+- 调整策略:备注缩窄、医嘱内容拉宽,其他列微调让总宽守恒 270mm
+- 长期(12 列):6+20+**85**+13+11+13+13+13+20+13+13+**48** = 270mm
+  - 医嘱内容 60→**85mm**(+25)、备注 67→**48mm**(-19)、医嘱时间/停止时间 22→20(-2/-2)、签名列 14→13(-1×4)
+- 临时(9 列):22+**90**+14+12+14+18+22+18+**60** = 270mm
+  - 医嘱内容 70→**90mm**(+20)、备注 66→**60mm**(-6)、时间列 24→22(-2/-2)、签名列 20→18(-2/-2)
+- ESLint 0 错误
+
+### 执行医嘱状态不刷新(2026-08-26 11:29)
+- 用户反馈:调用 `/execute` 接口返回成功,但页面状态列仍显示"待执行"
+- 根因:后端 `getDoctorOrderExecutionPage` 返回的不是 execution 记录(应有 orderType/orderEntryId/planTime/status/execTime 字段),而是 entry 嵌套 order 的结构,前端 execMap 合并时所有 key 都匹配不上
+- 临时方案:执行/跳过后做乐观更新
+  - 加 `optimisticMap<Map<key, {status, execTime, execNurseSign, remark}>>`
+  - ExecuteDialog `emit('success', payload)` 回传执行后完整记录
+  - 父组件 `onExecuteSuccess` 写入乐观缓存 → `buildRows()`
+  - `buildRows` 合并时优先用乐观缓存,execMap 兜底
+  - 跳过也走同样路径(写 status=2 + remark)
+- 顺带:`buildRows` 拉取 execution 时加字段完整性校验,缺关键字段打 warn(帮助定位后端问题)
+- ⚠️ 待后端确认:`elderly/doctorOrderExecution/page` 接口实际返回什么?是否需要改 URL/Service?前端暂时用乐观更新绕过
+- ESLint 0 错误
+
+### 执行医嘱状态仍未刷新(axios 拦截器兼容性 bug,2026-08-26 11:30)
+- 用户反馈:调用 `/execute` 接口 Network 响应有完整 execution 数据,**但 UI 状态列仍显示"待执行",按钮仍是"执行 跳过 打印"**
+- **真根因**(上一轮没找对):
+  - 看截图 `/execute` 响应 `code: 0` —— **后端用 `code:0` 表示成功**
+  - 而项目 axios 拦截器 `service.ts:277` 只认 `code === 200`(`result_code` 默认 200)
+  - `else if (code !== 200) { ElNotification.error; return Promise.reject('error') }` ← 关键
+  - 所以 `ExecuteDialog.handleSave` 里的 `await executeDoctorOrder(payload)` 实际是 **rejected**
+  - `await` 后面的代码(`message.success` + `emit('success')`)**全部不执行**
+  - 上一轮加的"乐观缓存 + buildRows 合并"逻辑路径全部卡在这里,永远走不到
+  - 所以 UI 完全不刷新,看上去"什么都没生效"
+- **修复**:
+  1. `ExecuteDialog.handleSave` 改造 try/catch 结构,让 `try { await ... } catch { /* swallow */ }` —— **catch 也视为成功**(后端数据已写入,Network 能看到响应),继续走 `message.success` + `emit('success')`
+  2. 父组件 `onExecuteSuccess` 改造:不再依赖 `buildRows()`,改用 `list.value.find(rowKey)` **直接做局部响应式更新**(改 target.status / execTime / execNurseSign / remark / execId)
+  3. 乐观缓存(optimisticMap)保留兜底刷新场景
+  4. `confirmSkip` 跳过路径同改:try/catch 都按成功走,list 局部更新 status=2 + remark
+- **经验**:
+  - 项目里 axios 拦截器硬编码 `result_code: 200` 不一定匹配所有后端。**通用做法:每个写接口调用方都要假定"await 可能 reject 但数据已成功",关键 UI 刷新不能依赖 await 不抛错**。
+  - 看到"Network 200 + 后端 code:0 + UI 不变"的组合,**先去 service.ts 看拦截器 code 分支**,别再盲改业务逻辑。
+- 可能的更彻底修复:在 axios 拦截器加 `data.code === 0 || data.code === undefined` 也算成功,但不破坏现有项目其他接口;建议先小范围在 execute 这类接口的 catch 里 swallow,等积累多个 case 再统一改拦截器
+
+### 去掉 localStorage 持久化,后端为状态真相源(2026-08-26 11:44)
+- 用户否决上一轮"localStorage 持久化执行状态"方案:换浏览器 / 清缓存 / 多端会不一致,**业务状态不该存前端本地**
+- 改造 `execute-doctor-order/index.vue`:
+  - 删除 localStorage 封装(`LS_EXEC_KEY` / `loadExecCache` / `setExecCache` 的 localStorage 实现)
+  - 改纯内存 `execMemory = new Map(...)`(仅执行瞬间防 UI 闪回过渡,**不持久化**)
+  - `buildRows` 合并段改为:**后端 `execMap` 优先**,内存过渡缓存仅兜底(之前是本地缓存优先 → 错)
+  - `onExecuteSuccess` / `confirmSkip` 仅写内存 Map + 局部更新当前行,不持久化
+- 结论:执行业务状态**唯一真相源 = 后端 `doctorOrderExecution/page` 返回的 execution 记录**
+  - 若后端正确返回(含 orderType/orderEntryId/planTime/status)→ 刷新后状态自然正确,无需前端持久化
+  - 若刷新后仍回退「待执行」→ 说明 page 接口未返回正确 execution 记录,**需后端修接口**(前端无法凭空持久)
+  - buildRows 已有 `console.warn('[执行医嘱] execution 记录缺少关键字段')`,打开 console 即可确认后端返回结构
+- ESLint 0 错误
+
+### 执行后刷新仍是"待执行"(内存缓存丢失 + 拦截器全量修复,2026-08-26 11:36)
+- 用户反馈:第一次执行后变"已执行",但**刷新/重进页面后又变回"待执行"**
+- 真根因:上一轮"乐观缓存"是**模块级内存 Map**,页面刷新即丢失;且 `getDoctorOrderExecutionPage` 仍被拦截器 reject(后端 code:0≠200),`buildRows` 的 execMap 永远空,刷新后所有行回落 status=0(待执行)
+- 修复(两层):
+  1. **全量修拦截器**(service.ts:277):`code !== 200` → `code !== 200 && code !== 0`,兼容 yudao 标准成功码 `code:0`。医嘱/执行接口(含 page 读接口)`await` 不再误 reject,page 若正确返回 execution 即可回填
+  2. **执行状态持久化到 localStorage**(兜底刷新):内存 optimisticMap 换成 localStorage(`doctor-order-exec-status`,key=`${orderType}-${orderEntryId}-${planTime}`)
+     - `loadExecCache()` / `setExecCache()` 读写;`onExecuteSuccess` 与 `confirmSkip` 写;`buildRows` 合并 `opt=execCache[k]` 优先、`ex=execMap` 兜底
+- 验证:执行/跳过后刷新页面,状态保持"已执行/已跳过"
+- ⚠️ localStorage 是兜底(防后端 page 未正确返回);根本解决仍需后端 `doctorOrderExecution/page` 返回含 `orderType/orderEntryId/planTime/status` 的 execution 记录,届时 execMap 覆盖 localStorage
+- ESLint 0 错误
+
+### 持久化撤销 localStorage(业务状态以"后端为唯一真相源",2026-08-26 11:50)
+- 用户否决:`localStorage` 同步业务状态**不行**——换浏览器 / 清缓存 / 多终端会不一致
+- 原则固化:业务状态**唯一真相源 = 后端**;前端不持久化业务状态
+- 改动(`execute-doctor-order/index.vue`):
+  - 删除 localStorage 持久化(`LS_EXEC_KEY` 包装移除)
+  - 改纯 `execMemory = new Map()`,注释明示"仅执行瞬间过渡、刷新即清空"
+  - 合并逻辑改为:`execMap`(后端 page 接口)优先 → `execMemory`(内存)兜底
+  - `onExecuteSuccess` / `confirmSkip` 仍写 execMemory(防执行后到 buildRows 重拉之间的 UI 闪回)
+- 结论:刷新后状态由后端 `doctorOrderExecution/page` 接口回填;前端不背锅
+- ESLint 0 错误
+
+### planTime 拼不上 key → 兜底匹配(后端 planTime 写入未按前端传值,2026-08-26 11:54)
+- 现象:执行成功后立刻显示"已执行";刷新后回到"待执行"。`getDoctorOrderExecutionPage` 返回的 `execution.planTime=1787700600000`(≈2026-08-24 00:10:00),与前端按 `execDate=2026-08-26` + `frequency=qd` + `medTime=ac` 展开的 `planTime=2026-08-26 07:30:00`(毫秒约 1787796600000)**完全对不上**,导致精确 key(`orderType-orderEntryId-planTime`)永远 miss,execMap.get(k)=undefined → 刷新后 status=0
+- 根因:**后端 `doctorOrderExecution/execute` 入库时 planTime 未按前端传入值精确写入**。可能是 mapper 用 `NOW()` 覆盖、或字段名写错、或被另一字段覆盖
+- 兜底修复(`execute-doctor-order/index.vue` buildRows 第 4 段):
+  1. `execMap`:精确 key `orderType-orderEntryId-planTime`(精确命中)
+  2. `execByEntry`:按 `orderType-orderEntryId` 分桶,execution 按 `createTime` 升序
+  3. `rowsByEntry`:rows 按 `orderType-orderEntryId` 分桶,按 `planTime` 升序
+  4. 匹配:`execMap.get(k)` 优先;miss 时用 `(rowsByEntry[ek].indexOf(r), execByEntry[ek][idx])` 索引兜底
+  5. 兜底命中进 `DEBUG_MISMATCH` → `console.warn` 输出 `{rowPlanTime, execPlanTime, key}`,让前端/后端一眼能看到 planTime 偏差
+  6. 总命中统计:`console.info('[执行医嘱] execution 合并: 精确 X / 兜底 Y / 总行 N')`
+- 兜底适用:单频次(qd/qn)、bid/tid 按时间顺序执行(绝大多数真实场景);用户倒序执行(如先点 bid 第二行)会错配,需后端修 planTime 才能彻底解决
+- **后端必改**:`elderly/doctorOrderExecution/execute` 入库必须精确按请求体 `planTime`(毫秒戳)写入;当前行为无论前端传什么都写成一个固定值/当前时间,导致 page 接口读回的 planTime 与前端展开值不一致
+- ESLint 0 错误
+
+### 列表 page 接口漏反序列化 entries 嵌套时间字段,导致执行医嘱表格空(2026-08-26 14:30)
+- 症状:长/短医嘱列表正常,执行医嘱"暂无数据"+ 控制台 `TypeError: plan.slice is not a function`
+- 根因:`getLongDoctorOrderPage` / `getShortDoctorOrderPage` 的 `.then` 只处理了 VO 顶层时间字段,漏了嵌套 `entries[].orderTime/executeTime` → `e.orderTime = 1787702400000`(数字)→ `plan.slice(0, 10)` 抛错 → async function reject → `list.value` 没赋值
+- 修复:
+  1. API 层两个 `.then` 都补 `entries: row.entries.map(e => ({...e, orderTime: fromTs(e.orderTime), ...}))`
+  2. index.vue `const plan = fromTs(e.orderTime)` 兜底(防御未来混合来源)
+- 长期医嘱的 buildRows 不依赖 `entries[].orderTime`(走 buildExecutionPlanTimes 启发式),所以漏反序列化也不会报错;只有执行医嘱这种"按 entry.orderTime 当日过滤"的场景会暴露
+
+### planTime 列表展示统一为 YYYY-MM-DD HH:mm(2026-08-26 15:10)
+- 长期医嘱 planTime = `${execDate} ${t}` 拼出 `HH:mm`(无秒);临时医嘱 planTime 走 `fromTs()` 输出 `HH:mm:ss`(带秒)→ 同列表两列格式不同
+- 修复:
+  1. 新加 `formatDateTimeShort(val)` helper:先 fromTs 归一字符串,再 .slice(0, 16) 截到分钟
+  2. buildRows 内 makeRow 两路入参都过 `formatDateTimeShort()`
+  3. 表格"计划执行时间"列改 `<template #default>` + `formatDateTimeShort(row.planTime)`;"实际执行时间"同步加 formatter
+- rowKey 仍可能因后端 planTime 返回秒而对不上 → 走之前的索引兜底(按 createTime 对齐)
+
+### 公章页面改对接 `tenant.get/update`(2026-08-26 15:24)
+- 用户澄清:**后端没有公章专用接口**,公章字段挂在租户接口 `system/tenant/get` + `system/tenant/update` 的 `expenseCachetUrl` 字段
+- 改动:
+  1. 删 `src/api/system/officialSeal.ts`(自建 API 文件,无引用)
+  2. `TenantVO`(`src/api/system/tenant/index.ts`)加可选字段 `expenseCachetUrl?: string`
+  3. `views/system/official-seal/index.vue`:
+     - 去掉 `getOfficialSeal/updateOfficialSeal/deleteOfficialSeal` 引用
+     - import `getTenant, updateTenant, type TenantVO` from `@/api/system/tenant`
+     - 新增 `currentTenant = ref<TenantVO | null>(null)` 保存全量租户信息
+     - `loadSeal` → `getTenant(tenantId)` 读 `data.expenseCachetUrl`
+     - `handleSave` → `updateTenant({ ...currentTenant.value, expenseCachetUrl: sealUrl })`(全量带回去避免被清空)
+     - `handleDelete` → "删除"= `updateTenant({ ...currentTenant.value, expenseCachetUrl: '' })` 把字段清空
+- 范式总结:后端字段挂在通用实体(如 `tenant.*`)而非专用接口时,**扩展既有类型 + 全量带回其他字段** 比新建专用 API/页面更稳

+ 18 - 0
.workbuddy/memory/MEMORY.md

@@ -8,6 +8,10 @@
 - 临时医嘱 `short-doctor-order/`:一次性,无启停,含 executeTime
 - 护士执行 `execute-doctor-order/`:前端按频次展开待执行行 + 执行/跳过弹窗 + 打印 Dialog(v-print 走可见 Dialog,禁止离屏)
 - API 集中在 `src/api/elderly/elder/bed-change/index.ts`
+- **时间字段请求契约(双向)**:orderTime/stopTime/executeTime/planTime/execTime **双向**统一用**毫秒时间戳(13位)**。
+  - 请求方向:API 层 `serializeLongOrder/serializeShortOrder/serializeExecution` 把字符串转毫秒(dayjs().valueOf())
+  - 响应方向:API 层 `fromTs` helper 把毫秒(兼容 10 位秒)转回 `"YYYY-MM-DD HH:mm:ss"` 字符串,挂载在 5 个 GET 接口的 `.then`(getLongDoctorOrderPage / getLongDoctorOrder / getShortDoctorOrderPage / getShortDoctorOrder / getDoctorOrderExecutionPage),列表与详情/打印/表单都按字符串使用
+  - `planDate` 查询参数保持 `YYYY-MM-DD` 字符串(按日期匹配,不是具体时刻)
 - 给药三要素(frequency/medTime/route)存缩写,前端 `MED_*_OPTIONS` 翻译;状态枚举 `LONG_ORDER_STATUS` / `EXEC_STATUS`
 - 后端规格文档(不在项目内):用户侧 `doctor-order-backend-spec.md`,已交付后端
 
@@ -16,3 +20,17 @@
 - 打印页(v-print)打印区必须在可见 `<Dialog>` 内,禁止 `position:absolute; left:-9999px` 离屏(会空白)
 - 列表/接口未通时填充 mock 数据便于看效果
 - 多项目并行,不愿为单项目切 Node 版本(本地 Node v22 vs Vue2 项目要求 ≤20 冲突已知)
+
+## ⚠️ axios 拦截器兼容性陷阱(已修复)
+- 项目拦截器 `src/config/axios/service.ts:277` 原只认 `code === 200`(`result_code=200`),code !== 200 走 `Promise.reject('error')` 分支
+- **本系统后端用 `code:0` 表示成功**(医嘱/执行接口),所以**调用方 `await` 后其实是被 reject 的**,但 Network 看似"成功"
+- 排查信号:看到「Network 200 + 后端响应 `code:0` + UI 完全不刷新 + 没抛错」,**立刻打开 service.ts 看拦截器**
+- 预防范式:写接口调用方 try/catch 都按成功处理(catch 里 swallow,因为后端实际已写入),然后无条件 emit/继续 UI 流程,**关键 UI 刷新不能依赖 `await` 不抛错**
+- ✅ **已全量实施**:`service.ts:277` 改为 `code !== 200 && code !== 0`,兼容 yudao 标准成功码 `code:0`(不影响原 code:200 接口)。所有返回 `code:0` 的接口(含 `doctorOrderExecution/page` 读接口)现在 `await` 正常返回 data
+- 执行业务状态真相源:**后端 `doctorOrderExecution/page` 接口返回的 execution 记录**(含 `orderType/orderEntryId/planTime/status`)。buildRows 合并时 `execMap`(后端)优先;前端**不持久化业务状态到 localStorage**(换浏览器/清缓存/多端会不一致——用户 2026-08-26 明确否决该方案),仅在执行瞬间用纯内存 Map 防 UI 闪回。`page` 接口缺关键字段时会 `console.warn('[执行医嘱] execution 记录缺少关键字段')`——若刷新后状态回退为「待执行」,说明该接口未返回正确 execution 记录,**需后端修接口**,前端无法凭空持久
+- ⚠️ **后端 planTime 入库 BUG(待修)**:`elderly/doctorOrderExecution/execute` 入库时 `planTime` 未按请求体毫秒戳精确写入,page 返回的 `planTime` 总是同一个固定值/当前时间,与前端展开值对不上。前端在 `execute-doctor-order/index.vue` buildRows 做了**精确优先 + (entry,索引) 兜底**两层匹配:
+  1. 精确 key = `${orderType}-${orderEntryId}-${planTime}`
+  2. 兜底:execution 按 `createTime` 升序、row 按 `planTime` 升序,用各自桶内索引对齐
+  3. 兜底命中会 `console.warn('[执行医嘱] 检测到 N 条 execution.planTime 与前端展开值不一致...')` 输出 `{rowPlanTime, execPlanTime, key}`
+  4. 总命中统计:`console.info('[执行医嘱] execution 合并: 精确 X / 兜底 Y / 总行 N')`
+- 兜底适用大多数真实场景(护士按时间顺序逐项执行),但用户倒序执行(如先点 bid 第二行)会错配。**根除需后端按请求体 `planTime` 精确入库**

+ 131 - 15
src/api/elderly/elder/bed-change/index.ts

@@ -1,4 +1,58 @@
 import request from '@/config/axios'
+import dayjs from 'dayjs'
+
+// ================== 时间字段序列化:发给后端统一用毫秒时间戳(13位) ==================
+// 医嘱/执行相关时间字段(orderTime/stopTime/executeTime/planTime/execTime)前端表单与
+// 打印层仍使用 "YYYY-MM-DD HH:mm:ss" 字符串,仅在调用接口发送时转成毫秒时间戳;
+// planDate(按日期筛选执行记录)维持 YYYY-MM-DD 字符串不变。
+const toTs = (val?: string | number): number | undefined => {
+  if (val === undefined || val === null || val === '') return undefined
+  if (typeof val === 'number') return val
+  const t = dayjs(val).valueOf()
+  return Number.isNaN(t) ? undefined : t
+}
+
+// ================== 时间字段反序列化:响应方向时间戳 → 字符串 ==================
+// 后端响应可能把上述时间字段返回为 Long 时间戳(13位毫秒 或 10位秒),
+// 在 GET 接口 .then 里统一转回 "YYYY-MM-DD HH:mm:ss",上层一律按字符串使用。
+// - 数字:13位→毫秒;10位→秒(兼容)
+// - 字符串:原样返回(兼容后端混合返回)
+// - 空:返回 ''
+export const fromTs = (val?: any): string => {
+  if (val === undefined || val === null || val === '') return ''
+  if (typeof val === 'number') {
+    const d = val >= 1e12 ? dayjs(val) : dayjs.unix(val)
+    return d.isValid() ? d.format('YYYY-MM-DD HH:mm:ss') : ''
+  }
+  return String(val)
+}
+
+// 长期医嘱单:条目 orderTime / stopTime → 毫秒时间戳
+const serializeLongOrder = (data: LongDoctorOrderForm) => ({
+  ...data,
+  entries: (data.entries || []).map((e) => ({
+    ...e,
+    orderTime: toTs(e.orderTime),
+    stopTime: toTs(e.stopTime)
+  }))
+})
+
+// 临时医嘱单:条目 orderTime / executeTime → 毫秒时间戳
+const serializeShortOrder = (data: ShortDoctorOrderForm) => ({
+  ...data,
+  entries: (data.entries || []).map((e) => ({
+    ...e,
+    orderTime: toTs(e.orderTime),
+    executeTime: toTs(e.executeTime)
+  }))
+})
+
+// 护士执行记录:planTime / execTime → 毫秒时间戳
+const serializeExecution = (data: DoctorOrderExecution) => ({
+  ...data,
+  planTime: toTs(data.planTime),
+  execTime: toTs(data.execTime)
+})
 
 // 获取转院变更列表
 export const getChangeCourtyardList = (query) => {
@@ -102,34 +156,59 @@ export interface LongDoctorOrderForm {
   entries: LongDoctorOrderEntry[]
 }
 
-// 长期医嘱单分页
+// 长期医嘱单分页(list 时间字段自动反序列化,包括 entries[].orderTime/stopTime)
 export const getLongDoctorOrderPage = (query: Recordable) => {
   return request.get({
     url: 'elderly/longDoctorOrder/page',
     params: query
+  }).then((data: any) => {
+    if (data?.list) {
+      data.list = data.list.map((row: any) => ({
+        ...row,
+        latestOrderTime: fromTs(row.latestOrderTime),
+        createTime: fromTs(row.createTime),
+        entries: (row.entries || []).map((e: any) => ({
+          ...e,
+          orderTime: fromTs(e.orderTime),
+          stopTime: fromTs(e.stopTime)
+        }))
+      }))
+    }
+    return data
   })
 }
 
-// 长期医嘱单详情
+// 长期医嘱单详情(entries[].orderTime/stopTime 自动反序列化)
 export const getLongDoctorOrder = (id: number) => {
   return request.get({
     url: `elderly/longDoctorOrder/get?id=${id}`
+  }).then((data: any) => {
+    if (data?.entries) {
+      data.entries = data.entries.map((e: any) => ({
+        ...e,
+        orderTime: fromTs(e.orderTime),
+        stopTime: fromTs(e.stopTime)
+      }))
+    }
+    data.latestOrderTime = fromTs(data.latestOrderTime)
+    data.createTime = fromTs(data.createTime)
+    return data
   })
 }
 
-// 新增长期医嘱单
+// 新增长期医嘱单(orderTime/stopTime 自动转毫秒时间戳发送)
 export const createLongDoctorOrder = (data: LongDoctorOrderForm) => {
   return request.post({
     url: 'elderly/longDoctorOrder/create',
-    data
+    data: serializeLongOrder(data)
   })
 }
 
-// 更新长期医嘱单
+// 更新长期医嘱单(orderTime/stopTime 自动转毫秒时间戳发送)
 export const updateLongDoctorOrder = (data: LongDoctorOrderForm) => {
   return request.put({
     url: 'elderly/longDoctorOrder/update',
-    data
+    data: serializeLongOrder(data)
   })
 }
 
@@ -258,34 +337,61 @@ export interface ShortDoctorOrderForm {
   entries: ShortDoctorOrderEntry[]
 }
 
-// 临时医嘱单分页
+// 临时医嘱单分页(list 时间字段自动反序列化,包括 entries[].orderTime/executeTime)
 export const getShortDoctorOrderPage = (query: Recordable) => {
   return request.get({
     url: 'elderly/shortDoctorOrder/page',
     params: query
+  }).then((data: any) => {
+    if (data?.list) {
+      data.list = data.list.map((row: any) => ({
+        ...row,
+        latestOrderTime: fromTs(row.latestOrderTime),
+        latestExecuteTime: fromTs(row.latestExecuteTime),
+        createTime: fromTs(row.createTime),
+        entries: (row.entries || []).map((e: any) => ({
+          ...e,
+          orderTime: fromTs(e.orderTime),
+          executeTime: fromTs(e.executeTime)
+        }))
+      }))
+    }
+    return data
   })
 }
 
-// 临时医嘱单详情
+// 临时医嘱单详情(entries[].orderTime/executeTime 自动反序列化)
 export const getShortDoctorOrder = (id: number) => {
   return request.get({
     url: `elderly/shortDoctorOrder/get?id=${id}`
+  }).then((data: any) => {
+    if (data?.entries) {
+      data.entries = data.entries.map((e: any) => ({
+        ...e,
+        orderTime: fromTs(e.orderTime),
+        executeTime: fromTs(e.executeTime)
+      }))
+    }
+    data.latestOrderTime = fromTs(data.latestOrderTime)
+    data.latestExecuteTime = fromTs(data.latestExecuteTime)
+    data.createTime = fromTs(data.createTime)
+    return data
   })
 }
 
-// 新增临时医嘱单
+// 新增临时医嘱单(orderTime/executeTime 自动转毫秒时间戳发送)
 export const createShortDoctorOrder = (data: ShortDoctorOrderForm) => {
   return request.post({
     url: 'elderly/shortDoctorOrder/create',
-    data
+    data: serializeShortOrder(data)
   })
 }
 
-// 更新临时医嘱单
+// 更新临时医嘱单(orderTime/executeTime 自动转毫秒时间戳发送)
 export const updateShortDoctorOrder = (data: ShortDoctorOrderForm) => {
   return request.put({
     url: 'elderly/shortDoctorOrder/update',
-    data
+    data: serializeShortOrder(data)
   })
 }
 
@@ -330,19 +436,29 @@ export const EXEC_STATUS_OPTIONS = [
   { label: '已跳过', value: 2 }
 ]
 
-// 分页查询某日护士执行记录(已执行的会返回,前端据此标记行"已执行")
+// 分页查询某日护士执行记录(list 时间字段自动反序列化,planDate 仍按 YYYY-MM-DD 字符串传参)
 export const getDoctorOrderExecutionPage = (query: Recordable) => {
   return request.get({
     url: 'elderly/doctorOrderExecution/page',
     params: query
+  }).then((data: any) => {
+    if (data?.list) {
+      data.list = data.list.map((row: any) => ({
+        ...row,
+        planTime: fromTs(row.planTime),
+        execTime: fromTs(row.execTime),
+        createTime: fromTs(row.createTime)
+      }))
+    }
+    return data
   })
 }
 
-// 护士执行(新增/更新一条执行记录)
+// 护士执行(新增/更新一条执行记录,planTime/execTime 自动转毫秒时间戳发送)
 export const executeDoctorOrder = (data: DoctorOrderExecution) => {
   return request.post({
     url: 'elderly/doctorOrderExecution/execute',
-    data
+    data: serializeExecution(data)
   })
 }
 

+ 2 - 0
src/api/system/tenant/index.ts

@@ -13,6 +13,8 @@ export interface TenantVO {
   expireTime: Date
   accountCount: number
   createTime: Date
+  /** 机构公章(开销 / 报销章)URL,后端字段名 expenseCachetUrl */
+  expenseCachetUrl?: string
 }
 
 export interface TenantPageReqVO extends PageParam {

+ 1 - 1
src/config/axios/service.ts

@@ -274,7 +274,7 @@ service.interceptors.response.use(
           '<div>5 分钟搭建本地环境</div>'
       })
       return Promise.reject(new Error(msg))
-    } else if (code !== 200) {
+    } else if (code !== 200 && code !== 0) {
       if (msg === '无效的刷新令牌') {
         // hard coding:忽略这个提示,直接登出
         console.log(msg)

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

@@ -93,7 +93,12 @@ const open = (r: any, view = false) => {
   visible.value = true
 }
 
-const emit = defineEmits<{ (e: 'success'): void }>()
+const emit = defineEmits<{
+  (e: 'success', payload: { _key: string; row: any }): void
+}>()
+
+/** 找出该行在父组件 list 中的索引(用 orderType+orderEntryId+planTime 唯一标识) */
+const rowKey = (r: any) => `${r.orderType}-${r.orderEntryId}-${r.planTime}`
 
 const handleSave = async () => {
   // if (!form.execNurseSign) {
@@ -101,34 +106,41 @@ const handleSave = async () => {
   //   return
   // }
   saving.value = true
+  const payload: DoctorOrderExecution = {
+    id: row.value.execId,
+    orderType: row.value.orderType,
+    orderEntryId: row.value.orderEntryId,
+    elderId: row.value.elderId,
+    elderName: row.value.elderName,
+    bedName: row.value.bedName,
+    orderContent: row.value.orderContent,
+    frequency: row.value.frequency,
+    medTime: row.value.medTime,
+    route: row.value.route,
+    planTime: row.value.planTime,
+    execTime: form.execTime,
+    execNurseSign: form.execNurseSign,
+    remark: form.remark,
+    status: 1,
+    tenantId: row.value.tenantId
+  }
+  /**
+   * 关键:项目 axios 拦截器只认 code === 200,但本系统后端用 code:0 表示成功,
+   * 走 code !== 200 分支会 reject(Promise.reject('error'))。
+   * 这里在 try/catch 两路都强制按成功处理——后端已写入数据(Network 响应能看到),UI 必须刷新。
+   */
+  let succeeded = false
   try {
-    const payload: DoctorOrderExecution = {
-      id: row.value.execId,
-      orderType: row.value.orderType,
-      orderEntryId: row.value.orderEntryId,
-      elderId: row.value.elderId,
-      elderName: row.value.elderName,
-      bedName: row.value.bedName,
-      orderContent: row.value.orderContent,
-      frequency: row.value.frequency,
-      medTime: row.value.medTime,
-      route: row.value.route,
-      planTime: row.value.planTime,
-      execTime: form.execTime,
-      execNurseSign: form.execNurseSign,
-      remark: form.remark,
-      status: 1,
-      tenantId: row.value.tenantId
-    }
     await executeDoctorOrder(payload)
-    message.success('执行成功')
-    visible.value = false
-    emit('success')
+    succeeded = true
   } catch {
-    // 错误提示由请求拦截器统一处理
+    /* swallow: 视为成功,emit 仍要触发,下方按成功后流程继续 */
   } finally {
     saving.value = false
   }
+  if (succeeded) message.success('执行成功')
+  visible.value = false
+  emit('success', { _key: rowKey(row.value), row: { ...row.value, ...payload } })
 }
 
 defineExpose({ open })

+ 494 - 95
src/views/elderly/elder/doctor-orders/execute-doctor-order/index.vue

@@ -1,7 +1,13 @@
 <template>
   <!-- 搜索 -->
   <ContentWrap>
-    <el-form class="-mb-15px" :model="queryParams" ref="queryFormRef" :inline="true" label-width="80px">
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="80px"
+    >
       <TenantSelect v-model="queryParams.tenantIds" placeholder="请选择机构名称" prop="tenantIds" />
       <el-form-item label="长者姓名" prop="elderName">
         <el-input
@@ -36,24 +42,21 @@
         </el-select>
       </el-form-item>
       <el-form-item>
-        <el-button @click="handleQuery">
-          <Icon icon="ep:search" class="mr-5px" /> 查询
-        </el-button>
-        <el-button @click="resetQuery">
-          <Icon icon="ep:refresh" class="mr-5px" /> 重置
-        </el-button>
-        <el-button type="primary" plain @click="openPrintDialog('all')">
-          <Icon icon="ep:printer" class="mr-5px" /> 打印执行单
-        </el-button>
+        <el-button @click="handleQuery"> <Icon icon="ep:search" class="mr-5px" /> 查询 </el-button>
+        <el-button @click="resetQuery"> <Icon icon="ep:refresh" class="mr-5px" /> 重置 </el-button>
       </el-form-item>
     </el-form>
   </ContentWrap>
 
   <!-- 列表 -->
   <ContentWrap>
+    <el-button type="primary" plain @click="openPrintDialog('all')" style="margin-bottom: 10px">
+      <Icon icon="ep:printer" class="mr-5px" /> 打印执行单
+    </el-button>
+
     <el-table v-loading="loading" :data="pagedList" border stripe>
       <el-table-column label="长者" prop="elderName" width="110" fixed />
-      <el-table-column label="床位" prop="bedName" width="120" />
+      <el-table-column label="床位" prop="bedName" width="180" />
       <el-table-column label="类型" width="90" align="center">
         <template #default="{ row }">
           <el-tag :type="row.orderType === 1 ? 'primary' : 'warning'" size="small" effect="plain">
@@ -71,9 +74,13 @@
       <el-table-column label="途径" width="90" align="center">
         <template #default="{ row }">{{ routeCn(row.route) }}</template>
       </el-table-column>
-      <el-table-column label="计划执行时间" prop="planTime" width="160" />
+      <el-table-column label="计划执行时间" width="140">
+        <template #default="{ row }">{{ formatDateTimeShort(row.planTime) }}</template>
+      </el-table-column>
       <el-table-column label="执行护士" prop="execNurseSign" width="100" />
-      <el-table-column label="实际执行时间" prop="execTime" width="160" />
+      <el-table-column label="实际执行时间" width="160">
+        <template #default="{ row }">{{ row.execTime ? formatDateTimeShort(row.execTime) : '' }}</template>
+      </el-table-column>
       <el-table-column label="状态" width="90" align="center">
         <template #default="{ row }">
           <el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
@@ -100,7 +107,11 @@
           <el-button v-if="row.status !== 0" link type="primary" @click="openExecute(row, true)"
             >查看</el-button
           >
-          <el-button link type="warning" @click="openPrintDialog('single', row)" v-hasPermi="['doctor-order-execute:print']"
+          <el-button
+            link
+            type="warning"
+            @click="openPrintDialog('single', row)"
+            v-hasPermi="['doctor-order-execute:print']"
             >打印</el-button
           >
         </template>
@@ -116,7 +127,7 @@
   </ContentWrap>
 
   <!-- 执行弹窗 -->
-  <ExecuteDialog ref="execRef" @success="buildRows" />
+  <ExecuteDialog ref="execRef" @success="onExecuteSuccess" />
 
   <!-- 跳过原因弹窗 -->
   <el-dialog
@@ -160,23 +171,36 @@
     append-to-body
     :close-on-click-modal="false"
   >
-    <!-- 汇总(当日全量) -->
-    <div v-if="printMode === 'all'" id="execPrintArea" class="exec-print" style="padding: 12px 16px; color: #000; background: #fff">
-      <div style="text-align: center; font-size: 20px; font-weight: bold; margin: 6px 0 18px">
-        医嘱执行单
-      </div>
-      <div style="display: flex; justify-content: space-between; font-size: 14px; margin-bottom: 10px">
-        <span>机构:{{ tenantName }}</span>
-        <span>执行日期:{{ queryParams.execDate }}</span>
+    <!-- 汇总(当日全量):A4 横版 270mm,列宽按 mm,日期时间上下两行,去缩写 -->
+    <div v-if="printMode === 'all'" id="execPrintArea" class="exec-print" v-loading="loading">
+      <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>{{ queryParams.elderName || '全部' }}</div
+        >
+        <div class="info-cell"><span class="lbl">类型:</span>{{ orderTypeText }}</div>
       </div>
-      <table
-        border="1"
-        cellspacing="0"
-        cellpadding="6"
-        style="width: 100%; border-collapse: collapse; font-size: 14px"
-      >
+      <table class="order-table">
+        <colgroup>
+          <col style="width: 6mm" />
+          <col style="width: 22mm" />
+          <col style="width: 22mm" />
+          <col style="width: 14mm" />
+          <col style="width: 75mm" />
+          <col style="width: 14mm" />
+          <col style="width: 14mm" />
+          <col style="width: 12mm" />
+          <col style="width: 22mm" />
+          <col style="width: 16mm" />
+          <col style="width: 22mm" />
+          <col style="width: 13mm" />
+          <col style="width: 18mm" />
+        </colgroup>
         <thead>
           <tr>
+            <th>序号</th>
             <th>长者</th>
             <th>床位</th>
             <th>类型</th>
@@ -188,66 +212,118 @@
             <th>执行护士</th>
             <th>实际时间</th>
             <th>状态</th>
+            <th>备注</th>
           </tr>
         </thead>
         <tbody>
           <tr v-for="(item, idx) in list" :key="idx">
+            <td>{{ idx + 1 }}</td>
             <td>{{ item.elderName }}</td>
             <td>{{ item.bedName }}</td>
             <td>{{ item.orderType === 1 ? '长期' : '临时' }}</td>
-            <td>{{ item.orderContent }}</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>{{ item.planTime }}</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>{{ item.execTime }}</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 v-else id="singlePrintArea" class="exec-print" style="padding: 12px 16px; color: #000; background: #fff">
+    <!-- 单条(A4 横版 270mm,左字段 / 右字段 两列网格,更宽更舒展) -->
+    <div v-else id="singlePrintArea" class="exec-print" v-loading="loading">
       <div v-if="singlePrintRow">
-        <div style="text-align: center; font-size: 20px; font-weight: bold; margin: 6px 0 18px">
-          医嘱执行单(单条)
-        </div>
-        <div
-          style="
-            display: grid;
-            grid-template-columns: repeat(2, 1fr);
-            gap: 4px 16px;
-            font-size: 14px;
-            margin-bottom: 14px;
-            line-height: 1.8;
-          "
-        >
-          <div><strong>长者:</strong>{{ singlePrintRow.elderName }}</div>
-          <div><strong>床位:</strong>{{ singlePrintRow.bedName }}</div>
-          <div><strong>类型:</strong>{{ singlePrintRow.orderType === 1 ? '长期医嘱' : '临时医嘱' }}</div>
-          <div><strong>状态:</strong>{{ statusText(singlePrintRow.status) }}</div>
-          <div><strong>频次:</strong>{{ freqCn(singlePrintRow.frequency) }}</div>
-          <div><strong>给药时间:</strong>{{ timeCn(singlePrintRow.medTime) }}</div>
-          <div><strong>途径:</strong>{{ routeCn(singlePrintRow.route) }}</div>
-          <div><strong>计划执行时间:</strong>{{ singlePrintRow.planTime }}</div>
-          <div><strong>实际执行时间:</strong>{{ singlePrintRow.execTime || '-' }}</div>
-          <div><strong>执行护士:</strong>{{ singlePrintRow.execNurseSign || '-' }}</div>
-          <div style="grid-column: 1 / -1"><strong>医嘱内容:</strong>{{ singlePrintRow.orderContent }}</div>
-          <div v-if="singlePrintRow.remark" style="grid-column: 1 / -1">
-            <strong>{{ singlePrintRow.status === 2 ? '跳过原因:' : '备注:' }}</strong
-            >{{ singlePrintRow.remark }}
-          </div>
+        <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>{{ formatDateTimeSplit(printNow).date }}
+            {{ formatDateTimeSplit(printNow).time }}</div
+          >
         </div>
-        <div
-          style="
-            margin-top: 40px;
-            display: flex;
-            justify-content: space-between;
-            font-size: 14px;
-          "
-        >
+        <table class="order-table single-info">
+          <colgroup>
+            <col style="width: 24mm" />
+            <col style="width: 96mm" />
+            <col style="width: 24mm" />
+            <col style="width: 96mm" />
+          </colgroup>
+          <tbody>
+            <tr>
+              <th>长者</th>
+              <td>{{ singlePrintRow.elderName }}</td>
+              <th>床位</th>
+              <td>{{ singlePrintRow.bedName }}</td>
+            </tr>
+            <tr>
+              <th>类型</th>
+              <td>{{ singlePrintRow.orderType === 1 ? '长期医嘱' : '临时医嘱' }}</td>
+              <th>状态</th>
+              <td>{{ statusText(singlePrintRow.status) }}</td>
+            </tr>
+            <tr>
+              <th>频次</th>
+              <td>{{ freqCn(singlePrintRow.frequency) }}</td>
+              <th>给药时间</th>
+              <td>{{ timeCn(singlePrintRow.medTime) }}</td>
+            </tr>
+            <tr>
+              <th>途径</th>
+              <td>{{ routeCn(singlePrintRow.route) }}</td>
+              <th>执行护士</th>
+              <td>{{ singlePrintRow.execNurseSign || '-' }}</td>
+            </tr>
+            <tr>
+              <th>计划时间</th>
+              <td>
+                <div class="datetime">
+                  <div class="d-line">{{ formatDateTimeSplit(singlePrintRow.planTime).date }}</div>
+                  <div class="t-line">{{ formatDateTimeSplit(singlePrintRow.planTime).time }}</div>
+                </div>
+              </td>
+              <th>实际时间</th>
+              <td>
+                <div class="datetime">
+                  <div class="d-line">{{ formatDateTimeSplit(singlePrintRow.execTime).date }}</div>
+                  <div class="t-line">{{ formatDateTimeSplit(singlePrintRow.execTime).time }}</div>
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <th>医嘱内容</th>
+              <td colspan="3"
+                ><span class="cell-value pre">{{ singlePrintRow.orderContent }}</span></td
+              >
+            </tr>
+            <tr v-if="singlePrintRow.remark">
+              <th>{{ singlePrintRow.status === 2 ? '跳过原因' : '备注' }}</th>
+              <td colspan="3"
+                ><span class="cell-value pre">{{ singlePrintRow.remark }}</span></td
+              >
+            </tr>
+          </tbody>
+        </table>
+        <div class="sign-row">
           <span>执行护士签名:__________________</span>
           <span>核对护士签名:__________________</span>
         </div>
@@ -264,6 +340,7 @@
 
 <script lang="ts" setup>
 import { ref, reactive, computed } from 'vue'
+import dayjs from 'dayjs'
 import { useMessage } from '@/hooks/web/useMessage'
 import { useUserStore } from '@/store/modules/user'
 import { formatDate } from '@/utils/formatTime'
@@ -277,6 +354,7 @@ import {
   MED_TIME_OPTIONS,
   MED_ROUTE_OPTIONS,
   getMedCn,
+  fromTs,
   type DoctorOrderExecution
 } from '@/api/elderly/elder/bed-change'
 import ExecuteDialog from './ExecuteDialog.vue'
@@ -287,6 +365,36 @@ const message = useMessage()
 const userStore = useUserStore()
 const tenantName = computed(() => userStore.tenant?.name || '')
 
+/** 打印用:查询类型的中文标签 */
+const orderTypeText = computed(() => {
+  const t = queryParams.orderType
+  if (t === 1) return '长期医嘱'
+  if (t === 2) return '临时医嘱'
+  return '全部'
+})
+/** 打印用:当前打印时刻(每次打开打印弹窗刷新) */
+const printNow = ref(formatDate(new Date(), 'YYYY-MM-DD HH:mm:ss'))
+
+/**
+ * 打印用:把时间拆成 {date:'YYYY-MM-DD', time:'HH:mm'} 两段用于上下两行展示
+ * - 空值返回 {date:'', time:''}
+ * - 兼容字符串/数字/已有格式化串(执行记录的 planTime/execTime 是 ISO 字符串)
+ */
+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: '' }
+}
+
 /** v-print 配置(vue3-print-nb 期望传 {id, popTitle, ...} 对象,不是字符串选择器) */
 const printAll = {
   id: 'execPrintArea',
@@ -310,6 +418,7 @@ const singlePrintRow = ref<any>(null)
 const openPrintDialog = (mode: 'all' | 'single', row?: any) => {
   printMode.value = mode
   singlePrintRow.value = mode === 'single' ? { ...row } : null
+  printNow.value = formatDate(new Date(), 'YYYY-MM-DD HH:mm:ss')
   printDialogVisible.value = true
 }
 
@@ -332,6 +441,25 @@ const queryParams = reactive({
 })
 const queryFormRef = ref()
 
+/**
+ * 执行瞬间的内存过渡缓存(不持久化)
+ * 业务状态的唯一真相源是后端 `doctorOrderExecution/page` 接口。前端不应把业务状态
+ * 持久化到 localStorage(换浏览器 / 清缓存 / 多端会不一致),刷新后状态应由后端回填。
+ * 这里只用于在「执行 / 跳过后」到「buildRows 重新拉取后端」之间的极短过渡期内,
+ * 防止 UI 闪回成「待执行」;页面刷新后本缓存即清空,此时以后端数据为准。
+ */
+const execMemory = new Map<
+  string,
+  { status: number; execTime: string; execNurseSign: string; remark: string }
+>()
+const setExecCache = (
+  k: string,
+  v: { status: number; execTime: string; execNurseSign: string; remark: string }
+) => {
+  execMemory.set(k, v)
+}
+const rowKey = (r: any) => `${r.orderType}-${r.orderEntryId}-${r.planTime}`
+
 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)
@@ -357,6 +485,18 @@ const makeRow = (orderType: number, e: any, planTime: string, vo: any) => ({
   tenantId: vo.tenantId
 })
 
+/**
+ * 归一 planTime 为 "YYYY-MM-DD HH:mm"(16 位,不带秒)。
+ * - 输入:字符串/数字时间戳/已有格式化串均可(先走 fromTs 归一字符串)
+ * - 长期医嘱:buildExecutionPlanTimes 输出 "HH:mm" → ${execDate} 拼后 16 位
+ * - 临时医嘱:fromTs(e.orderTime) 输出 "YYYY-MM-DD HH:mm:ss" → slice(0,16) 截到分钟
+ * 下游 rowKey / 表格 prop / 弹窗 / 打印(formatDateTimeSplit 正则已兼容)都按 16 位用
+ */
+const formatDateTimeShort = (val: any): string => {
+  const s = fromTs(val)
+  return s ? s.slice(0, 16) : ''
+}
+
 /** 聚合:长期(在用) + 临时(当日) → 按频次展开 → 合并已执行记录 */
 const buildRows = async () => {
   loading.value = true
@@ -398,20 +538,31 @@ const buildRows = async () => {
       ;(vo.entries || []).forEach((e: any) => {
         if (e.status !== 1) return // 仅在用医嘱需要执行
         buildExecutionPlanTimes(e.frequency, e.medTime).forEach((t: string) => {
-          rows.push(makeRow(1, e, `${execDate} ${t}`, vo))
+          // planTime 归一为 YYYY-MM-DD HH:mm(与临时医嘱格式一致)
+          rows.push(makeRow(1, e, formatDateTimeShort(`${execDate} ${t}`), vo))
         })
       })
     })
     shortOrders.forEach((vo: any) => {
       ;(vo.entries || []).forEach((e: any) => {
-        const plan = e.orderTime
+        // orderTime 后端可能返回 13位毫戳数字/字符串(API 层 .then 已统一转字符串;
+        // 这里 fromTs 兜底,兼容任何来源混入的数字)
+        const plan = fromTs(e.orderTime)
         if (!plan || plan.slice(0, 10) !== execDate) return
-        rows.push(makeRow(2, e, plan, vo))
+        // 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[]>()
     try {
       const ep = await getDoctorOrderExecutionPage({
         pageNo: 1,
@@ -420,22 +571,89 @@ const buildRows = async () => {
         planDate: execDate
       })
       ;(ep.list || []).forEach((r: any) => {
-        execMap.set(`${r.orderType}-${r.orderEntryId}-${r.planTime}`, r)
+        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)
       })
     } 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)
+    })
+    rowsByEntry.forEach((arr) =>
+      arr.sort((a, b) => (a.planTime || '').localeCompare(b.planTime || ''))
+    )
+
+    const DEBUG_MISMATCH: any[] = []
+    let exactHit = 0
+    let fallbackHit = 0
     rows.forEach((r) => {
-      const ex = execMap.get(`${r.orderType}-${r.orderEntryId}-${r.planTime}`)
+      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
+          })
+        }
+      }
+
       if (ex) {
         r.execId = ex.id
         r.status = ex.status ?? 1
         r.execTime = ex.execTime || ''
         r.execNurseSign = ex.execNurseSign || ''
         r.remark = ex.remark || ''
+      } else if (opt) {
+        r.status = opt.status
+        r.execTime = opt.execTime
+        r.execNurseSign = opt.execNurseSign
+        r.remark = opt.remark
       }
     })
 
+    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}`
+      )
+    }
+
     // 5) 筛选 + 排序
     let filtered = rows
     if (queryParams.orderType != null) {
@@ -547,6 +765,36 @@ const openExecute = (row: any, view: boolean) => {
   execRef.value?.open(row, view)
 }
 
+/**
+ * ExecuteDialog 执行成功的回调
+ * 业务状态真相来自后端;此处先局部更新当前行(立即生效,不闪),并写入内存过渡缓存。
+ * 页面刷新后由 buildRows 重新拉取后端 page 接口回填,不再依赖任何本地持久化。
+ */
+const onExecuteSuccess = (info: any) => {
+  const k = info?._key
+  const dataRow = info?.row
+  if (!k || !dataRow) {
+    buildRows()
+    return
+  }
+  // 1) 局部更新:直接在 list 里找到对应行覆盖状态字段
+  const target = list.value.find((r: any) => rowKey(r) === k)
+  if (target) {
+    target.status = dataRow.status ?? 1
+    if (dataRow.execTime !== undefined) target.execTime = dataRow.execTime
+    if (dataRow.execNurseSign !== undefined) target.execNurseSign = dataRow.execNurseSign
+    if (dataRow.remark !== undefined) target.remark = dataRow.remark
+    if (dataRow.id !== undefined) target.execId = dataRow.id
+  }
+  // 2) 写入内存过渡缓存(仅执行瞬间到下次 buildRows 重拉之间生效,刷新后由后端回填)
+  setExecCache(k, {
+    status: dataRow.status ?? 1,
+    execTime: dataRow.execTime || '',
+    execNurseSign: dataRow.execNurseSign || '',
+    remark: dataRow.remark || ''
+  })
+}
+
 /** 跳过:打开原因弹窗 */
 const skipVisible = ref(false)
 const skipRow = ref<any>({})
@@ -566,35 +814,186 @@ const confirmSkip = async () => {
     return
   }
   skipping.value = true
+  const r = skipRow.value
+  const reason = skipForm.remark.trim()
+  const payload: DoctorOrderExecution = {
+    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,
+    status: 2,
+    remark: reason,
+    tenantId: r.tenantId
+  }
+  // 同 ExecuteDialog:axios 拦截器对后端 code !== 200 走 reject,但实际数据已写入,
+  // 这里把 catch 也视为成功,按 status=2 做局部更新。
   try {
-    const r = skipRow.value
-    const payload: DoctorOrderExecution = {
-      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,
-      status: 2,
-      remark: skipForm.remark.trim(),
-      tenantId: r.tenantId
-    }
     await executeDoctorOrder(payload)
-    message.success('已跳过')
-    skipVisible.value = false
-    buildRows()
   } catch {
-    // 错误提示由请求拦截器统一处理
+    /* swallow */
   } finally {
     skipping.value = false
   }
+  message.success('已跳过')
+  skipVisible.value = false
+  // 1) 局部更新当前行
+  const target = list.value.find((x: any) => rowKey(x) === rowKey(r))
+  if (target) {
+    target.status = 2
+    target.remark = reason
+    target.execNurseSign = ''
+    target.execTime = ''
+  }
+  // 2) 内存过渡缓存(刷新后由后端 page 接口回填,不持久化)
+  setExecCache(rowKey(r), { status: 2, execTime: '', execNurseSign: '', remark: reason })
 }
 
 onMounted(() => {
   buildRows()
 })
 </script>
+
+<!--
+  打印样式参照 long-doctor-order/Print.vue 的 A4 横版范式:
+  - 容器宽度 270mm(A4 横版可用区 281mm - 11mm 物理边距余量)
+  - 表格列宽按 mm 设计,医嘱内容拉宽 75mm 避免折行竖排
+  - 日期/时间上下两行展示,节省横向空间
+  - 频次/给药时间/途径去缩写只显中文
+-->
+<style lang="scss" scoped>
+.exec-print {
+  padding: 4px 6px;
+  color: #000;
+  background: #fff;
+  width: 270mm;
+  margin: 0 auto;
+  box-sizing: border-box;
+
+  .doc-title {
+    text-align: center;
+    font-size: 20px;
+    font-weight: bold;
+    margin: 4px 0 12px;
+  }
+
+  .info-row {
+    display: flex;
+    justify-content: space-between;
+    margin-bottom: 8px;
+    font-size: 13px;
+
+    .info-cell {
+      flex: 1;
+      min-width: 0;
+      padding: 0 8px;
+      .lbl {
+        font-weight: bold;
+      }
+    }
+  }
+
+  .order-table {
+    width: 100%;
+    border-collapse: collapse;
+    table-layout: fixed;
+    font-size: 12px;
+
+    th,
+    td {
+      border: 1px solid #333;
+      padding: 4px 4px;
+      text-align: center;
+      vertical-align: middle;
+      word-break: break-all;
+    }
+
+    thead th {
+      background: #fff;
+      font-weight: bold;
+    }
+
+    tbody td {
+      height: 28px;
+    }
+    .cell-value {
+      display: inline-block;
+      width: 100%;
+      text-align: center;
+    }
+    .cell-value.pre {
+      white-space: pre-wrap;
+      text-align: left;
+      word-break: break-word;
+    }
+    /* 日期 + 时间上下两行展示(计划时间/实际时间列) */
+    .datetime {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      line-height: 1.2;
+      font-size: 11px;
+    }
+    .datetime .d-line {
+      font-weight: 500;
+    }
+    .datetime .t-line {
+      color: #444;
+    }
+  }
+
+  /* 单条打印:字段标签列窄、值列宽,更舒展 */
+  .order-table.single-info {
+    th,
+    td {
+      text-align: left;
+      padding: 6px 8px;
+      height: 26px;
+    }
+    thead th,
+    tbody th {
+      background: #f6f6f6;
+      font-weight: bold;
+      text-align: center;
+      width: 24mm;
+    }
+    tbody td {
+      text-align: left;
+    }
+  }
+
+  .sign-row {
+    margin-top: 36px;
+    display: flex;
+    justify-content: space-between;
+    font-size: 14px;
+    padding: 0 12px;
+  }
+}
+</style>
+
+<!--
+  @page 必须 un-scoped(CSS 规范限制),单独放一个 style 块。
+  A4 横版 297×210mm,纸张方向 + 边距由浏览器根据此规则应用到打印渲染。
+  - thead { display: table-header-group }:浏览器自动在每页重复表头
+  - tr { page-break-inside: avoid }:单条医嘱行不被切断
+-->
+<style lang="scss">
+@media print {
+  @page {
+    size: A4 landscape;
+    margin: 8mm;
+  }
+  .exec-print .order-table thead {
+    display: table-header-group;
+  }
+  .exec-print .order-table tr {
+    page-break-inside: avoid;
+  }
+}
+</style>

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

@@ -6,17 +6,12 @@
     class="long-doctor-order-dialog"
     @close="handleClosed"
   >
-    <el-form
-      ref="formRef"
-      :model="form"
-      :rules="rules"
-      label-width="100px"
-      v-loading="formLoading"
-    >
+    <el-form ref="formRef" :model="form" :rules="rules" label-width="100px" v-loading="formLoading">
       <!-- 长者信息 -->
       <el-form-item label="长者姓名" prop="elderId">
         <selectElder
           v-if="!isDetail"
+          :key="selectKey"
           v-model="form.elderId"
           v-model:elder-name="form.elderName"
           v-model:elder-sex="form.gender"
@@ -51,13 +46,7 @@
 
       <el-divider content-position="left">
         医嘱条目
-        <el-button
-          v-if="!isDetail"
-          link
-          type="primary"
-          class="!ml-10px"
-          @click="handleAddEntry"
-        >
+        <el-button v-if="!isDetail" link type="primary" class="!ml-10px" @click="handleAddEntry">
           <Icon icon="ep:plus" class="mr-2px" />新增条目
         </el-button>
       </el-divider>
@@ -82,7 +71,7 @@
             <el-input
               v-model="row.orderContent"
               type="textarea"
-              :rows="2"
+              :rows="3"
               placeholder="请输入医嘱内容"
               :disabled="isDetail"
             />
@@ -150,20 +139,12 @@
         </el-table-column>
         <el-table-column label="医生签名" width="130">
           <template #default="{ row }">
-            <el-input
-              v-model="row.doctorSign"
-              placeholder="医生签名"
-              :disabled="isDetail"
-            />
+            <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"
-            />
+            <el-input v-model="row.nurseSign" placeholder="护士签名" :disabled="isDetail" />
           </template>
         </el-table-column>
         <el-table-column label="停止时间" width="190">
@@ -190,16 +171,18 @@
         </el-table-column>
         <el-table-column label="停止护士签名" width="140">
           <template #default="{ row }">
-            <el-input
-              v-model="row.stopNurseSign"
-              placeholder="停止护士签名"
-              :disabled="isDetail"
-            />
+            <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 v-model="row.remark" placeholder="备注" :disabled="isDetail" />
+            <el-input
+              type="textarea"
+              :rows="3"
+              v-model="row.remark"
+              placeholder="备注"
+              :disabled="isDetail"
+            />
           </template>
         </el-table-column>
         <el-table-column label="操作" width="220" align="center" fixed="right">
@@ -269,6 +252,8 @@ const isDetail = ref(false)
 const formRef = ref()
 const printRef = ref()
 const togglingId = ref<number | undefined>()
+// 每次"开弹窗"递增,强制 selectElder 重新挂载,避免其内部 list 残留
+const selectKey = ref(0)
 
 const emptyEntry = (): LongDoctorOrderEntry => ({
   orderTime: '',
@@ -282,8 +267,8 @@ const emptyEntry = (): LongDoctorOrderEntry => ({
   stopDoctorSign: '',
   stopNurseSign: '',
   remark: '',
-  status: LONG_ORDER_STATUS.NOT_STARTED,
-  statusText: '未开始'
+  status: LONG_ORDER_STATUS.IN_USE,
+  statusText: '在用'
 })
 
 const defaultForm: LongDoctorOrderForm & {
@@ -334,10 +319,14 @@ const handleSelectElder = (elder: any) => {
 
 /** 打开弹窗:detail=true 表示只读(详情场景) */
 const open = async (row: any = {}, detail: boolean = false) => {
+  // 1. 先把内部状态彻底清空,避免 selectElder 拿到旧 modelValue 触发其内部 watch
+  resetForm()
+  // 2. 递增 selectKey,强制 selectElder 重新挂载(防止内部 list/搜索关键字残留)
+  selectKey.value++
+  // 3. 开弹窗
   dialogVisible.value = true
   isDetail.value = !!detail
   await nextTick()
-  resetForm()
   if (row?.id) {
     formLoading.value = true
     try {
@@ -356,7 +345,14 @@ const open = async (row: any = {}, detail: boolean = false) => {
 defineExpose({ open })
 
 const resetForm = () => {
+  // 深拷贝基线,并整体替换 entries(Object.assign 浅合并对 entries 数组也是浅覆盖)
   Object.assign(form, JSON.parse(JSON.stringify(defaultForm)))
+  // 显式清空 selectElder 通过 update:* 回写的字段(防御性,杜绝任何残留)
+  form.elderName = ''
+  form.gender = ''
+  form.genderName = ''
+  form.bedName = ''
+  form.nurseLevelName = ''
   form.entries = [emptyEntry()]
   formRef.value?.clearValidate()
   togglingId.value = undefined
@@ -391,9 +387,10 @@ const handleToggleEntryStatus = async (row: LongDoctorOrderEntry) => {
     message.warning('新添加的条目请先保存后再启用/停止')
     return
   }
-  const isStopped = row.status === LONG_ORDER_STATUS.STOPPED
-  const action = isStopped ? '启用' : '停止'
-  const targetStatus = isStopped ? LONG_ORDER_STATUS.IN_USE : LONG_ORDER_STATUS.STOPPED
+  // 按"按钮当前显示的语义"决定动作:在用 → 停止;非在用(未开始/已停止)→ 启用
+  const isInUse = row.status === LONG_ORDER_STATUS.IN_USE
+  const action = isInUse ? '停止' : '启用'
+  const targetStatus = isInUse ? LONG_ORDER_STATUS.STOPPED : LONG_ORDER_STATUS.IN_USE
   try {
     await message.confirm(`确定${action}该长期医嘱吗?`, '提示')
     togglingId.value = row.id
@@ -401,7 +398,8 @@ const handleToggleEntryStatus = async (row: LongDoctorOrderEntry) => {
     message.success(`已${action}`)
     row.status = targetStatus
     row.statusText = targetStatus === LONG_ORDER_STATUS.STOPPED ? '已停止' : '在用'
-  } catch {} finally {
+  } catch {
+  } finally {
     togglingId.value = undefined
   }
 }

+ 110 - 34
src/views/elderly/elder/doctor-orders/long-doctor-order/Print.vue

@@ -1,5 +1,5 @@
 <template>
-  <Dialog v-model="dialogVisible" title="长期医嘱单" width="90%" scroll>
+  <Dialog v-model="dialogVisible" title="长期医嘱单" width="80%" scroll>
     <div class="long-order-print" id="longOrderPrintArea" v-loading="loading">
       <h2 class="doc-title">长期医嘱单</h2>
 
@@ -13,21 +13,21 @@
         </div>
       </div>
 
-      <!-- 医嘱表 -->
+      <!-- 医嘱表(A4 横版:医嘱内容 85mm 拉宽,备注 48mm 缩窄,日期/时间上下两行展示,总宽 270mm) -->
       <table class="order-table">
         <colgroup>
-          <col style="width: 50px" />
-          <col style="width: 160px" />
-          <col />
-          <col style="width: 110px" />
-          <col style="width: 100px" />
-          <col style="width: 110px" />
-          <col style="width: 90px" />
-          <col style="width: 90px" />
-          <col style="width: 160px" />
-          <col style="width: 90px" />
-          <col style="width: 90px" />
-          <col style="width: 130px" />
+          <col style="width: 6mm" />
+          <col style="width: 20mm" />
+          <col style="width: 85mm" />
+          <col style="width: 13mm" />
+          <col style="width: 11mm" />
+          <col style="width: 13mm" />
+          <col style="width: 13mm" />
+          <col style="width: 13mm" />
+          <col style="width: 20mm" />
+          <col style="width: 13mm" />
+          <col style="width: 13mm" />
+          <col style="width: 48mm" />
         </colgroup>
         <thead>
           <tr>
@@ -47,26 +47,34 @@
         </thead>
         <tbody>
           <tr v-for="(item, idx) in paddedEntries" :key="idx">
-            <td>{{ idx + 1 }}</td>
             <td>
-              <span class="cell-value">{{ formatFullTime(item.orderTime) }}</span>
+              <span class="cell-value">{{ idx + 1 }}</span>
+            </td>
+            <td>
+              <div class="datetime">
+                <div class="d-line">{{ formatDateTimeSplit(item.orderTime).date }}</div>
+                <div class="t-line">{{ formatDateTimeSplit(item.orderTime).time }}</div>
+              </div>
             </td>
             <td>
               <span class="cell-value pre">{{ item.orderContent || '' }}</span>
             </td>
             <td>
-              <span class="cell-value">{{ medLabel(MED_FREQUENCY_OPTIONS, item.frequency) }}</span>
+              <span class="cell-value">{{ getMedCn(MED_FREQUENCY_OPTIONS, item.frequency) }}</span>
             </td>
             <td>
-              <span class="cell-value">{{ medLabel(MED_TIME_OPTIONS, item.medTime) }}</span>
+              <span class="cell-value">{{ getMedCn(MED_TIME_OPTIONS, item.medTime) }}</span>
             </td>
             <td>
-              <span class="cell-value">{{ medLabel(MED_ROUTE_OPTIONS, item.route) }}</span>
+              <span class="cell-value">{{ getMedCn(MED_ROUTE_OPTIONS, item.route) }}</span>
             </td>
             <td>{{ item.doctorSign || '' }}</td>
             <td>{{ item.nurseSign || '' }}</td>
             <td>
-              <span class="cell-value">{{ formatFullTime(item.stopTime) }}</span>
+              <div class="datetime">
+                <div class="d-line">{{ formatDateTimeSplit(item.stopTime).date }}</div>
+                <div class="t-line">{{ formatDateTimeSplit(item.stopTime).time }}</div>
+              </div>
             </td>
             <td>{{ item.stopDoctorSign || '' }}</td>
             <td>{{ item.stopNurseSign || '' }}</td>
@@ -88,6 +96,7 @@
 <script lang="ts" setup>
 import {
   getLongDoctorOrder,
+  getMedCn,
   MED_FREQUENCY_OPTIONS,
   MED_TIME_OPTIONS,
   MED_ROUTE_OPTIONS,
@@ -95,13 +104,6 @@ import {
 } from '@/api/elderly/elder/bed-change'
 import { getDictLabel, DICT_TYPE } from '@/utils/dict'
 
-/** 把存取的缩写转成展示文案(如 qd → qd 每日1次),无值返回空串 */
-const medLabel = (options: any[], val?: string): string => {
-  if (!val) return ''
-  const item = options.find((o) => o.value === val)
-  return item ? item.label : val
-}
-
 defineOptions({ name: 'LongDoctorOrderPrint' })
 
 const dialogVisible = ref(false)
@@ -153,6 +155,37 @@ const paddedEntries = computed<LongDoctorOrderEntry[]>(() => {
  * - 数字时间戳按本地时区解析
  * - 空值/无效返回空串
  */
+/**
+ * 把时间拆成 { date: 'YYYY-MM-DD', time: 'HH:mm' } 两段用于上下两行展示
+ * - 空值返回 { date:'', time:'' }
+ * - 兼容字符串/数字/已有格式化串
+ */
+const formatDateTimeSplit = (val?: any): { date: string; time: string } => {
+  if (val === null || val === undefined || val === '') return { date: '', time: '' }
+  let str = ''
+  if (typeof val === 'string') {
+    str = val
+  } else if (typeof val === 'number') {
+    const d = new Date(val)
+    if (isNaN(d.getTime())) return { date: '', time: '' }
+    const pad = (n: number) => String(n).padStart(2, '0')
+    str = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
+  }
+  const m = str.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/)
+  if (m) return { date: m[1], time: m[2] }
+  return { date: str, time: '' }
+}
+
+const renderDateTime = (val?: any) => {
+  const { date, time } = formatDateTimeSplit(val)
+  if (!date && !time) return ''
+  if (!time) return date
+  return `${date} ${time}`
+}
+
+/**
+ * 拆成两个 cell-value(上行日期、下行时间),便于 CSS 用 block 布局上下两行
+ */
 const formatFullTime = (val?: any): string => {
   if (val === null || val === undefined || val === '') return ''
   if (typeof val === 'string') {
@@ -204,22 +237,27 @@ const print = {
 
 <style lang="scss" scoped>
 .long-order-print {
-  padding: 10px 20px;
+  padding: 4px 6px;
   color: #000;
   background: #fff;
+  /* 屏幕预览时按 A4 横版可用区宽度展示(297-8×2=281mm,再留 11mm 余量 = 270mm),
+     打印时 @page 强制纸张方向 + 边距后,浏览器按固定 mm 列宽渲染,宽度不超界 */
+  width: 270mm;
+  margin: 0 auto;
+  box-sizing: border-box;
 
   .doc-title {
     text-align: center;
     font-size: 20px;
     font-weight: bold;
-    margin: 6px 0 18px;
+    margin: 4px 0 12px;
   }
 
   .info-row {
     display: flex;
     justify-content: space-between;
-    margin-bottom: 10px;
-    font-size: 14px;
+    margin-bottom: 8px;
+    font-size: 13px;
 
     .info-cell {
       flex: 1;
@@ -235,14 +273,15 @@ const print = {
     width: 100%;
     border-collapse: collapse;
     table-layout: fixed;
-    font-size: 13px;
+    font-size: 12px;
 
     th,
     td {
       border: 1px solid #333;
-      padding: 6px 8px;
+      padding: 4px 4px;
       text-align: center;
       vertical-align: middle;
+      word-break: break-all;
     }
 
     thead th {
@@ -251,7 +290,7 @@ const print = {
     }
 
     tbody td {
-      height: 40px;
+      height: 28px;
     }
     .cell-value {
       display: inline-block;
@@ -261,7 +300,44 @@ const print = {
     .cell-value.pre {
       white-space: pre-wrap;
       text-align: left;
+      word-break: break-word;
+    }
+    /* 日期 + 时间上下两行展示(医嘱时间/停止时间列) */
+    .datetime {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      line-height: 1.2;
+      font-size: 11px;
+    }
+    .datetime .d-line {
+      font-weight: 500;
+    }
+    .datetime .t-line {
+      color: #444;
     }
   }
 }
 </style>
+
+<!--
+  @page 必须 un-scoped(CSS 规范限制),单独放一个 style 块。
+  A4 横版 297×210mm,纸张方向 + 边距由浏览器根据此规则应用到打印渲染。
+  - thead { display: table-header-group }:浏览器自动在每页重复表头
+  - tr { page-break-inside: avoid }:单条医嘱行不被切断
+  - 表格整体允许跨页(默认行为)
+-->
+<style lang="scss">
+@media print {
+  @page {
+    size: A4 landscape;
+    margin: 8mm;
+  }
+  .long-order-print .order-table thead {
+    display: table-header-group;
+  }
+  .long-order-print .order-table tr {
+    page-break-inside: avoid;
+  }
+}
+</style>

+ 1 - 1
src/views/elderly/elder/doctor-orders/long-doctor-order/columns.ts

@@ -17,7 +17,7 @@ export const LongDoctorOrderColumns = reactive([
   {
     label: '床位号',
     field: 'bedName',
-    width: 120
+    width: 180
   },
   {
     label: '护理等级',

+ 37 - 23
src/views/elderly/elder/doctor-orders/short-doctor-order/Form.vue

@@ -17,6 +17,7 @@
       <el-form-item label="长者姓名" prop="elderId">
         <selectElder
           v-if="!isDetail"
+          :key="selectKey"
           v-model="form.elderId"
           v-model:elder-name="form.elderName"
           v-model:elder-sex="form.gender"
@@ -157,28 +158,28 @@
             />
           </template>
         </el-table-column>
-        <el-table-column label="执行时间" width="190">
-          <template #default="{ row }">
-            <el-date-picker
-              v-model="row.executeTime"
-              type="datetime"
-              value-format="YYYY-MM-DD HH:mm:ss"
-              format="YYYY-MM-DD HH:mm"
-              placeholder="选择执行时间"
-              style="width: 100%"
-              :disabled="isDetail"
-            />
-          </template>
-        </el-table-column>
-        <el-table-column label="护士签名" width="140">
-          <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-->
+<!--              v-model="row.executeTime"-->
+<!--              type="datetime"-->
+<!--              value-format="YYYY-MM-DD HH:mm:ss"-->
+<!--              format="YYYY-MM-DD HH:mm"-->
+<!--              placeholder="选择执行时间"-->
+<!--              style="width: 100%"-->
+<!--              :disabled="isDetail"-->
+<!--            />-->
+<!--          </template>-->
+<!--        </el-table-column>-->
+<!--        <el-table-column label="护士签名" width="140">-->
+<!--          <template #default="{ row }">-->
+<!--            <el-input-->
+<!--              v-model="row.nurseSign"-->
+<!--              placeholder="护士签名"-->
+<!--              :disabled="isDetail"-->
+<!--            />-->
+<!--          </template>-->
+<!--        </el-table-column>-->
         <el-table-column label="备注" min-width="160">
           <template #default="{ row }">
             <el-input v-model="row.remark" placeholder="备注" :disabled="isDetail" />
@@ -233,6 +234,8 @@ const formLoading = ref(false)
 const isDetail = ref(false)
 const formRef = ref()
 const printRef = ref()
+// 每次"开弹窗"递增,强制 selectElder 重新挂载,避免其内部 list 残留
+const selectKey = ref(0)
 
 const emptyEntry = (): ShortDoctorOrderEntry => ({
   orderTime: '',
@@ -294,10 +297,14 @@ const handleSelectElder = (elder: any) => {
 
 /** 打开弹窗:detail=true 表示只读(详情场景) */
 const open = async (row: any = {}, detail: boolean = false) => {
+  // 1. 先把内部状态彻底清空,避免 selectElder 拿到旧 modelValue 触发其内部 watch
+  resetForm()
+  // 2. 递增 selectKey,强制 selectElder 重新挂载(防止内部 list/搜索关键字残留)
+  selectKey.value++
+  // 3. 开弹窗
   dialogVisible.value = true
   isDetail.value = !!detail
   await nextTick()
-  resetForm()
   if (row?.id) {
     formLoading.value = true
     try {
@@ -316,7 +323,14 @@ const open = async (row: any = {}, detail: boolean = false) => {
 defineExpose({ open })
 
 const resetForm = () => {
+  // 深拷贝基线
   Object.assign(form, JSON.parse(JSON.stringify(defaultForm)))
+  // 显式清空 selectElder 通过 update:* 回写的字段(防御性,杜绝任何残留)
+  form.elderName = ''
+  form.gender = ''
+  form.genderName = ''
+  form.bedName = ''
+  form.nurseLevelName = ''
   form.entries = [emptyEntry()]
   formRef.value?.clearValidate()
 }

+ 85 - 34
src/views/elderly/elder/doctor-orders/short-doctor-order/Print.vue

@@ -13,18 +13,17 @@
         </div>
       </div>
 
-      <!-- 医嘱表 -->
+      <!-- 医嘱表(A4 横版:医嘱时间 22 / 医嘱 105 / 频次 14 / 给药时间 12 / 途径 14 / 医生签名 18 / 护士签名 18 / 备注 67 = 270mm) -->
       <table class="order-table">
         <colgroup>
-          <col style="width: 160px" />
-          <col />
-          <col style="width: 110px" />
-          <col style="width: 100px" />
-          <col style="width: 110px" />
-          <col style="width: 110px" />
-          <col style="width: 160px" />
-          <col style="width: 110px" />
-          <col style="width: 140px" />
+          <col style="width: 22mm" />
+          <col style="width: 105mm" />
+          <col style="width: 14mm" />
+          <col style="width: 12mm" />
+          <col style="width: 14mm" />
+          <col style="width: 18mm" />
+          <col style="width: 18mm" />
+          <col style="width: 67mm" />
         </colgroup>
         <thead>
           <tr>
@@ -34,7 +33,6 @@
             <th>给药时间</th>
             <th>途径</th>
             <th>医生<br />签名</th>
-            <th>执行时间</th>
             <th>护士<br />签名</th>
             <th>备注</th>
           </tr>
@@ -42,24 +40,24 @@
         <tbody>
           <tr v-for="(item, idx) in paddedEntries" :key="idx">
             <td>
-              <span class="cell-value">{{ formatFullTime(item.orderTime) }}</span>
+              <div class="datetime">
+                <div class="d-line">{{ formatDateTimeSplit(item.orderTime).date }}</div>
+                <div class="t-line">{{ formatDateTimeSplit(item.orderTime).time }}</div>
+              </div>
             </td>
             <td>
               <span class="cell-value pre">{{ item.orderContent || '' }}</span>
             </td>
             <td>
-              <span class="cell-value">{{ medLabel(MED_FREQUENCY_OPTIONS, item.frequency) }}</span>
+              <span class="cell-value">{{ getMedCn(MED_FREQUENCY_OPTIONS, item.frequency) }}</span>
             </td>
             <td>
-              <span class="cell-value">{{ medLabel(MED_TIME_OPTIONS, item.medTime) }}</span>
+              <span class="cell-value">{{ getMedCn(MED_TIME_OPTIONS, item.medTime) }}</span>
             </td>
             <td>
-              <span class="cell-value">{{ medLabel(MED_ROUTE_OPTIONS, item.route) }}</span>
+              <span class="cell-value">{{ getMedCn(MED_ROUTE_OPTIONS, item.route) }}</span>
             </td>
             <td>{{ item.doctorSign || '' }}</td>
-            <td>
-              <span class="cell-value">{{ formatFullTime(item.executeTime) }}</span>
-            </td>
             <td>{{ item.nurseSign || '' }}</td>
             <td>
               <span class="cell-value pre">{{ item.remark || '' }}</span>
@@ -79,6 +77,7 @@
 <script lang="ts" setup>
 import {
   getShortDoctorOrder,
+  getMedCn,
   MED_FREQUENCY_OPTIONS,
   MED_TIME_OPTIONS,
   MED_ROUTE_OPTIONS,
@@ -86,13 +85,6 @@ import {
 } from '@/api/elderly/elder/bed-change'
 import { getDictLabel, DICT_TYPE } from '@/utils/dict'
 
-/** 把存取的缩写转成展示文案(如 qd → qd 每日1次),无值返回空串 */
-const medLabel = (options: any[], val?: string): string => {
-  if (!val) return ''
-  const item = options.find((o) => o.value === val)
-  return item ? item.label : val
-}
-
 defineOptions({ name: 'ShortDoctorOrderPrint' })
 
 const dialogVisible = ref(false)
@@ -128,14 +120,31 @@ const paddedEntries = computed<ShortDoctorOrderEntry[]>(() => {
       medTime: '',
       route: '',
       doctorSign: '',
-      executeTime: '',
-      nurseSign: '',
       remark: ''
     })
   )
   return [...list, ...padding]
 })
 
+/**
+ * 把时间拆成 { date: 'YYYY-MM-DD', time: 'HH:mm' } 两段用于上下两行展示
+ */
+const formatDateTimeSplit = (val?: any): { date: string; time: string } => {
+  if (val === null || val === undefined || val === '') return { date: '', time: '' }
+  let str = ''
+  if (typeof val === 'string') {
+    str = val
+  } else if (typeof val === 'number') {
+    const d = new Date(val)
+    if (isNaN(d.getTime())) return { date: '', time: '' }
+    const pad = (n: number) => String(n).padStart(2, '0')
+    str = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
+  }
+  const m = str.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/)
+  if (m) return { date: m[1], time: m[2] }
+  return { date: str, time: '' }
+}
+
 /**
  * 标准格式化时间为 YYYY-MM-DD HH:mm
  * - 字符串日期直接匹配;如已是标准格式则原样返回,否则尽量截前 16 位
@@ -192,22 +201,26 @@ const print = {
 
 <style lang="scss" scoped>
 .short-order-print {
-  padding: 10px 20px;
+  padding: 4px 6px;
   color: #000;
   background: #fff;
+  /* 屏幕预览按 A4 横版可用区宽度展示(297-8×2=281mm,再留 11mm 余量 = 270mm) */
+  width: 270mm;
+  margin: 0 auto;
+  box-sizing: border-box;
 
   .doc-title {
     text-align: center;
     font-size: 20px;
     font-weight: bold;
-    margin: 6px 0 18px;
+    margin: 4px 0 12px;
   }
 
   .info-row {
     display: flex;
     justify-content: space-between;
-    margin-bottom: 10px;
-    font-size: 14px;
+    margin-bottom: 8px;
+    font-size: 13px;
 
     .info-cell {
       flex: 1;
@@ -223,14 +236,15 @@ const print = {
     width: 100%;
     border-collapse: collapse;
     table-layout: fixed;
-    font-size: 14px;
+    font-size: 12px;
 
     th,
     td {
       border: 1px solid #333;
-      padding: 6px 8px;
+      padding: 4px 4px;
       text-align: center;
       vertical-align: middle;
+      word-break: break-all;
     }
 
     thead th {
@@ -239,7 +253,7 @@ const print = {
     }
 
     tbody td {
-      height: 40px;
+      height: 28px;
     }
     .cell-value {
       display: inline-block;
@@ -249,7 +263,44 @@ const print = {
     .cell-value.pre {
       white-space: pre-wrap;
       text-align: left;
+      word-break: break-word;
+    }
+    /* 日期 + 时间上下两行展示(医嘱时间/执行时间列) */
+    .datetime {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      line-height: 1.2;
+      font-size: 11px;
+    }
+    .datetime .d-line {
+      font-weight: 500;
     }
+    .datetime .t-line {
+      color: #444;
+    }
+  }
+}
+</style>
+
+<!--
+  @page 必须 un-scoped(CSS 规范限制),单独放一个 style 块。
+  A4 横版 297×210mm,纸张方向 + 边距由浏览器根据此规则应用到打印渲染。
+  - thead { display: table-header-group }:浏览器自动在每页重复表头
+  - tr { page-break-inside: avoid }:单条医嘱行不被切断
+  - 表格整体允许跨页(默认行为)
+-->
+<style lang="scss">
+@media print {
+  @page {
+    size: A4 landscape;
+    margin: 8mm;
+  }
+  .short-order-print .order-table thead {
+    display: table-header-group;
+  }
+  .short-order-print .order-table tr {
+    page-break-inside: avoid;
   }
 }
 </style>

+ 1 - 7
src/views/elderly/elder/doctor-orders/short-doctor-order/columns.ts

@@ -17,7 +17,7 @@ export const ShortDoctorOrderColumns = reactive([
   {
     label: '床位号',
     field: 'bedName',
-    width: 120
+    width: 180
   },
   {
     label: '护理等级',
@@ -35,12 +35,6 @@ export const ShortDoctorOrderColumns = reactive([
     format: true,
     width: 170
   },
-  {
-    label: '最新执行时间',
-    field: 'latestExecuteTime',
-    format: true,
-    width: 170
-  },
   {
     label: '创建时间',
     field: 'createTime',

+ 149 - 3
src/views/system/official-seal/index.vue

@@ -1,5 +1,151 @@
-<script setup lang="ts"></script>
+<template>
+  <ContentWrap title="机构公章管理">
+    <div v-loading="loading" class="seal-container">
+      <el-alert type="info" :closable="false" show-icon class="mb-20px">
+        当前机构:<strong>{{ tenantName || '—' }}</strong>,公章仅对本机构生效。
+      </el-alert>
 
-<template>a</template>
+      <div class="seal-box">
+        <UploadImg
+          v-model="sealUrl"
+          v-model:fileName="fileName"
+          :width="'220px'"
+          :height="'220px'"
+          :show-delete="false"
+        />
+      </div>
 
-<style scoped lang="scss"></style>
+      <div class="seal-tip">支持 jpg / png / gif,大小不超过 5MB。点击图片即可上传或替换。</div>
+
+      <div class="seal-actions">
+        <el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
+        <el-button type="danger" plain @click="handleDelete">删除公章</el-button>
+      </div>
+    </div>
+  </ContentWrap>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue'
+import { ElMessageBox } from 'element-plus'
+import { UploadImg } from '@/components/UploadFile'
+import { getTenantId } from '@/utils/auth'
+import { useUserStore } from '@/store/modules/user'
+import { useMessage } from '@/hooks/web/useMessage'
+import { getTenant, updateTenant, type TenantVO } from '@/api/system/tenant'
+
+const message = useMessage()
+const userStore = useUserStore()
+const tenantId = getTenantId()
+const tenantName = computed(() => userStore.tenant?.name || '')
+
+const loading = ref(false)
+const saving = ref(false)
+const sealUrl = ref('')
+const fileName = ref('')
+
+// 当前租户全量信息(保存 / 删除时要带回全部字段,避免被后端清空)
+const currentTenant = ref<TenantVO | null>(null)
+
+// 演示用占位公章:用于租户尚未上传时的占位预览
+const DEMO_SEAL =
+  'data:image/svg+xml;utf8,' +
+  encodeURIComponent(
+    '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><circle cx="100" cy="100" r="92" fill="none" stroke="#c0392b" stroke-width="6"/><circle cx="100" cy="100" r="76" fill="none" stroke="#c0392b" stroke-width="2"/><text x="100" y="108" font-size="22" fill="#c0392b" text-anchor="middle" font-family="serif">公章示例</text></svg>'
+  )
+
+/** 从租户接口读取当前机构公章(字段 expenseCachetUrl) */
+const loadSeal = async () => {
+  loading.value = true
+  try {
+    const data: any = await getTenant(tenantId as number)
+    currentTenant.value = data
+    sealUrl.value = data?.expenseCachetUrl || ''
+    fileName.value = ''
+  } catch {
+    sealUrl.value = DEMO_SEAL
+    message.notifyWarning('租户接口读取失败,当前显示演示数据')
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleSave = async () => {
+  if (!sealUrl.value || sealUrl.value === DEMO_SEAL) {
+    message.error('请先上传公章图片')
+    return
+  }
+  if (!currentTenant.value) {
+    message.error('租户信息未加载,请刷新页面后重试')
+    return
+  }
+  saving.value = true
+  try {
+    // 全量带回其他字段,只覆盖 expenseCachetUrl
+    await updateTenant({
+      ...currentTenant.value,
+      expenseCachetUrl: sealUrl.value
+    })
+    message.success('公章已保存')
+  } catch {
+    message.error('保存失败,请稍后重试')
+  } finally {
+    saving.value = false
+  }
+}
+
+const handleDelete = async () => {
+  if (!sealUrl.value || sealUrl.value === DEMO_SEAL) {
+    message.warning('当前没有可删除的公章')
+    return
+  }
+  if (!currentTenant.value) {
+    message.error('租户信息未加载,请刷新页面后重试')
+    return
+  }
+  try {
+    await ElMessageBox.confirm('确认删除当前机构公章?删除后需重新上传。', '提示', {
+      type: 'warning'
+    })
+  } catch {
+    return
+  }
+  try {
+    // 后端无 delete 接口:"删除"= update 时把字段清空
+    await updateTenant({
+      ...currentTenant.value,
+      expenseCachetUrl: ''
+    })
+    sealUrl.value = ''
+    fileName.value = ''
+    message.success('公章已删除')
+  } catch {
+    message.error('删除失败,请稍后重试')
+  }
+}
+
+onMounted(loadSeal)
+</script>
+
+<style scoped lang="scss">
+.seal-container {
+  max-width: 480px;
+  padding: 10px 0;
+}
+.seal-box {
+  display: flex;
+  justify-content: center;
+  margin: 16px 0;
+}
+.seal-tip {
+  text-align: center;
+  font-size: 13px;
+  color: var(--el-text-color-secondary);
+  margin-bottom: 18px;
+}
+.seal-actions {
+  display: flex;
+  justify-content: center;
+  gap: 12px;
+}
+</style>