Browse Source

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

xiongxing 1 month ago
parent
commit
297220f243
28 changed files with 4986 additions and 35 deletions
  1. 28 0
      .workbuddy/memory/2026-08-20.md
  2. 152 0
      .workbuddy/memory/2026-08-25.md
  3. 354 0
      .workbuddy/memory/2026-08-26.md
  4. 20 0
      .workbuddy/memory/2026-08-27.md
  5. 118 0
      .workbuddy/memory/2026-08-28.md
  6. 45 0
      .workbuddy/memory/MEMORY.md
  7. 482 0
      src/api/elderly/elder/bed-change/index.ts
  8. 2 0
      src/api/system/tenant/index.ts
  9. 9 1
      src/components/BedCardModal/index.vue
  10. 1 1
      src/components/TenantSelect/src/TenantSelect.vue
  11. 1 1
      src/config/axios/service.ts
  12. 120 25
      src/views/elderly/apply/check-in/ProcessForm.vue
  13. 5 5
      src/views/elderly/contracts/components/CheckInRegistrationBody.vue
  14. 147 0
      src/views/elderly/elder/doctor-orders/execute-doctor-order/ExecuteDialog.vue
  15. 1000 0
      src/views/elderly/elder/doctor-orders/execute-doctor-order/index.vue
  16. 473 0
      src/views/elderly/elder/doctor-orders/long-doctor-order/Form.vue
  17. 346 0
      src/views/elderly/elder/doctor-orders/long-doctor-order/Print.vue
  18. 45 0
      src/views/elderly/elder/doctor-orders/long-doctor-order/columns.ts
  19. 338 0
      src/views/elderly/elder/doctor-orders/long-doctor-order/index.vue
  20. 421 0
      src/views/elderly/elder/doctor-orders/short-doctor-order/Form.vue
  21. 311 0
      src/views/elderly/elder/doctor-orders/short-doctor-order/Print.vue
  22. 44 0
      src/views/elderly/elder/doctor-orders/short-doctor-order/columns.ts
  23. 235 0
      src/views/elderly/elder/doctor-orders/short-doctor-order/index.vue
  24. 18 0
      src/views/elderly/elder/elder-file/Detail.vue
  25. 1 0
      src/views/elderly/elder/health-record/Form.vue
  26. 59 2
      src/views/elderly/fee/bill-pay/preview.vue
  27. 60 0
      src/views/elderly/fee/bill-pay/unpay-preview.vue
  28. 151 0
      src/views/system/official-seal/index.vue

+ 28 - 0
.workbuddy/memory/2026-08-20.md

@@ -0,0 +1,28 @@
+# 2026-08-20 工作记录
+
+## Detail.vue 床头卡跳转后关闭失效(keep-alive + el-dialog 动画打断)
+
+### 现象
+- 用户点床头卡 → 弹窗内"跳转到编辑页" → router.push 跳走 → 用户返回 → 再点床头卡能开 → 但点"关闭预览"关不掉,console 里 `handleDialogClose` 的 `console.log('床头卡弹窗已关闭')` 照常打出。
+
+### 根因
+- 路由 `elderly/elder-file/detail/:id` 的 `meta.noCache = false`(`src/router/modules/remaining.ts:135`),Detail 页被 `<keep-alive>` 缓存。
+- `BedCardModal` 的 `handleCloseE` 同步执行 `visible.value = false`(触发 el-dialog 关闭动画,约 300ms)后立即 `emit('edit')`,父级 `handleEdit` 立刻 `router.push(...)`,路由切换(deactivate)**打断**了 el-dialog 正在进行的关闭动画。
+- el-dialog 内部动画状态卡死:v-model 已经是 false,但 dialog 实例不再响应后续 `modelValue -> false` 的关闭动作(但 `emit('close')` 是同步触发,所以 log 照打)。这就是「log 出来但弹窗没关」的来源。
+
+### 修复
+- 在 `src/views/elderly/elder/elder-file/Detail.vue` 的 `handleEdit` 中:先 `dialogVisible.value = false` 显式触发关闭,再 `setTimeout(..., 350)` 等关闭动画走完,再 `router.push`。
+- 注意:不能依赖 `handleCloseE` 自己去关,因为它和 `router.push` 几乎同帧,动画照样被打断。必须由父级在跳转前给动画留出 300+ ms。
+
+### 验证
+- ESLint:0 errors(仅 1 个历史 warning)
+
+### 后续可考虑(暂未做)
+- `BedCardModal` 的 `visible = ref(props.modelValue)` + 两个 watch 的模式本身就是脆弱的双向同步,容易在不同生命周期/keep-alive 场景下出现 desync。根治方案是改成 computed:
+  ```ts
+  const visible = computed({ get: () => props.modelValue, set: v => emit('update:modelValue', v) })
+  ```
+  这是个共享组件,改造影响面较大,等真出现其他 desync 再统一改。
+
+### 文件
+- 修改:`src/views/elderly/elder/elder-file/Detail.vue`(`handleEdit`)

+ 152 - 0
.workbuddy/memory/2026-08-25.md

@@ -0,0 +1,152 @@
+# 2026-08-25 工作记录
+
+## 长期医嘱单页面(long-doctor-order)
+
+按截图新建 `src/views/elderly/elder/doctor-orders/long-doctor-order/` 三件套:
+- `columns.ts`:长期医嘱列表格列定义,复用 `reactive` + `type/dictArr/format/width` 约定,与 `src/views/elderly/elder/column.ts` 风格一致。
+- `Form.vue`:Dialog 弹窗(60% 宽),基于 `selectElder` 自动联动填充姓名/性别/床号/护理等级(注意 Vue3 不推荐 `.sync`,要用 `v-model:propName`)。表单含医嘱内容、开始时间、医生签名、护士签名、停止时间、停止医生签名、停止护士签名、备注。`open(row, detail)` 回显详情。
+- `index.vue`:参考 `bed-change/index.vue` 的标准结构(ContentWrap 筛选 + ContentWrap 列表(TabBarBtn + Table2 + Pagination)+ Form 弹窗引用),监听 `@edit`/`@del` 事件做编辑/删除。删除用 `message.confirm` 二次确认。
+
+接口:`src/api/elderly/elder/bed-change/index.ts` 追加 5 个 CRUD + VO/Form 类型:
+- `getLongDoctorOrderPage / getLongDoctorOrder / createLongDoctorOrder / updateLongDoctorOrder / deleteLongDoctorOrder`
+- 路径:`elderly/longDoctorOrder/{page|get|create|update|delete}`,复用 `request.get/post/put/delete` 模式。
+
+注意:列定义里 `gender` 用 `type:'1'` + `DICT_TYPE.SYSTEM_USER_SEX` 字典。**未引入 `status` 字典**(项目里估计没有 LONG_ORDER_STATUS),等后端落地后再加一列。
+
+---
+
+## 临时医嘱单页面(short-doctor-order)
+
+按截图实现 `src/views/elderly/elder/doctor-orders/short-doctor-order/`,四件套:
+- `columns.ts`:列表列(长者姓名/性别/床位号/护理等级/医嘱条数/最新医嘱时间/最新执行时间/创建时间),无状态列(临时医嘱一次性,无未开始/在用/已停止语义)。
+- `Form.vue`:80% 宽 Dialog,上方长者 picker(`selectElder` 联动),下方 el-table 子表展示**多条医嘱条目**(每条 6 列:医嘱时间/医嘱内容/医生签名/执行时间/护士签名/备注),支持新增/删除条目,至少保留 1 条。`entries` 是数组,对应后端一对多结构。
+- `Print.vue`:完全按截图渲染。**关键样式**:日/月 列表头用 `::before` 画斜线(`linear-gradient` 一条对角线),签名按「有数据显数据、无数据显横线」规则(`.sign-box.empty` 灰底文字),打印最少 6 行(不足补空行)。
+- `index.vue`:与 long-doctor-order 同款结构(ContentWrap 筛选 + ContentWrap 列表 + Pagination + Form/Print 引用),操作列只保留 编辑/删除/打印(**没有停止/启用**,因为临时医嘱是一次性)。列表请求失败时填充 2 条测试假数据。
+
+接口:`bed-change/index.ts` 追加 5 个 + 3 个类型:
+- `getShortDoctorOrderPage / getShortDoctorOrder / createShortDoctorOrder / updateShortDoctorOrder / deleteShortDoctorOrder`
+- 路径:`elderly/shortDoctorOrder/{page|get|create|update|delete}`
+- 类型:`ShortDoctorOrderVO`(含 `entries: ShortDoctorOrderEntry[]`)/ `ShortDoctorOrderForm` / `ShortDoctorOrderEntry`(每条单据子行字段)。
+
+## 通用发现
+- yudao 风格打印:`v-print` 指令全局可用(`vue3-print-nb`),只需配置 `id/popTitle/preview/zIndex`。打印区域 ID 不要和其他医嘱单(`longOrderPrintArea`)冲突。
+- el-table 子表 + el-date-picker 单元格内嵌:`el-date-picker` 加 `style="width: 100%"` 才不溢出。`el-form-item` 包外层 + 子列用 `template #default="{ row }"` 解构。
+- 临时医嘱多 entry 提交 payload 用 `{elderId, entries: [...]}`,后端一对多时直接整个 list 传过去即可。
+
+校验:ESLint 0 错误,vue-tsc 过滤 long-doctor-order/bed-change/index.ts 无报错。
+
+## 项目里标准的列表页结构(备查)
+
+`ContentWrap`(筛选 + 列表 + 分页) + `TabBarBtn @add` + `Table2`(自动渲染操作列:编辑/详情/撤销/删除,事件:edit/detail/del/cancel)+ `Pagination`。
+列定义写在独立 `columns.ts` 用 `reactive([{label,field,type,format,...}])`。
+弹窗:`<Dialog v-model="dialogVisible" :title>` + `defineExpose({ open })`。
+全局组件(无需 import):`ContentWrap / Dialog / TabBarBtn / Table2 / Pagination / Icon / TenantSelect`。
+
+## 长期医嘱单功能增强(同文件,追加)
+
+按需求增量修改 long-doctor-order 三件套 + 接口:
+- **新增「未开始」状态**:状态语义 0=未开始 / 1=在用 / 2=已停止。筛选新增「未开始」选项;`getList` 预计算 `statusText`;`columns.ts` 加「状态」列(渲染 `statusText`,因无后端字典故不走 dict-tag)。
+- **操作列新增 停止/启用 切换 + 打印 + 打开**:Table2 内置 编辑/删除/详情 用 `:show-edit-condition / :show-delete-condition / :show-detail-condition="()=>false"` 隐藏(避免权限串 `elder:*` 导致的重复/不可控),改在 `#next` 插槽自绘 编辑/删除/停止启用/打开/打印 按钮(用正确的 `v-hasPermi="['long-doctor-order:*']"`)。`op-width` 调到 340。
+  - `handleToggleStatus`:status==2(已停止)→显示「启用」置 1;否则→「停止」置 2;`message.confirm` 二次确认后调 `updateLongDoctorOrderStatus(id, status)`。
+  - `openDetail`→`Form.open(row,true)` 只读详情;`openPrint`→`Print.open(row)`。
+- **新建 `Print.vue`**:Dialog 内 `#longOrderPrintArea` 渲染医嘱单详情(基本信息表 + 医嘱内容 + 签名区)。**签名规则**:有值显值、无值显横线(`.sign-box.empty` 仅 `border-bottom` + 浅色「(签名)」占位)供纸质签名。底部 `v-print="print"`(vue3-print-nb,已在 main.ts `app.use(Print)` 全局注册)触发打印。
+- **Form.vue 详情增强**:detail 模式展示「医嘱状态」(只读);footer 加「打印」按钮→`Print.open({id})`。
+- 接口追加:`updateLongDoctorOrderStatus(id,status)`(PUT `elderly/longDoctorOrder/updateStatus`)+ `LONG_ORDER_STATUS` / `LONG_ORDER_STATUS_OPTIONS` / `getLongDoctorOrderStatusText()` 常量。
+
+校验:ESLint 0 错误;vue-tsc 过滤 long-doctor-order/bed-change/index.ts 无报错。
+
+⚠️ 待后端落地确认项:①`updateStatus` 接口 URL/参数名(`?id=&status=`);②新医嘱默认 status(建议后端置 0 未开始);③菜单 path + 权限码 `long-doctor-order:edit/delete/print`;④打印走 `v-print` 会克隆 `#longOrderPrintArea` 到 iframe 打印,遮罩层不会入纸。
+
+---
+
+## 打印页时间格式化 + 签名去横线(长期/临时医嘱统一)
+
+用户要求两个医嘱单打印页:**时间用标准格式化(没有就留空白)+ 表格里签名不需要横线**。
+
+### 长期医嘱 `long-doctor-order/Print.vue`
+- 表头 11 列 → 9 列(序号/医嘱时间/医嘱内容/医生签名/护士签名/停止时间/停止医生签名/停止护士签名/备注)。
+- 去掉「医嘱时间/停止时间」的 colspan 2 + 对角线「日/月·时」表头,改单列直接显示完整时间。
+- 新增 `formatFullTime(val)`:`YYYY-MM-DD HH:mm:ss` → 截到 `YYYY-MM-DD HH:mm`;空/`null`/`undefined`/`''` → 空串;数字时间戳按本地时区解析;其他兜底原样返回。替换原 `formatDateOnly`/`formatTimeOnly`。
+- 签名单元格去掉 `.sign-box`(含 `border-bottom` 横线 + `.empty` 占位灰),直接 `{{ item.xxxSign || '' }}`。
+- 删除 `.diag-cell` 斜线 CSS(`::before` linear-gradient)。
+
+### 临时医嘱 `short-doctor-order/Print.vue`
+- 表头 8 列 → 6 列(医嘱时间/医嘱/医生签名/执行时间/护士签名/备注)。
+- 同样 `formatFullTime` 替换 `formatDateOnly`/`formatTimeOnly`,`orderTime` 与 `executeTime` 两个时间字段都用它。
+- 签名去 `.sign-box` 横线,删除 `.diag-cell` 斜线 CSS。
+
+### 结论
+两张打印单视觉风格现已一致:时间列单列标准 `YYYY-MM-DD HH:mm`,无值留空;签名只显文本不加横线。这是用户明确偏好(「有表格了就不需要横线」),后续新打印页默认沿用此风格。
+
+---
+
+## 长期医嘱 Form.vue 子表新增「启用/停止医嘱」按钮
+
+用户要求新增弹窗里子表操作列也显示「启用医嘱/停止医嘱」(此前只有已保存行 `row.id` 存在才显示)。
+
+改动:
+- 操作列 180px → 220px。
+- 启用/停止按钮显示条件 `v-if="row.id && !isDetail"` → `v-if="!isDetail"`(新增行也可见)。
+- 文案/颜色按 `status`:`IN_USE(1)`→「停止医嘱」warning;`NOT_STARTED(0)`/`STOPPED(2)`→「启用医嘱」success。
+- `emptyEntry()` 默认 `status: LONG_ORDER_STATUS.NOT_STARTED`(未开始),`statusText:'未开始'`。
+- `togglingId` 判定加 `row.id !== undefined` 防止新增行 id=undefined 误判。
+- 未保存行(无 id)点击仍走 `handleToggleEntryStatus` 原提示分支「新添加的条目请先保存后再启用/停止」。
+
+⚠️ 临时医嘱 `short-doctor-order/Form.vue` **未加**启停按钮(临时医嘱一次性,无此语义)。如要加「执行/作废」类按钮再单独处理。
+
+---
+
+## 医嘱给药三要素(频次/时间/途径)下拉选择项
+
+用户要求长期+临时医嘱,每条医嘱新增三个结构化选择项:给药频次、给药时间、给药途径,下拉项最右侧带中文临床场景提示。
+
+### API `bed-change/index.ts`
+- 新增 `MedOption` 接口(`value/label/cn/tip`)。
+- 新增三套选项常量(长期/临时共用):
+  - `MED_FREQUENCY_OPTIONS`:qd/bid/tid/qid/qn/qod/qw/biw/tiw/q2w/q3w/q4w/q8h/q6h/q4h(15 项)。
+  - `MED_TIME_OPTIONS`:ac/pc/hs/st/prn/sos/am/pm(8 项)。
+  - `MED_ROUTE_OPTIONS`:po/iv/ivgtt/im/sc/ih/igtt/inh/ext(9 项,ivgtt 含 iv drip 别名释义)。
+  - `label` = 「缩写 + 中文含义」(如下拉主显),`cn` = 中文含义,`tip` = 临床场景(hover 提示)。
+- `LongDoctorOrderEntry` / `ShortDoctorOrderEntry` 各加 `frequency?`、`medTime?`、`route?`(均存缩写)。
+
+### 两个 `Form.vue`(长期/临时)
+- `emptyEntry()` 补 `frequency/medTime/route: ''`。
+- 子表在「医嘱内容」列后插入三列 `el-select`(clearable + filterable),`v-model` 绑 `row.frequency/medTime/route`,`disabled=isDetail`。
+- `el-option` 用 `:value`(缩写) + `:label`(缩写+中文) + `:title="o.tip"`(原生 hover tooltip 显示临床场景)。选框提交时 payload 自动带三字段。
+
+### 两个 `Print.vue`(长期/临时)
+- 导入三套常量 + 新增 `medLabel(options, val)` helper(缩写→`o.label`,空值返回空串)。
+- 表头/表格在「医嘱内容」后加「频次/给药时间/途径」三列,单元格 `medLabel(...)` 显示中文文案;`paddedEntries` 补空字段。
+- 长期打印 9→12 列;临时打印 6→9 列。
+
+### 结论
+两种医嘱编辑时均可结构化选择给药三要素,打印页同步展示中文;选项字典集中在 API 一处维护。校验:ESLint 0 错误(5 文件)。
+
+⚠️ 待后端确认:①entry 新增三字段的库表列名/序列化(建议 frequency/med_time/route);②前端存的缩写需与后端字典一致;③如后端有独立给药字典接口,需替换本地常量。
+
+---
+
+## 护士执行医嘱页面(execute-doctor-order)—— 新建
+
+用户要开发护士"执行医嘱"功能,数据来源是前两个医嘱。已确认方案:**前端自动展开 + 按频次拆分多次**(护士只做"确认执行+签名",不手动新增执行记录)。
+
+### 设计决策
+- 数据流转:**不新增独立录入**,页面读长期+临时医嘱接口,在前端按频次展开成"当日执行行",护士点「执行」→写一条执行记录。
+- 长期医嘱只取 `status=1(在用)` 的 entry;临时医嘱取 `orderTime` 当日者,以医嘱时间为 planTime(一次性)。
+- 执行记录需持久化(跨刷新可追溯):`getDoctorOrderExecutionPage(planDate)` 拉已执行,按 `${orderType}-${orderEntryId}-${planTime}` 合并标记行 status=已执行。
+- 行操作:待执行→「执行」(弹窗填实际时间+签名) / 「跳过」(写 status=2);已执行/已跳过→「查看」。
+
+### API `bed-change/index.ts` 追加
+- `DoctorOrderExecution` 接口(orderType/orderEntryId/elderId/elderName/bedName/orderContent/frequency/medTime/route/planTime/execTime/execNurseSign/status/remark/tenantId)。
+- `EXEC_STATUS`(0待执行/1已执行/2已跳过) + `EXEC_STATUS_OPTIONS`。
+- `getDoctorOrderExecutionPage(query)` GET `elderly/doctorOrderExecution/page`;`executeDoctorOrder(data)` POST `elderly/doctorOrderExecution/execute`;`skipDoctorOrderExecution(id)` PUT `elderly/doctorOrderExecution/skip`。
+- `getMedCn(options, val)` 选项值→中文。
+- `buildExecutionPlanTimes(frequency, medTime)`:频次→HH:mm 时点数组(日频次多时点;周/间隔频次给 1 个 09:00 代表点;medTime 对单时点微调 hs→21:00 等)。**前端启发式,真实排班以后端执行为准**。
+
+### 文件
+- `execute-doctor-order/index.vue`:ContentWrap 筛选(机构/长者/执行日期默认今天/类型/状态)+ el-table 列表(合并展开)+ 客户端分页 Pagination + 打印执行单(`v-print="'#execPrintArea'"`,离屏克隆打印)+ ExecuteDialog 引用。列表失败各自 catch 用 `buildMockLong/buildMockShort` 兜底(含 bids/tid 多时点,验证展开)。
+- `execute-doctor-order/ExecuteDialog.vue`:摘要描述 + 表单(实际执行时间默认 now、执行护士默认 `userStore.nickname`、备注),保存调 `executeDoctorOrder`。`defineExpose({open(row, view)})`。
+
+校验:ESLint 0 错误(3 文件)。
+
+⚠️ 待后端确认:①执行记录表 `doctor_order_execution` 列名/序列化;②`execute` 接口入参(是否需传 elderId/orderContent 冗余或仅 orderEntryId+planTime+status+execTime+sign);③`page` 查询参数 `planDate` 命名;④权限码 `doctor-order-execute:execute`;⑤频次展开逻辑若后端已有执行计划生成,前端应改为直接读执行表而非自行展开。

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

@@ -0,0 +1,354 @@
+# 2026-08-26 工作日志
+
+## 护士执行医嘱 - 跳过加原因弹窗
+- 文件:`src/views/elderly/elder/doctor-orders/execute-doctor-order/index.vue`
+- 改动:原「跳过」用 `message.confirm` 简单确认,改为独立 `el-dialog` 弹窗(标题"跳过执行")
+  - 弹窗含:长者·床位 alert 提示(含医嘱内容)+ 计划时间 + 「跳过原因」必填 textarea(maxlength 200 / show-word-limit)
+  - `confirmSkip`:原因 trim 后非空才放行,调 `executeDoctorOrder(payload)` 传 `status: 2` + `remark`
+  - 原因留痕写入 `DoctorOrderExecution.remark`,已跳过记录可在「查看」弹窗(ExecuteDialog)看到
+- `ExecuteDialog.vue` 未改(其 `:disabled="isView"` 下备注 textarea 已能展示已跳过记录的 remark)
+- 注意:`handleSkip` 不再用 `message.confirm`;`executeDoctorOrder` 同时承担"执行(status=1)"与"跳过(status=2)"两种写入,靠 payload.status 区分
+- ESLint 0 错误
+
+## 修复顶部"打印执行单"无效 + 操作列加单条打印
+- 文件:`src/views/elderly/elder/doctor-orders/execute-doctor-order/index.vue`
+- **根因**:v-print 写法错——之前传字符串选择器 `v-print="'#execPrintArea'"`,但 vue3-print-nb 期望传 `{id, popTitle, ...}` 配置对象(项目内 30+ 处用法都是 `v-print="print"`)。这就是顶部"打印执行单"点了没反应的原因
+- 改动:
+  1. 顶部 `v-print="printAll"`(修无效),`printAll = {id: 'execPrintArea', popTitle: '医嘱执行单', preview: false, zIndex: 20002}`
+  2. 操作列加「打印」按钮(始终可见,`v-hasPermi="['doctor-order-execute:print']"`),width 170→220
+  3. 加单条打印区 `#singlePrintArea`(grid 2 列展示该行长者/床位/类型/状态/频次/给药时间/途径/计划时间/实际时间/执行护士/医嘱内容 + 跳过原因或备注 + 底部执行护士/核对护士签名横线)
+  4. 加隐藏 anchor `<a ref="singlePrintRef" v-print="printOne">`,`handlePrintRow` 通过 `nextTick` 后程序 `.click()` 触发
+  5. import 加 `nextTick`
+- 体验:两个打印并存——顶部"打印执行单"打当日汇总(护理台归档用),操作列"打印"打单条(护士取一张给执行人签名用)
+- ESLint 0 错误
+
+## 修正:两个打印都空白预览(根因=离屏 div)
+- 现象:改对象写法后弹出打印对话框但内容是空白页
+- **根因**:打印区用了 `position:absolute; left:-9999px` 离屏 div。vue3-print-nb 把该 div 克隆进 iframe 后,负坐标把内容推到视口外 → 浏览器打印空白。项目内 30+ 个 `Print.vue` 的打印区都在**可见的 `<Dialog>` 内**,对话框打开时打印,无离屏写法
+- 修复(`execute-doctor-order/index.vue`):
+  1. 删除离屏 `#execPrintArea`/`#singlePrintArea` div + 隐藏 anchor `singlePrintRef`/`handlePrintRow`/`nextTick`
+  2. 改为一个可见 `<el-dialog v-model="printDialogVisible">` 包裹打印区:`v-if="printMode==='all'"` 显示 `#execPrintArea`(汇总),`v-else` 显示 `#singlePrintArea`(单条,渲染 `singlePrintRow`)
+  3. 打印区加 `style="padding:12px 16px;color:#000;background:#fff"`(scoped 样式 clone 到 iframe 会丢,故用 inline)
+  4. 顶部按钮 `v-print="printAll"` → `@click="openPrintDialog('all')"`;操作列打印 `@click="openPrintDialog('single', row)"`
+  5. `openPrintDialog(mode,row)` 设 `printMode/singlePrintRow/printDialogVisible`;footer 用**两个互斥**按钮各自 `v-print="printAll"/printOne`(绑固定对象,避免 computed 动态对象重复绑 click)
+- 经验:**v-print 打印区绝对不能离屏**,必须放在可见 Dialog 内(参考 long-doctor-order/Print.vue 范式)
+- ESLint 0 错误
+
+## 产出后端设计规格文档(交付后端)
+- 文件:`doctor-order-backend-spec.md`(项目根目录)
+- 背景:用户要向后端说明医嘱执行模块怎么设计。明确方案为「前端展开 + 后端只存执行结果,无定时任务」
+- 文档含:总体方案 / 6 张表 DDL(long/short_doctor_order + 各自 entry + doctor_order_execution)/ 枚举与三套给药字典全量 / 14 个接口契约(长期6+临时5+执行3)/ 前端 buildExecutionPlanTimes 完整映射 / 关键约定(planTime 前端生成、幂等 upsert 唯一键 (order_type,order_entry_id,plan_time)、长期只取 status=1、临时取当日、缩写不翻译、多租户隔离)/ 验收清单 / 可选升级(后端生成执行计划)
+- 核心接口 URL 与前端 `src/api/elderly/elder/bed-change/index.ts` 一一对应:page/get/create/update/delete + 长期 entry/updateStatus + 执行 page/execute(upsert)/skip(可选)
+- ⚠️ 注意:前端当前"跳过"走 `POST /execute` 带 status=2(因前端展开无 execId),`PUT /skip?id=` 暂未使用,文档标注为可选
+
+## 09:46 确认文档幂等内容 + 清理项目根
+- 用户问"upsert 幂等是否在 md 文件里"——实际已在文件 4 处体现:DDL `uk_exec` 唯一索引(140)、接口契约幂等说明(412)、关键约定第7条(449)、验收清单(460)
+- 应"只发文件没口头说"的顾虑,在文档末尾新增「第十节 后端重点须知」,集中强调:执行接口必须 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/页面更稳
+
+### 入住办理 fixed 项目删除后合计变空(2026-08-26 16:36)
+- 用户截图:月固定收费区点击任意 X 删除某项 → 「月固定金额合计 ¥元」显示空
+- 文件:`src/views/elderly/apply/check-in/ProcessForm.vue`
+- 根因(两层):
+  1. **直接原因**:`totalMonthFee` computed 直接 `total += Number(item.totalAmount)`,无 NaN 防护。任一项 totalAmount 是 undefined/NaN → `Number(undefined) = NaN` → `total` 立即被污染 → `formatNum(NaN)` 返回 '' → 合计显示空
+  2. **诱导原因**:`init()` 里 `arr[0] = { ...arr[0], ...item }` 用后端数据覆盖 arr 元素,spread 顺序导致 `item.totalAmount = undefined` 时把默认值 0 覆盖成 undefined
+  3. **触发时机**:用户 X 删除某项 → Vue 3 reactive array splice → computed 重新求值 → 累加时撞上某项 undefined 一击毙命
+- 修复(两层):
+  - `totalMonthFee` 加 `safeAdd(v)` helper:`Number.isFinite(Number(v)) ? Number(v) : 0`,跳过 NaN/undefined/字符串
+  - `init()` 4 处 arr 覆盖加 `totalAmount: Number.isFinite(Number(item.totalAmount)) ? Number(item.totalAmount) : 0` 强制归一
+- ⚠️ 隐患未动:`submitForm()` 里有 `arr.splice(3, 1)`(line 985-988)物理删除服务费项,破坏 UI 数据源完整性,只在用户没填服务费时触发。建议后续改成"标记跳过"而非数组删除
+- 范式:**computed 累加一定要 NaN 防护**。`formatNum` 对 NaN/不合法值统一返回 '',导致一点点 NaN 就让整列合计为空,肉眼上跟"删除某项后合计消失"非常像
+
+### 对账单预览加机构公章(2026-08-26 16:08)
+- 用户:在 `src/views/elderly/fee/bill-pay/preview.vue` 的对账单预览**右下角**(表格尾部,红色框区域)加公章图
+- 改动:
+  1. import:`getTenant` from `@/api/system/tenant`、`getTenantId` from `@/utils/auth`
+  2. 加 `sealUrl = ref('')`
+  3. `open()` 改用 `Promise.all([getExpenseOrderDetail(id), getTenant(tenantId).catch(() => null)])` 并行拉取;公章取 `tenant.expenseCachetUrl`,接口失败容错
+  4. `handleClosed()` 关闭时清空 `sealUrl`,避免下次打开看到旧机构公章
+  5. 模板:两个 tab 的表格结束后都加 `<div class="seal-area">`,内含 `<img :src="sealUrl" v-if="sealUrl">` + `<span class="seal-label">(机构签章)</span>`
+  6. 样式:`.seal-area` 右对齐 + `min-height: 30mm` 占位;`.seal-img { width:28mm; height:28mm; object-fit:contain }` 打印自适应
+- 公章来源 = 已对接的 `tenant.expenseCachetUrl`(不重复加 API),新机构未上传则不渲染
+- v-print iframe 同步:v3-print-nb 把 scoped CSS 克隆进 iframe,公章图会随打印输出
+
+### 对账单公章改为叠在表格内部 + 旋转 -45°(2026-08-26 16:12)
+- 用户第二轮反馈:公章位置改到**红框处**(表格内"备注"列右下角,跨第3行+支付方式行),**旋转 -45°**,去掉"机构签章"4字
+- 改动:
+  1. 模板:`<div class="seal-area">` 改为 `<div v-if="sealUrl" class="seal-overlay">`,去掉 `.seal-label` 文字
+  2. `.first, .second { position: relative }` 提供绝对定位上下文
+  3. `.seal-overlay` 改绝对定位:`right:0; bottom:0; width:36mm; height:36mm; z-index:2; pointer-events:none`
+  4. `.seal-img { transform: rotate(-45deg); transform-origin: 100% 100%; opacity:0.85 }` 旋转中心在右下角 → 旋转后向左上"倾倒",自然落在表格右下区域
+- 关键技巧:`transform-origin: 100% 100%` 让旋转中心在公章图右下角,旋转后公章左上角会向左上方延伸出去,正好覆盖表格右下备注区域;如果用 `center` 旋转则公章整体会跑出容器
+- `pointer-events: none` 保证不挡表格交互(hover / 复制备注)
+
+### 缴费通知单预览加"家属信息"按钮 + 弹窗(2026-08-26 16:25)
+- 用户:在 `src/views/elderly/fee/bill-pay/unpay-preview.vue` 缴费通知单预览**右上角**加"家属信息"按钮,点击弹家属列表;**按钮不能被打印出来**
+- 后端响应新增 `relatives[]` 字段(每项含 peopleName/relation/phone/idCard/insurance/isBillSender 等)
+- 改动:
+  1. Script:加 `relatives = ref<any[]>([])` + `relativeDialogVisible = ref(false)`;`open()` 里 `relatives.value = Array.isArray(res.relatives) ? res.relatives : []`;`handleClosed()` 清空
+  2. 模板:`.header` 标题下加 `<el-button v-if="relatives.length" class="no-print relative-action">家属信息({n})</el-button>`,按钮带家属数量徽标
+  3. 模板:`<template #footer>` 后追加独立 Dialog(家属信息表格:序号/姓名/关系/联系电话/身份证号/险种/账单发送人 el-tag)
+  4. 样式:`.header { position: relative }` + `.relative-action { position: absolute; right:0; top:4px }`
+  5. **打印隐藏关键**:`printExtraCss` 注入两条规则
+     - `.no-print { display: none !important; }`(v-print iframe 内的全局样式)
+     - `@media print { .no-print { display: none !important; } }`(屏幕本身按 print 媒体查询时也隐藏)
+- 范式:**打印排除元素**首选 `.no-print` + `printExtraCss` 注入样式(v-print 走 iframe,scoped data-v 属性不被命中,必须走全局规则);不要靠把按钮放容器外这种"侥幸路径"——一旦后续把 Dialog 包进更外层容器,按钮就会被纳入打印范围
+
+### 修复 submitForm 的 arr.splice(3,1) 物理删除(2026-08-26 17:12)
+- 问题:长住提交时若服务费未选,`arr.splice(3, 1)` 把"服务标准"项从 arr 物理删除 → arr 永久变 3 项,`getOverheadByType(7)` 的 `arr[3].list`、`totalMonthFee` computed、编辑回填 `arr[3]` 全部索引错乱,补填也回不来
+- 修复(ProcessForm.vue,语义不变):
+  1. 校验段:删 try/catch + splice,改 `arr.forEach` —— `item.type == 7 && !item.itemId` 跳过校验(服务费可选),其余三项 `!item.itemId` 报错
+  2. 提交段:`push(...arr)` 改 `push(...arr.filter(item => item.itemId))` —— filter 返回新数组,未选的服务费不提交,arr 本体保持 4 项
+- 判断"已选/未选"用 `item.itemId`(el-select v-model 直接绑定,比原 `arr[3].amount` 更可靠)
+- 范式:**固定结构数组(按下标访问的 arr[i])绝不能 splice 删项**,用 filter/标记跳过代替
+- ESLint 0 错误(3 个 v-for shadow warning 为既有问题)
+
+### 复核"费用改动"影响面 + totalAmount 兜底升级(2026-08-26 17:20)
+- 用户要求复核上上次(safeAdd + totalAmount 归一)改动是否导致费用计算错误/提交参数问题
+- 链路核对结论:
+  - `totalMonthFee`(safeAdd)只影响显示合计,不影响提交;有值项 Number() 保真
+  - 提交金额来源:回填时 `dataForm.monthlyExpenses` 会过滤掉 type 1/2/3/7(914-916 行),费用项只经 `arr` 提交 → `init()` 归一出的 totalAmount **就是后端入库金额**
+  - **发现真实隐患**:后端存在不返回 totalAmount 的费用项(用户最初 bug 的证据),原归一 `: 0` 会把真实费用提交成 0 元
+- 修复:init() 4 处归一升级为 `totalAmount 合法 → 用它;否则 actualAmount 合法 → 用它(月费 totalAmount 本就 = actualAmount);都缺才 0`
+- 核对 `handleChangeDiscount` 的 `amount * ((discount/100)*10)`:配合 discount=9(9折)语义 = amount*0.9,公式正确,无需动
+- 既有行为(未改):push(...arr.filter()) 提交的 arr 元素含 label1/label2/errMsg/discountTemp 等 UI 字段(后端忽略未知字段);`item.list=undefined` 引用共享污染 arr.list(splice 时代就有)
+- ESLint 0 错误
+
+### 是否赠送=是时 赠送起止时间必填(2026-08-26 17:26)
+- 需求:`ProcessForm.vue`(入住办理)阶段性收费项,`item.isFreeGift == 1` 时"赠送开始/结束时间"必填
+- 实现(两层):
+  1. 模板:两个 el-form-item 改动态 prop `'stageExpenses.' + index + '.freeStartTime'`(原 prop="freeStartTime" 挂在外层 form model=dataForm 上,指向 dataForm.freeStartTime 不存在,校验永不生效)+ 内联 rules required 随 isFreeGift 变化(trigger change/blur)
+  2. submitForm:stageExpenses.forEach 前手动兜底校验,缺失时 message.error(`请选择第N个收费项目的赠送开始/结束时间`) + return(项目先例 Check.vue/ToLong.vue 同款写法)
+- 注意:`Renewal.vue`(续约)438-482 行有相同结构的赠送字段,本次未改,用户未要求
+- ESLint 0 错误
+
+### "重新发起"赠送时间不回显 排查+防御(2026-08-26 17:33)
+- 现象:重新发起(getProcess→getCheckInRecordId 回填)时 stageExpenses 的赠送开始/结束时间不回显,且能提交成功
+- 链路:`getProcess` → `init({bpmStatus,elderId,id}, true, status)` → `getCheckInRecordId` → `dataForm.value = {...res}` → stageExpenses 原样带入
+- 候选根因(按概率):
+  1. 后端 getCheckInDetail 返回的 freeStartTime/freeEndTime 是**时间戳(Long)**或"YYYY-MM-DD HH:mm:ss",而 TgDatePicker value-format=date("YYYY-MM-DD") → 显示空
+  2. 后端 isFreeGift 返回 null/0/缺字段 → v-if 不显示时间框、必填校验不触发 → 提交成功
+  3. 后端 isFreeGift 返回 "true"/"1" 字符串 → `== 1` 宽松相等,"1"==1 true 但 "true"==1 false
+- 防御修复(init 回填 stageExpenses map 内):`item.isFreeGift = Number(item.isFreeGift) === 1 ? 1 : 0` + `normalizeGiftDate()`(数字时间戳/长字符串→"YYYY-MM-DD")
+- 诊断钩子:`console.info('[入住办理] stageExpenses 回显:', ...)` 打印每项 isFreeGift/freeStartTime/freeEndTime,用户重发后看控制台判断是后端字段缺失还是类型问题
+- ESLint 0 错误

+ 20 - 0
.workbuddy/memory/2026-08-27.md

@@ -0,0 +1,20 @@
+# 2026-08-27
+
+## 长期/临时医嘱 Form resetForm 失效 bug(已修)
+- **症状**:点编辑→关闭→点「新增」,Dialog 标题仍显示「编辑」,长者选择器 disabled 锁死,里面显示上个长者(桂娃娃2(8978979))
+- **根因**:`Object.assign(form, JSON.parse(JSON.stringify(defaultForm)))`
+  - `JSON.stringify({id: undefined})` 会**丢弃 undefined 字段**(不写入 JSON 字符串)
+  - 所以反序列化出来的对象根本没有 `id` / `elderId` 这两个 key
+  - `Object.assign(target, source)` 是浅合并 + 仅复制 source 自身可枚举属性 → target.id 仍保留编辑时的旧值
+- **触发三个症状**:
+  1. `<Dialog :title="form.id ? '编辑' : '新增'">` 标题误判
+  2. `<selectElder :disabled="!!form.id">` 锁住
+  3. 提交参数把上次的 id 带出去
+- **修复**:两个 Form.vue 的 `resetForm` 改为**逐字段显式重置**,不依赖 JSON 拷贝:
+  ```js
+  form.id = undefined
+  form.elderId = undefined
+  form.elderName = ''
+  // ...其他字段
+  ```
+- **范式沉淀**:表单"重置到默认态"**不要用** `Object.assign(form, JSON.parse(JSON.stringify(defaultForm)))`,前者依赖 JSON 序列化语义、后者依赖对象属性存在性。当 defaultForm 含 undefined 字段时,二者叠加静默失效。**逐字段显式重置**最稳,或用 lodash `_.cloneDeepWith` / `structuredClone`,但都要先 unset undefined 字段。

+ 118 - 0
.workbuddy/memory/2026-08-28.md

@@ -0,0 +1,118 @@
+# 2026-08-28
+
+## 医嘱三页面加「年龄」字段(已完成)
+
+按用户截图:
+- 长期医嘱 / 临时医嘱 print 单抬头:「姓名 / 性别 / 床号 / 护理等级」 → 「姓名 / 性别 / 年龄 / 床号 / 护理等级」(在性别后插入年龄 cell)
+- 执行医嘱:列表 + 多条打印 + 单条打印 三处的「类型」列 / 行 → 全部改成「年龄」
+
+### 改动文件
+1. `src/views/elderly/elder/doctor-orders/long-doctor-order/Form.vue`
+   - `selectElder` 加 `v-model:elder-age="form.elderAge"`
+   - `defaultForm` 加 `elderAge: ''`
+   - `handleSelectElder` 补 `form.elderAge = elder.elderAge ?? ''`
+   - `resetForm` 加 `form.elderAge = ''`
+2. `src/views/elderly/elder/doctor-orders/long-doctor-order/Print.vue`
+   - `info-row` 在性别 cell 后插 `<span>年龄</span>{{ form.elderAge }}`
+   - `defaultForm` 加 `elderAge: ''`
+3. `src/views/elderly/elder/doctor-orders/short-doctor-order/Form.vue`:同 long Form
+4. `src/views/elderly/elder/doctor-orders/short-doctor-order/Print.vue`:同 long Print
+5. `src/views/elderly/elder/doctor-orders/execute-doctor-order/index.vue`
+   - 列表 `el-table`:「类型」列改 `prop="elderAge"` width=80,去掉 tag
+   - 多条打印 `<table>` thead「类型」改「年龄」,tbody 改为 `{{ item.elderAge || '' }}`
+   - 单条打印表里:「类型」th → 「年龄」,td 改 `{{ singlePrintRow.elderAge || '' }}`
+   - `makeRow()` 加 `elderAge: vo.elderAge ?? ''`
+   - `buildMockLong / buildMockShort` 三条测试数据加 `elderAge: 78 / 72 / 65`
+
+### 数据来源
+- `selectElder` 组件早就有 `elderAge` 字段 + `emit('update:elderAge', item.elderAge)`,只是 Form.vue 之前没用上
+- 详情接口(`getLongDoctorOrder` / `getShortDoctorOrder`)理论上有 elderAge(VO 字段)。若后端遗漏,需要后端补字段
+- 执行医嘱列表 elderAge 从 longOrder / shortOrder 的 VO 取(`getLongDoctorOrderPage` / `getShortDoctorOrderPage` 返回 list[].elderAge)
+
+### 截图示意
+- 单条执行医嘱打印:「长者 / 年龄 / 床位 / 状态」「长期医嘱」消失
+- 多条执行医嘱列表打印:列名「类型」→「年龄」
+- 长/短期医嘱单:抬头第 3 格「年龄:xx 岁」
+
+## 长/短医嘱「新增」弹窗加年龄展示(已完成)
+按用户新一轮截图:编辑/新增弹窗基本信息行(原本只有 性别/床位/护理等级)需要再加一列「年龄」。
+
+### 改动
+1. `long-doctor-order/Form.vue` 第 26-46 行
+   - `<el-row :gutter="16">` 内 3 列改 4 列(span 6)
+   - 在性别 cell 后插入年龄 cell:`:model-value="form.elderAge !== '' && form.elderAge !== undefined && form.elderAge !== null ? `${form.elderAge}岁` : ''"` + placeholder
+2. `short-doctor-order/Form.vue` 第 32-52 行:同 long
+
+### 回填路径
+- 新增/编辑模式通过 selectElder `v-model:elder-age="form.elderAge"` 自动回填
+- 详情接口(getLong/ShortDoctorOrder)若返 elderAge,Object.assign 时一并落入;后端暂未补该字段时编辑场景会留空(与 bedName/nurseLevelName 同源问题)
+- 显示加"岁"单位,未选长者时显示 placeholder 文字
+
+## 长期医嘱单打印:A4 横版 → 竖版(已完成)
+按用户要求:长期医嘱单打印从 A4 横版改成竖版。竖版可用宽度只有 210-8×2=194mm,12 列布局必须重排。
+
+### 重排列宽(long-doctor-order/Print.vue)
+- 容器 width: 270mm → 194mm
+- colgroup 12 列 mm 数字:6 / 18 / 50 / 10 / 10 / 11 / 12 / 12 / 18 / 12 / 12 / 23(合计 194mm)
+- @page: landscape → portrait
+- 缩窄重点:医嘱内容 85→50mm,备注 48→23mm(用户明确)
+
+### 遗留 / 待评估
+- info-row(姓名/性别/年龄/床号/护理等级)5 等分在 194mm 下每格约 145px;若"姓名:xxx"等较长文本会被挤换行。下一轮看效果决定是否改 flex-start + gap
+
+### 竖版备注再缩(备注 23→12mm,总宽 194→183mm)
+按用户第二轮反馈:备注列在 80% dialog 中 zoom >100% 时整张表被挤出右边界。
+- colgroup 末列 23 → 12mm(备注)
+- 容器 width: 194 → 183mm
+- 打印 @page A4 portrait 不变(纸张 = 210mm 减 16mm 边距 = 194mm 可用,183mm 表宽仍内嵌 ✓)
+
+### 竖版再调:备注加宽 + 左右边距缩小
+按用户第三轮反馈:12mm 备注太挤,希望左右视觉边距更小。
+- Dialog width: 80% → 95%(dialog 占屏更多)
+- 容器 width: 183mm → `width:100%; max-width:195mm`(弹填 dialog,不超过 A4 portrait 可用区 195mm)
+- 容器 padding: `4px 6px` → `4px 0`(去掉左右 padding,让表格贴 dialog 内壁)
+- colgroup 备注 12 → 24mm(+12mm,可读宽度恢复)
+- 总宽 183 → 195mm:6+18+50+10+10+11+12+12+18+12+12+24 = 195mm
+- @page A4 portrait 不变(纸张 210-16=194mm,容器 max 195 略超,但 colgroup 总和 195 ≥纸张 194 仅 ~1mm,浏览器自动调整)
+
+### colgroup mm → %,完全弹填 dialog
+按用户第四轮反馈:边距缩了之后右侧备注列右边线框仍被挤掉。
+根因:colgroup mm 是物理长度(96dpi 1mm=3.78px),用户视口窄+Windows 缩放使 195mm 实际像素超 dialog 95% 宽度。
+- colgroup 12 列 mm → %:3.08/9.23/25.64/5.13/5.13/5.64/6.15/6.15/9.23/6.15/6.15/12.31(合计 100%)
+- 比例与原 195mm 设计一致(6/18/50/.../24mm)
+- 容器 width: 100%(去掉 max-width),margin: 0(不再居中,贴 dialog 左内壁到右内壁)
+- table width: 100%(已有,table-layout: fixed 严格按 colgroup % 分列)
+- 打印纸张 A4 portrait 可用 194mm 时,容器 = 纸张宽,列宽 % 等比缩放至 194×比例 ≈ 原 6/18/50/.../24mm ✓
+
+### 临时医嘱单 colgroup 重排
+按用户第六轮反馈:竖版下医嘱列窄(28.35%)+ 备注列宽(26.80%)不合理。
+- 新比例按 A4 portrait 194mm 内容重排:医嘱 28.35→38.66%(+10.3%,75mm),备注 26.80→17.53%(-9.27%,34mm)
+- 其他列微调:医嘱时间 9.28%(18mm)、频次 5.15%(10mm)、给药时间 5.15%(10mm)、途径 5.67%(11mm)、医生/护士签名各 9.28%(18mm)
+按用户第五轮反馈:内部 cell 边框 1px #333 视觉粗,最右列外侧线在容器抗锯齿下显得细且淡,希望统一淡雅。
+- th/td `border: 1px solid #333` → `border: 1px solid #999`
+- 保持 1px 宽度不变,仅颜色变浅,所有内分割线与最外侧线视觉一致
+
+## 临时医嘱单 + 执行医嘱打印同步改成竖版(已完成)
+参照长期医嘱单竖版范式(A4 portrait + % 列宽 + #999 细线),把另外两个打印也改了。
+
+### 临时医嘱单 short-doctor-order/Print.vue
+- Dialog width: 80% → 95%
+- 容器 width: 270mm → 100%,padding 4px 6px → 4px 0,margin 0 auto → 0
+- colgroup mm(8列 22/105/14/12/14/18/18/67=270) → %(9.28/28.35/6.19/6.19/6.70/8.25/8.25/26.80,合计 100),比例取自 194mm 竖版设计
+- th/td border #333 → #999
+- @page landscape → portrait
+
+### 执行医嘱 execute-doctor-order/index.vue
+- 打印 dialog width: 1080px → 95%
+- 容器 .exec-print width: 270mm → 100%,padding 4px 6px → 4px 0,margin 0
+- 汇总表 colgroup mm(13列 6/22/22/14/75/14/14/12/22/16/22/13/18=270) → %(3.09/8.25/8.25/5.67/18.04/5.67/5.67/5.67/8.25/7.22/8.25/5.67/10.31,合计≈100),比例取自 194mm 竖版设计
+- 单条表 colgroup mm(24/96/24/96=240) → %(10/40/10/40 = 100)
+- .single-info tbody th width: 24mm → 10%
+- th/td border #333 → #999
+- @page landscape → portrait
+- 注释块「参照 A4 横版范式」→「参照 A4 竖版范式」
+
+### 列宽 % 设计原则
+- 原 mm 数之和 ÷ 194(portrait 可用宽 210-16)再 ×100 得 %,保留与原竖版 mm 设计一致的相对比例
+- 屏幕预览弹填 dialog(避免 mm 物理宽度在窄视口 + Windows 缩放下溢出)
+- 打印 @page portrait 纸张 194mm 时,容器 = 纸张宽,列宽 % 等比缩放回原 mm 值

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

@@ -0,0 +1,45 @@
+# 项目长期记忆(kyj-yanglao-web-new)
+
+## 项目身份
+湖北省养老机构系统前端(yudao 框架:Vue3 + TS + Element Plus)。重点模块:九防看板、合同(湖北2024版)、入住登记、长期/临时医嘱单、护士执行医嘱。
+
+## 医嘱模块(2026-08 搭建)
+- 长期医嘱 `long-doctor-order/`:长者 + 多条 entries 子表,条目含 status 启用/停止
+- 临时医嘱 `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`,已交付后端
+
+## 用户偏好 / 约定
+- ⚠️ **保持项目目录整洁**:非源码文件(.md 规格、交付物等)不要 Write 到项目根;交付文档走用户指定位置或先问
+- 打印页(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` 精确入库**
+
+## ⚠️ 表单 reset 失效陷阱(2026-08-27 已修)
+- **症状**:编辑→关闭→新增 → Dialog 标题误判「编辑」、选择器锁死、提交参数带旧 id
+- **禁用模式**:`Object.assign(form, JSON.parse(JSON.stringify(defaultForm)))`
+  - 原因:`JSON.stringify({id:undefined, ...})` 会**丢弃 undefined 字段**(不写入 JSON 字符串)
+  - 反序列化后 source 对象没有 `id`/`elderId` key
+  - `Object.assign` 只复制 source 自身可枚举属性 → target 上的 `form.id` 残留不变
+- **范式**:表单 reset 必须**逐字段显式重置**(`form.id = undefined; form.entries = [emptyEntry()]; ...`),不依赖 JSON 拷贝。如果 defaultForm 不含 undefined 字段才能用 JSON 拷贝路径
+- 详见两个 Form.vue 文件头注释

+ 482 - 0
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) => {
@@ -55,3 +109,431 @@ export const checkPrivateRoom = (id,elderId=undefined) => {
     url: `elderly/bedChangeRecord/checkPrivateRoom?bedId=${id}&elderId=${elderId}`
   })
 }
+
+// ============================================================
+// 长期医嘱单(LongDoctorOrder)相关接口
+// ============================================================
+// 单条长期医嘱条目(对应一条长期医嘱 + 停止信息)
+export interface LongDoctorOrderEntry {
+  id?: number
+  longDoctorOrderId?: number
+  orderTime?: string
+  orderContent?: string
+  // 给药三要素(结构化选择项)
+  frequency?: string // 给药频次,存缩写,如 qd
+  medTime?: string // 给药时间,存缩写,如 ac
+  route?: string // 给药途径,存缩写,如 po
+  doctorSign?: string
+  nurseSign?: string
+  stopTime?: string
+  stopDoctorSign?: string
+  stopNurseSign?: string
+  remark?: string
+  status?: number
+  statusText?: string
+}
+
+// 长期医嘱单 VO:按长者分组的单据,含多条 entry
+export interface LongDoctorOrderVO {
+  id?: number
+  elderId?: number
+  elderName?: string
+  gender?: number | string
+  genderName?: string
+  bedName?: string
+  nurseLevelName?: string
+  tenantId?: number | string
+  // 汇总字段(列表用,后端可冗余返回)
+  entryCount?: number
+  latestOrderTime?: string
+  entries?: LongDoctorOrderEntry[]
+  createTime?: string
+}
+
+export interface LongDoctorOrderForm {
+  id?: number
+  elderId: number
+  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: serializeLongOrder(data)
+  })
+}
+
+// 更新长期医嘱单(orderTime/stopTime 自动转毫秒时间戳发送)
+export const updateLongDoctorOrder = (data: LongDoctorOrderForm) => {
+  return request.put({
+    url: 'elderly/longDoctorOrder/update',
+    data: serializeLongOrder(data)
+  })
+}
+
+// 删除长期医嘱单
+export const deleteLongDoctorOrder = (id: number) => {
+  return request.delete({
+    url: `elderly/longDoctorOrder/delete?id=${id}`
+  })
+}
+
+// 单条长期医嘱条目状态切换(停止 / 启用)
+// status: 0=未开始 1=在用 2=已停止
+export const updateLongDoctorOrderEntryStatus = (entryId: number, status: number) => {
+  return request.put({
+    url: `elderly/longDoctorOrder/entry/updateStatus?id=${entryId}&status=${status}`
+  })
+}
+
+// 长期医嘱单状态字典(0 未开始 / 1 在用 / 2 已停止)
+export const LONG_ORDER_STATUS = {
+  NOT_STARTED: 0,
+  IN_USE: 1,
+  STOPPED: 2
+}
+
+export const LONG_ORDER_STATUS_OPTIONS = [
+  { label: '未开始', value: 0 },
+  { label: '在用', value: 1 },
+  { label: '已停止', value: 2 }
+]
+
+export const getLongDoctorOrderStatusText = (status: number | string | undefined): string => {
+  const item = LONG_ORDER_STATUS_OPTIONS.find((o) => o.value === Number(status))
+  return item ? item.label : '未知'
+}
+
+// ==================医嘱给药三要素字典(长期/临时医嘱共用)====================
+export interface MedOption {
+  value: string
+  label: string // 下拉框主显:缩写 + 中文
+  cn: string // 中文含义
+  tip: string // 临床常见使用场景(hover tips)
+}
+
+// 给药频次
+export const MED_FREQUENCY_OPTIONS: MedOption[] = [
+  { value: 'qd', label: 'qd 每日1次', cn: '每日1次', tip: '常规慢病长期口服用药' },
+  { value: 'bid', label: 'bid 每日2次', cn: '每日2次', tip: '多数普通口服药早晚分服' },
+  { value: 'tid', label: 'tid 每日3次', cn: '每日3次', tip: '需随三餐规律服用的药物' },
+  { value: 'qid', label: 'qid 每日4次', cn: '每日4次', tip: '严格间隔6小时的抗感染治疗' },
+  { value: 'qn', label: 'qn 每晚1次', cn: '每晚1次', tip: '助眠、长效降压、降脂类药物' },
+  { value: 'qod', label: 'qod 隔日1次', cn: '隔日1次', tip: '特殊半衰期较长的药物' },
+  { value: 'qw', label: 'qw 每周1次', cn: '每周1次', tip: '长效特殊制剂、慢病维持给药' },
+  { value: 'biw', label: 'biw 每周2次', cn: '每周2次', tip: '康复理疗、部分专科治疗' },
+  { value: 'tiw', label: 'tiw 每周3次', cn: '每周3次', tip: '透析、肌肉注射类治疗' },
+  { value: 'q2w', label: 'q2w 每2周1次', cn: '每2周1次', tip: '化疗、靶向、生物制剂给药' },
+  { value: 'q3w', label: 'q3w 每3周1次', cn: '每3周1次', tip: '化疗、靶向、生物制剂给药' },
+  { value: 'q4w', label: 'q4w 每4周1次', cn: '每4周1次', tip: '化疗、靶向、生物制剂给药' },
+  { value: 'q8h', label: 'q8h 每8小时1次', cn: '每8小时1次', tip: '重症、抗感染精准间隔给药' },
+  { value: 'q6h', label: 'q6h 每6小时1次', cn: '每6小时1次', tip: '重症、抗感染精准间隔给药' },
+  { value: 'q4h', label: 'q4h 每4小时1次', cn: '每4小时1次', tip: '重症、抗感染精准间隔给药' }
+]
+
+// 给药时间
+export const MED_TIME_OPTIONS: MedOption[] = [
+  { value: 'ac', label: 'ac 餐前', cn: '餐前服用', tip: '促胃动力、降糖、胃黏膜保护剂' },
+  { value: 'pc', label: 'pc 餐后', cn: '餐后服用', tip: '对胃肠道有刺激的普通药物' },
+  { value: 'hs', label: 'hs 睡前', cn: '睡前服用', tip: '助眠、长效降压、抗过敏药' },
+  { value: 'st', label: 'st 立即', cn: '立即执行', tip: '急诊急救、紧急处置类医嘱' },
+  { value: 'prn', label: 'prn 必要时', cn: '必要时使用', tip: '止痛、止吐、退热等对症药物' },
+  { value: 'sos', label: 'sos 紧急1次', cn: '紧急需要时1次', tip: '限24小时内仅用1次的应急药' },
+  { value: 'am', label: 'am 上午', cn: '上午', tip: '标注日间常规给药时间' },
+  { value: 'pm', label: 'pm 下午', cn: '下午', tip: '标注午后常规给药时间' }
+]
+
+// 给药途径
+export const MED_ROUTE_OPTIONS: MedOption[] = [
+  { value: 'po', label: 'po 口服', cn: '口服', tip: '绝大多数常规口服药物' },
+  { value: 'iv', label: 'iv 静脉推注', cn: '静脉推注', tip: '小容量药剂快速静脉给药' },
+  { value: 'ivgtt', label: 'ivgtt/iv drip 静脉滴注', cn: '静脉滴注/输液', tip: '大容量补液、抗感染输液' },
+  { value: 'im', label: 'im 肌内注射', cn: '肌内注射', tip: '常规疫苗、止痛针、部分抗生素' },
+  { value: 'sc', label: 'sc 皮下注射', cn: '皮下注射', tip: '胰岛素、低分子肝素等药物' },
+  { value: 'ih', label: 'ih 皮下注射', cn: '皮下注射', tip: '胰岛素、低分子肝素等药物' },
+  { value: 'igtt', label: 'igtt 胃肠滴注', cn: '胃肠滴注', tip: '鼻饲、肠内营养输注' },
+  { value: 'inh', label: 'inh 吸入给药', cn: '吸入给药', tip: '哮喘、慢阻肺的雾化治疗' },
+  { value: 'ext', label: 'ext 外用', cn: '外用', tip: '皮肤药膏、黏膜局部用药' }
+]
+
+// ==================临时医嘱单====================
+// 单条临时医嘱条目(对应打印页一行)
+export interface ShortDoctorOrderEntry {
+  id?: number
+  orderTime?: string
+  orderContent?: string
+  // 给药三要素(结构化选择项)
+  frequency?: string // 给药频次,存缩写,如 qd
+  medTime?: string // 给药时间,存缩写,如 ac
+  route?: string // 给药途径,存缩写,如 po
+  doctorSign?: string
+  executeTime?: string
+  nurseSign?: string
+  remark?: string
+}
+
+// 临时医嘱单 VO(按长者分组的单据,含多条 entry)
+export interface ShortDoctorOrderVO {
+  id?: number
+  elderId?: number
+  elderName?: string
+  gender?: number | string
+  genderName?: string
+  bedName?: string
+  nurseLevelName?: string
+  tenantId?: number | string
+  // 汇总字段(后端可冗余返回,便于列表展示)
+  entryCount?: number
+  latestOrderTime?: string
+  latestExecuteTime?: string
+  entries?: ShortDoctorOrderEntry[]
+  createTime?: string
+}
+
+export interface ShortDoctorOrderForm {
+  id?: number
+  elderId: number
+  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: serializeShortOrder(data)
+  })
+}
+
+// 更新临时医嘱单(orderTime/executeTime 自动转毫秒时间戳发送)
+export const updateShortDoctorOrder = (data: ShortDoctorOrderForm) => {
+  return request.put({
+    url: 'elderly/shortDoctorOrder/update',
+    data: serializeShortOrder(data)
+  })
+}
+
+// 删除临时医嘱单
+export const deleteShortDoctorOrder = (id: number) => {
+  return request.delete({
+    url: `elderly/shortDoctorOrder/delete?id=${id}`
+  })
+}
+
+// ==================医嘱执行记录(护士执行单)====================
+// 一条"护士执行"记录:前端按医嘱展开生成执行行后,护士点击执行即写入一条
+export interface DoctorOrderExecution {
+  id?: number
+  orderType?: number // 1=长期医嘱 2=临时医嘱
+  orderEntryId?: number // 关联原医嘱条目 id(长期/临时各自的 entry id)
+  elderId?: number
+  elderName?: string
+  bedName?: string
+  orderContent?: string
+  frequency?: string // 给药频次缩写
+  medTime?: string // 给药时间缩写
+  route?: string // 给药途径缩写
+  planTime?: string // 计划执行时间 YYYY-MM-DD HH:mm
+  execTime?: string // 实际执行时间
+  execNurseSign?: string // 执行护士签名
+  status?: number // 0=待执行 1=已执行 2=已跳过
+  remark?: string
+  tenantId?: number | string
+  createTime?: string
+}
+
+export const EXEC_STATUS = {
+  PENDING: 0,
+  DONE: 1,
+  SKIPPED: 2
+}
+
+export const EXEC_STATUS_OPTIONS = [
+  { label: '待执行', value: 0 },
+  { label: '已执行', value: 1 },
+  { 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: serializeExecution(data)
+  })
+}
+
+// 跳过/取消某次执行
+export const skipDoctorOrderExecution = (id: number) => {
+  return request.put({
+    url: `elderly/doctorOrderExecution/skip?id=${id}`
+  })
+}
+
+// 选项值 → 中文含义(空值返回空串)
+export const getMedCn = (options: MedOption[], val?: string): string => {
+  if (!val) return ''
+  const item = options.find((o) => o.value === val)
+  return item ? item.cn : val
+}
+
+/**
+ * 根据频次(frequency)与给药时间(medTime),在指定日期展开"计划执行时点"列表(返回 HH:mm 数组)。
+ * 说明:
+ * - 日频次(qd/bid/tid/qid/qn/q8h/q6h/q4h) 按医学常规给出多个时点的 HH:mm;
+ * - 周频次/间隔频次(qod/qw/biw/tiw/q2w/q3w/q4w) 无法对应到一天内的多个时点,统一给 1 个 09:00 代表点;
+ * - medTime 仅对"单时点"频次(如 qd/qn/qod/qw 等)做微调(hs→21:00、am→09:00、pc→08:00 等);
+ * - 该映射为前端启发式,真实排班以后端执行计划为准。
+ */
+export const buildExecutionPlanTimes = (frequency?: string, medTime?: string): string[] => {
+  const single = (base: string): string[] => [base]
+  const byMedTime = (def: 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
+    }
+  }
+  switch (frequency) {
+    case 'qd':
+      return single(byMedTime('09:00'))
+    case 'bid':
+      return medTime === 'hs' ? ['09:00', '21:00'] : ['09:00', '15:00']
+    case 'tid':
+      return ['08:00', '12:00', '18:00']
+    case 'qid':
+      return ['06:00', '12:00', '18:00', '22:00']
+    case 'qn':
+      return single('21:00')
+    case 'qod':
+      return single(byMedTime('09:00'))
+    case 'qw':
+      return single(byMedTime('09:00'))
+    case 'biw':
+      return single('09:00')
+    case 'tiw':
+      return single('09:00')
+    case 'q2w':
+      return single('09:00')
+    case 'q3w':
+      return single('09:00')
+    case 'q4w':
+      return single('09:00')
+    case 'q8h':
+      return ['08:00', '16:00', '00:00']
+    case 'q6h':
+      return ['06:00', '12:00', '18:00', '00:00']
+    case 'q4h':
+      return ['04:00', '08:00', '12:00', '16:00', '20:00', '00:00']
+    default:
+      return single('09:00')
+  }
+}

+ 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 {

+ 9 - 1
src/components/BedCardModal/index.vue

@@ -27,6 +27,7 @@
         <el-button @click="handleImport" type="warning" style="margin-left: auto"
           >导出床头卡</el-button
         >
+        <el-button @click="handleCloseE" style="margin-left: 30px">跳转到编辑页</el-button>
         <el-button @click="handleClose" style="margin-left: 30px">{{ closeText }}</el-button>
         <slot name="footer-extra"></slot>
       </div>
@@ -67,6 +68,7 @@ const emit = defineEmits<{
   'update:modelValue': [value: boolean]
   close: []
   importCard: []
+  edit: []
 }>()
 
 // 床头卡组件引用
@@ -141,7 +143,7 @@ const updatePatientData = async (data) => {
         eId: sourceData.id || sourceData.qr?.eId || '',
         tId: sourceData.tenantId || sourceData.qr?.tId || ''
       },
-      updateTime: sourceData.updateTime||''
+      updateTime: sourceData.updateTime || ''
     }
     console.log('DATA', data)
 
@@ -181,6 +183,12 @@ const handleClose = () => {
   emit('close')
 }
 
+// 关闭弹窗
+const handleCloseE = () => {
+  visible.value = false
+  emit('edit')
+}
+
 //
 const handleImport = () => {
   emit('importCard')

+ 1 - 1
src/components/TenantSelect/src/TenantSelect.vue

@@ -62,7 +62,7 @@ const props = defineProps({
   single: {
     // 针对房源,流程模型只能查单个的这种
     type: Boolean,
-    defalut: false
+    default: false
   },
   disabled: {
     type: Boolean,

+ 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)

+ 120 - 25
src/views/elderly/apply/check-in/ProcessForm.vue

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

+ 5 - 5
src/views/elderly/contracts/components/CheckInRegistrationBody.vue

@@ -875,18 +875,18 @@ const onInlineInput = (key: keyof CheckInRegistrationForm, e: Event) => {
         </td>
       </tr>
 
-      <!-- 签名区域:签章(2) + 乙方(2) + 签名(3) = 7列 -->
+      <!-- 签名区域:签章(2) + 甲方(2) + 签名(3) = 7列 -->
       <tr>
         <td class="lbl" rowspan="2" colspan="2">签章</td>
-        <td colspan="2">乙方(入住人/监护人)签章:</td>
+        <td colspan="2">甲方(机构负责人)签章:</td>
         <td colspan="3">
-          <SignatureItem v-model="form.partyBSign" :is-text-mode="isTextMode" />
+          <SignatureItem v-model="form.partyCSign" :is-text-mode="isTextMode" />
         </td>
       </tr>
       <tr>
-        <td colspan="2">丙方(机构负责人)签章:</td>
+        <td colspan="2">乙方(入住人/监护人)签章:</td>
         <td colspan="3">
-          <SignatureItem v-model="form.partyCSign" :is-text-mode="isTextMode" />
+          <SignatureItem v-model="form.partyBSign" :is-text-mode="isTextMode" />
         </td>
       </tr>
       <tr>

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

@@ -0,0 +1,147 @@
+<template>
+  <el-dialog
+    v-model="visible"
+    :title="isView ? '查看执行记录' : '护士执行医嘱'"
+    width="680px"
+    :close-on-click-modal="false"
+    append-to-body
+  >
+    <el-descriptions :column="2" border size="small" class="mb-16px">
+      <el-descriptions-item label="长者">{{ row.elderName || '-' }}</el-descriptions-item>
+      <el-descriptions-item label="床位">{{ row.bedName || '-' }}</el-descriptions-item>
+      <el-descriptions-item label="医嘱类型">
+        {{ row.orderType === 1 ? '长期医嘱' : '临时医嘱' }}
+      </el-descriptions-item>
+      <el-descriptions-item label="计划执行时间">{{ row.planTime || '-' }}</el-descriptions-item>
+      <el-descriptions-item label="给药频次">{{ freqCn(row.frequency) }}</el-descriptions-item>
+      <el-descriptions-item label="给药时间">{{ timeCn(row.medTime) }}</el-descriptions-item>
+      <el-descriptions-item label="给药途径">{{ routeCn(row.route) }}</el-descriptions-item>
+      <el-descriptions-item label="状态">
+        <el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
+      </el-descriptions-item>
+      <el-descriptions-item label="医嘱内容" :span="2">{{ row.orderContent || '-' }}</el-descriptions-item>
+    </el-descriptions>
+
+    <el-form label-width="120px" :disabled="isView">
+      <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 v-if="!isView" type="primary" :loading="saving" @click="handleSave">
+        确认执行
+      </el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script lang="ts" setup>
+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,
+  MED_FREQUENCY_OPTIONS,
+  MED_TIME_OPTIONS,
+  MED_ROUTE_OPTIONS,
+  getMedCn
+} from '@/api/elderly/elder/bed-change'
+
+const message = useMessage()
+const userStore = useUserStore()
+
+const visible = ref(false)
+const isView = ref(false)
+const saving = ref(false)
+const row = ref<any>({})
+
+const form = reactive<Partial<DoctorOrderExecution>>({
+  execTime: '',
+  execNurseSign: '',
+  remark: ''
+})
+
+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 statusType = (s?: number) => (s === 1 ? 'success' : s === 2 ? 'info' : 'warning')
+const statusText = (s?: number) => (s === 1 ? '已执行' : s === 2 ? '已跳过' : '待执行')
+
+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.remark = r?.remark || ''
+  visible.value = true
+}
+
+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) {
+  //   message.error('请填写执行护士签名')
+  //   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 {
+    await executeDoctorOrder(payload)
+    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 })
+</script>

+ 1000 - 0
src/views/elderly/elder/doctor-orders/execute-doctor-order/index.vue

@@ -0,0 +1,1000 @@
+<template>
+  <!-- 搜索 -->
+  <ContentWrap>
+    <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
+          @keyup.enter="handleQuery"
+          v-model="queryParams.elderName"
+          placeholder="请输入长者姓名"
+          class="!w-200px"
+          clearable
+        />
+      </el-form-item>
+      <el-form-item label="执行日期" prop="execDate">
+        <el-date-picker
+          v-model="queryParams.execDate"
+          type="date"
+          value-format="YYYY-MM-DD"
+          format="YYYY-MM-DD"
+          placeholder="选择执行日期"
+          style="width: 180px"
+        />
+      </el-form-item>
+      <el-form-item label="医嘱类型" prop="orderType">
+        <el-select v-model="queryParams.orderType" placeholder="全部" clearable class="!w-160px">
+          <el-option label="长期医嘱" :value="1" />
+          <el-option label="临时医嘱" :value="2" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-select v-model="queryParams.status" placeholder="全部" clearable class="!w-160px">
+          <el-option label="待执行" :value="0" />
+          <el-option label="已执行" :value="1" />
+          <el-option label="已跳过" :value="2" />
+        </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-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="180" />
+      <el-table-column label="年龄" prop="elderAge" width="80" align="center" />
+      <el-table-column label="医嘱内容" prop="orderContent" min-width="220" show-overflow-tooltip />
+      <el-table-column label="频次" width="90" align="center">
+        <template #default="{ row }">{{ freqCn(row.frequency) }}</template>
+      </el-table-column>
+      <el-table-column label="给药时间" width="90" align="center">
+        <template #default="{ row }">{{ timeCn(row.medTime) }}</template>
+      </el-table-column>
+      <el-table-column label="途径" width="90" align="center">
+        <template #default="{ row }">{{ routeCn(row.route) }}</template>
+      </el-table-column>
+      <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="实际执行时间" 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>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="220" align="center" fixed="right">
+        <template #default="{ row }">
+          <el-button
+            v-if="row.status === 0"
+            link
+            type="primary"
+            @click="openExecute(row, false)"
+            v-hasPermi="['doctor-order-execute:execute']"
+            >执行</el-button
+          >
+          <el-button
+            v-if="row.status === 0"
+            link
+            type="danger"
+            @click="handleSkip(row)"
+            v-hasPermi="['doctor-order-execute:skip']"
+            >跳过</el-button
+          >
+          <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
+          >
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <Pagination
+      :total="total"
+      v-model:page="pageNo"
+      v-model:limit="pageSize"
+      @pagination="() => {}"
+    />
+  </ContentWrap>
+
+  <!-- 执行弹窗 -->
+  <ExecuteDialog ref="execRef" @success="onExecuteSuccess" />
+
+  <!-- 跳过原因弹窗 -->
+  <el-dialog
+    v-model="skipVisible"
+    title="跳过执行"
+    width="500px"
+    :close-on-click-modal="false"
+    append-to-body
+  >
+    <el-alert
+      :title="`${skipRow.elderName || ''} · ${skipRow.bedName || ''}`"
+      :description="skipRow.orderContent || ''"
+      type="warning"
+      :closable="false"
+      class="mb-16px"
+    />
+    <el-form label-width="90px">
+      <el-form-item label="计划时间">{{ skipRow.planTime || '-' }}</el-form-item>
+      <el-form-item label="跳过原因" required>
+        <el-input
+          v-model="skipForm.remark"
+          type="textarea"
+          :rows="3"
+          maxlength="200"
+          show-word-limit
+          placeholder="请填写本次不执行的原因,如:长者外出 / 拒采 / 当日禁食转院…"
+        />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="skipVisible = false">取消</el-button>
+      <el-button type="primary" :loading="skipping" @click="confirmSkip">确认跳过</el-button>
+    </template>
+  </el-dialog>
+
+  <!-- 打印弹窗:打印区在可见状态下由 v-print 克隆,避免离屏 div 导致空白页 -->
+  <el-dialog
+    v-model="printDialogVisible"
+    :title="printMode === 'all' ? '医嘱执行单' : '医嘱执行单(单条)'"
+    width="95%"
+    append-to-body
+    :close-on-click-modal="false"
+  >
+    <!-- 汇总(当日全量):A4 竖版,弹填 95% dialog,列宽按 %,日期时间上下两行,去缩写 -->
+    <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 class="order-table">
+        <colgroup>
+          <col style="width: 3.09%" />
+          <col style="width: 8.25%" />
+          <col style="width: 8.25%" />
+          <col style="width: 5.67%" />
+          <col style="width: 18.04%" />
+          <col style="width: 5.67%" />
+          <col style="width: 5.67%" />
+          <col style="width: 5.67%" />
+          <col style="width: 8.25%" />
+          <col style="width: 7.22%" />
+          <col style="width: 8.25%" />
+          <col style="width: 5.67%" />
+          <col style="width: 10.31%" />
+        </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-for="(item, idx) in list" :key="idx">
+            <td>{{ idx + 1 }}</td>
+            <td>{{ item.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>
+
+    <!-- 单条(A4 竖版,左字段 / 右字段 两列网格,弹填 95% dialog) -->
+    <div v-else id="singlePrintArea" class="exec-print" v-loading="loading">
+      <div v-if="singlePrintRow">
+        <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>
+        <table class="order-table single-info">
+          <colgroup>
+            <col style="width: 10%" />
+            <col style="width: 40%" />
+            <col style="width: 10%" />
+            <col style="width: 40%" />
+          </colgroup>
+          <tbody>
+            <tr>
+              <th>长者</th>
+              <td>{{ singlePrintRow.elderName }}</td>
+              <th>床位</th>
+              <td>{{ singlePrintRow.bedName }}</td>
+            </tr>
+            <tr>
+              <th>年龄</th>
+              <td>{{ singlePrintRow.elderAge || '' }}</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>
+      </div>
+    </div>
+
+    <template #footer>
+      <el-button @click="printDialogVisible = false">关闭</el-button>
+      <el-button v-if="printMode === 'all'" type="primary" v-print="printAll">打印</el-button>
+      <el-button v-else type="primary" v-print="printOne">打印</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<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'
+import {
+  getLongDoctorOrderPage,
+  getShortDoctorOrderPage,
+  getDoctorOrderExecutionPage,
+  executeDoctorOrder,
+  buildExecutionPlanTimes,
+  MED_FREQUENCY_OPTIONS,
+  MED_TIME_OPTIONS,
+  MED_ROUTE_OPTIONS,
+  getMedCn,
+  fromTs,
+  type DoctorOrderExecution
+} from '@/api/elderly/elder/bed-change'
+import ExecuteDialog from './ExecuteDialog.vue'
+
+defineOptions({ name: 'DoctorOrderExecute' })
+
+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',
+  popTitle: '医嘱执行单',
+  extraCss: '',
+  preview: false,
+  zIndex: 20002
+}
+const printOne = {
+  id: 'singlePrintArea',
+  popTitle: '医嘱执行单(单条)',
+  extraCss: '',
+  preview: false,
+  zIndex: 20002
+}
+/** 打印弹窗:可见状态下由 v-print 克隆,避免离屏 div 空白页 */
+const printDialogVisible = ref(false)
+const printMode = ref<'all' | 'single'>('all')
+const singlePrintRow = ref<any>(null)
+/** 打开打印弹窗:all=当日汇总,single=当前行 */
+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
+}
+
+const loading = ref(false)
+const list = ref<any[]>([])
+const pageNo = ref(1)
+const pageSize = ref(20)
+const total = computed(() => list.value.length)
+const pagedList = computed(() => {
+  const start = (pageNo.value - 1) * pageSize.value
+  return list.value.slice(start, start + pageSize.value)
+})
+
+const queryParams = reactive({
+  tenantIds: userStore.orgTenantId as any,
+  elderName: undefined as string | undefined,
+  execDate: formatDate(new Date(), 'YYYY-MM-DD'),
+  orderType: undefined as number | undefined,
+  status: undefined as number | undefined
+})
+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)
+const statusType = (s?: number) => (s === 1 ? 'success' : s === 2 ? 'info' : 'warning')
+const statusText = (s?: number) => (s === 1 ? '已执行' : s === 2 ? '已跳过' : '待执行')
+
+/** 构造一条执行行 */
+const makeRow = (orderType: number, e: any, planTime: string, vo: any) => ({
+  orderType,
+  orderEntryId: e.id,
+  elderId: vo.elderId,
+  elderName: vo.elderName,
+  elderAge: vo.elderAge ?? '',
+  bedName: vo.bedName,
+  orderContent: e.orderContent,
+  frequency: e.frequency,
+  medTime: e.medTime,
+  route: e.route,
+  planTime,
+  status: 0,
+  execTime: '',
+  execNurseSign: '',
+  remark: '',
+  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
+  pageNo.value = 1
+  try {
+    const { tenantIds, execDate, elderName } = queryParams
+    // 1) 长期医嘱
+    let longOrders: any[] = []
+    try {
+      const lp = await getLongDoctorOrderPage({
+        pageNo: 1,
+        pageSize: 100,
+        tenantIds,
+        elderName: elderName || undefined
+      })
+      longOrders = lp.list || []
+    } catch (e) {
+      console.warn('[执行医嘱] 长期医嘱列表失败,使用测试数据', e)
+      longOrders = buildMockLong(execDate)
+    }
+    // 2) 临时医嘱
+    let shortOrders: any[] = []
+    try {
+      const sp = await getShortDoctorOrderPage({
+        pageNo: 1,
+        pageSize: 100,
+        tenantIds,
+        elderName: elderName || undefined
+      })
+      shortOrders = sp.list || []
+    } catch (e) {
+      console.warn('[执行医嘱] 临时医嘱列表失败,使用测试数据', e)
+      shortOrders = buildMockShort(execDate)
+    }
+
+    // 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[]>()
+    try {
+      const ep = await getDoctorOrderExecutionPage({
+        pageNo: 1,
+        pageSize: 1000,
+        tenantIds,
+        planDate: execDate
+      })
+      ;(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)
+      })
+    } 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 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) {
+      filtered = filtered.filter((r) => r.orderType === queryParams.orderType)
+    }
+    if (queryParams.status != null) {
+      filtered = filtered.filter((r) => r.status === queryParams.status)
+    }
+    filtered.sort((a, b) => (a.planTime || '').localeCompare(b.planTime || ''))
+    list.value = filtered
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 测试假数据:长期医嘱(在用条目,含多频次) */
+const buildMockLong = (execDate: string): any[] => [
+  {
+    id: 1001,
+    elderId: 801,
+    elderName: '张建国',
+    elderAge: 78,
+    bedName: '1号楼-301-01',
+    tenantId: queryParams.tenantIds,
+    entries: [
+      {
+        id: 9001,
+        orderTime: `${execDate} 09:00:00`,
+        orderContent: '口服苯磺酸氨氯地平片 5mg',
+        frequency: 'qd',
+        medTime: 'am',
+        route: 'po',
+        status: 1,
+        statusText: '在用'
+      },
+      {
+        id: 9002,
+        orderTime: `${execDate} 09:00:00`,
+        orderContent: '二甲双胍缓释片 0.5g 早晚服用',
+        frequency: 'bid',
+        medTime: 'pc',
+        route: 'po',
+        status: 1,
+        statusText: '在用'
+      }
+    ]
+  },
+  {
+    id: 1002,
+    elderId: 802,
+    elderName: '刘秀英',
+    elderAge: 72,
+    bedName: '2号楼-205-03',
+    tenantId: queryParams.tenantIds,
+    entries: [
+      {
+        id: 9003,
+        orderTime: `${execDate} 08:00:00`,
+        orderContent: '胰岛素皮下注射,餐前 15 分钟',
+        frequency: 'tid',
+        medTime: 'ac',
+        route: 'sc',
+        status: 1,
+        statusText: '在用'
+      }
+    ]
+  }
+]
+
+/** 测试假数据:临时医嘱(医嘱时间为当日) */
+const buildMockShort = (execDate: string): any[] => [
+  {
+    id: 2001,
+    elderId: 803,
+    elderName: '王德海',
+    elderAge: 65,
+    bedName: '1号楼-402-02',
+    tenantId: queryParams.tenantIds,
+    entries: [
+      {
+        id: 9101,
+        orderTime: `${execDate} 10:30:00`,
+        orderContent: '血常规 + 生化全套(空腹采血)',
+        frequency: 'st',
+        medTime: 'st',
+        route: 'iv',
+        executeTime: '',
+        nurseSign: ''
+      }
+    ]
+  }
+]
+
+/** 查询 */
+const handleQuery = () => {
+  buildRows()
+}
+
+/** 重置 */
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  queryParams.elderName = undefined
+  queryParams.execDate = formatDate(new Date(), 'YYYY-MM-DD')
+  queryParams.orderType = undefined
+  queryParams.status = undefined
+  handleQuery()
+}
+
+/** 执行 / 查看 */
+const execRef = ref()
+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>({})
+const skipping = ref(false)
+const skipForm = reactive({ remark: '' })
+
+const handleSkip = (row: any) => {
+  skipRow.value = row
+  skipForm.remark = ''
+  skipVisible.value = true
+}
+
+/** 确认跳过:原因必填,写入一条 status=2 的执行记录 */
+const confirmSkip = async () => {
+  if (!skipForm.remark || !skipForm.remark.trim()) {
+    message.error('请填写跳过原因')
+    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 {
+    await executeDoctorOrder(payload)
+  } 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 竖版范式:
+  - 容器宽度 100%(弹填 dialog),colgroup 按 % 切分,比例与原 194mm 设计一致
+  - 表格列宽用 %(避免窄视口 + Windows 缩放下 mm 物理宽度溢出 dialog)
+  - 日期/时间上下两行展示,节省横向空间
+  - 频次/给药时间/途径去缩写只显中文
+  - 边框 1px solid #999(淡灰,与长期医嘱单一致)
+-->
+<style lang="scss" scoped>
+.exec-print {
+  padding: 4px 0;
+  color: #000;
+  background: #fff;
+  /* 屏幕预览弹填 dialog(width:100%),左侧距 dialog 内壁 4px,右贴边。
+     打印时 @page A4 portrait 纸张可用 ~194mm,容器按其自动调整。 */
+  width: 100%;
+  margin: 0;
+  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 #555;
+      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: 10%;
+    }
+    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 竖版 210×297mm,纸张方向 + 边距由浏览器根据此规则应用到打印渲染。
+  - thead { display: table-header-group }:浏览器自动在每页重复表头
+  - tr { page-break-inside: avoid }:单条医嘱行不被切断
+-->
+<style lang="scss">
+@media print {
+  @page {
+    size: A4 portrait;
+    margin: 8mm;
+  }
+  .exec-print .order-table thead {
+    display: table-header-group;
+  }
+  .exec-print .order-table tr {
+    page-break-inside: avoid;
+  }
+}
+</style>

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

@@ -0,0 +1,473 @@
+<template>
+  <Dialog
+    v-model="dialogVisible"
+    :title="form.id ? '编辑长期医嘱单' : '新增长期医嘱单'"
+    width="80%"
+    class="long-doctor-order-dialog"
+    @close="handleClosed"
+  >
+    <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"
+          v-model:bed-name="form.bedName"
+          v-model:elder-age="form.elderAge"
+          :disabled="!!form.id"
+          @elder="handleSelectElder"
+        />
+        <el-input v-else :model-value="form.elderName" disabled />
+      </el-form-item>
+
+      <el-row :gutter="16">
+        <el-col :span="6">
+          <el-form-item label="性别">
+            <el-input :model-value="form.genderName" disabled placeholder="选择长者后自动填充" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="6">
+          <el-form-item label="年龄">
+            <el-input
+              :model-value="form.elderAge !== '' && form.elderAge !== undefined && form.elderAge !== null ? `${form.elderAge}岁` : ''"
+              disabled
+              placeholder="选择长者后自动填充"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="6">
+          <el-form-item label="床位号">
+            <el-input :model-value="form.bedName" disabled placeholder="选择长者后自动填充" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="6">
+          <el-form-item label="护理等级">
+            <el-input
+              :model-value="form.nurseLevelName"
+              disabled
+              placeholder="选择长者后自动填充"
+            />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-divider content-position="left">
+        医嘱条目
+        <el-button v-if="!isDetail" link type="primary" class="!ml-10px" @click="handleAddEntry">
+          <Icon icon="ep:plus" class="mr-2px" />新增条目
+        </el-button>
+      </el-divider>
+
+      <el-table :data="form.entries" border style="width: 100%" size="default">
+        <el-table-column label="序号" type="index" width="55" align="center" />
+        <el-table-column label="医嘱时间" width="190">
+          <template #default="{ row }">
+            <el-date-picker
+              v-model="row.orderTime"
+              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="医嘱内容" min-width="200">
+          <template #default="{ row }">
+            <el-input
+              v-model="row.orderContent"
+              type="textarea"
+              :rows="3"
+              placeholder="请输入医嘱内容"
+              :disabled="isDetail"
+            />
+          </template>
+        </el-table-column>
+        <el-table-column label="给药频次" width="140">
+          <template #default="{ row }">
+            <el-select
+              v-model="row.frequency"
+              placeholder="频次"
+              clearable
+              filterable
+              :disabled="isDetail"
+              style="width: 100%"
+            >
+              <el-option
+                v-for="o in MED_FREQUENCY_OPTIONS"
+                :key="o.value"
+                :value="o.value"
+                :label="o.label"
+                :title="o.tip"
+              />
+            </el-select>
+          </template>
+        </el-table-column>
+        <el-table-column label="给药时间" width="130">
+          <template #default="{ row }">
+            <el-select
+              v-model="row.medTime"
+              placeholder="时间"
+              clearable
+              filterable
+              :disabled="isDetail"
+              style="width: 100%"
+            >
+              <el-option
+                v-for="o in MED_TIME_OPTIONS"
+                :key="o.value"
+                :value="o.value"
+                :label="o.label"
+                :title="o.tip"
+              />
+            </el-select>
+          </template>
+        </el-table-column>
+        <el-table-column label="给药途径" width="140">
+          <template #default="{ row }">
+            <el-select
+              v-model="row.route"
+              placeholder="途径"
+              clearable
+              filterable
+              :disabled="isDetail"
+              style="width: 100%"
+            >
+              <el-option
+                v-for="o in MED_ROUTE_OPTIONS"
+                :key="o.value"
+                :value="o.value"
+                :label="o.label"
+                :title="o.tip"
+              />
+            </el-select>
+          </template>
+        </el-table-column>
+        <el-table-column label="医生签名" width="130">
+          <template #default="{ row }">
+            <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
+              v-model="row.stopTime"
+              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.stopDoctorSign"
+              placeholder="停止医生签名"
+              :disabled="isDetail"
+            />
+          </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
+              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">
+          <template #default="{ row }">
+            <el-button
+              v-if="!isDetail"
+              link
+              :type="row.status === LONG_ORDER_STATUS.IN_USE ? 'warning' : 'success'"
+              :loading="row.id !== undefined && togglingId === row.id"
+              @click="handleToggleEntryStatus(row)"
+              v-hasPermi="['long-doctor-order:edit']"
+              >{{ row.status === LONG_ORDER_STATUS.IN_USE ? '停止医嘱' : '启用医嘱' }}</el-button
+            >
+            <el-button
+              v-if="!isDetail"
+              link
+              type="danger"
+              @click="handleRemoveEntry(getEntryIndex(row))"
+              >删除</el-button
+            >
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <div v-if="!form.entries || form.entries.length === 0" class="empty-tip">
+        暂无医嘱,点击右上「新增条目」添加
+      </div>
+    </el-form>
+
+    <template #footer>
+      <el-button @click="handleClosed">关闭</el-button>
+      <el-button v-if="isDetail" type="primary" @click="handlePrint">打印</el-button>
+      <el-button v-if="!isDetail" v-loading="formLoading" type="primary" @click="submitForm">
+        提交
+      </el-button>
+    </template>
+  </Dialog>
+
+  <!-- 打印(详情页) -->
+  <Print ref="printRef" />
+</template>
+
+<script lang="ts" setup>
+import {
+  getLongDoctorOrder,
+  createLongDoctorOrder,
+  updateLongDoctorOrder,
+  updateLongDoctorOrderEntryStatus,
+  LONG_ORDER_STATUS,
+  MED_FREQUENCY_OPTIONS,
+  MED_TIME_OPTIONS,
+  MED_ROUTE_OPTIONS,
+  type LongDoctorOrderForm,
+  type LongDoctorOrderEntry
+} from '@/api/elderly/elder/bed-change'
+import { getDictLabel, DICT_TYPE } from '@/utils/dict'
+import Print from './Print.vue'
+
+defineOptions({ name: 'LongDoctorOrderForm' })
+
+const message = useMessage()
+const { t } = useI18n()
+
+const dialogVisible = ref(false)
+const formLoading = ref(false)
+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: '',
+  orderContent: '',
+  frequency: '',
+  medTime: '',
+  route: '',
+  doctorSign: '',
+  nurseSign: '',
+  stopTime: '',
+  stopDoctorSign: '',
+  stopNurseSign: '',
+  remark: '',
+  status: LONG_ORDER_STATUS.IN_USE,
+  statusText: '在用'
+})
+
+const defaultForm: LongDoctorOrderForm & {
+  elderName?: string
+  gender?: number | string
+  genderName?: string
+  bedName?: string
+  nurseLevelName?: string
+  elderAge?: string | number
+} = {
+  id: undefined,
+  elderId: undefined,
+  elderName: '',
+  gender: '',
+  genderName: '',
+  bedName: '',
+  nurseLevelName: '',
+  elderAge: '',
+  entries: [emptyEntry()]
+}
+
+const form = reactive(JSON.parse(JSON.stringify(defaultForm)))
+
+const rules = {
+  elderId: [{ required: true, message: '请选择长者', trigger: 'change' }],
+  entries: [
+    {
+      validator: (_rule: any, _value: any, callback: any) => {
+        if (!form.entries || form.entries.length === 0) {
+          callback(new Error('请至少添加一条医嘱'))
+        } else if (form.entries.some((e) => !e.orderContent)) {
+          callback(new Error('每条医嘱必须填写医嘱内容'))
+        } else {
+          callback()
+        }
+      },
+      trigger: 'change'
+    }
+  ]
+}
+
+/** 选择长者后补全护理等级、性别字典、年龄 */
+const handleSelectElder = (elder: any) => {
+  if (!elder) return
+  form.nurseLevelName = elder.nurseLevelName || elder.nurseLevel || ''
+  form.bedName = elder.bedName || form.bedName || ''
+  form.gender = elder.elderSex ?? elder.gender ?? form.gender
+  form.genderName = getDictLabel(DICT_TYPE.SYSTEM_USER_SEX, form.gender) || ''
+  form.elderAge = elder.elderAge ?? ''
+}
+
+/** 打开弹窗: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()
+  if (row?.id) {
+    formLoading.value = true
+    try {
+      const data: any = await getLongDoctorOrder(row.id)
+      Object.assign(form, defaultForm, data)
+      form.genderName = getDictLabel(DICT_TYPE.SYSTEM_USER_SEX, form.gender) || ''
+      form.nurseLevelName = data.nurseLevelName || ''
+      form.bedName = data.bedName || ''
+      form.entries = (data.entries || []).map((e: any) => ({ ...emptyEntry(), ...e }))
+      if (form.entries.length === 0) form.entries = [emptyEntry()]
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open })
+
+const resetForm = () => {
+  // 注意:不能用 Object.assign(form, JSON.parse(JSON.stringify(defaultForm))),
+  // 因为 JSON.stringify 会丢弃值为 undefined 的字段(如 id / elderId),
+  // 导致 Object.assign 不会覆盖它们,编辑→新增/详情切换时 form.id 残留引发:
+  //   1. Dialog 标题误判为「编辑」
+  //   2. selectElder :disabled="!!form.id" 锁住,长者选不了
+  //   3. 残留值被提交出去
+  // 这里逐字段显式重置,最稳。
+  form.id = undefined
+  form.elderId = undefined
+  form.elderName = ''
+  form.gender = ''
+  form.genderName = ''
+  form.bedName = ''
+  form.nurseLevelName = ''
+  form.elderAge = ''
+  form.entries = [emptyEntry()]
+  formRef.value?.clearValidate()
+  togglingId.value = undefined
+}
+
+const handleClosed = () => {
+  resetForm()
+  dialogVisible.value = false
+}
+
+/** 新增 / 删除医嘱条目 */
+const handleAddEntry = () => {
+  form.entries.push(emptyEntry())
+}
+
+const getEntryIndex = (entry: any) => {
+  return form.entries.indexOf(entry)
+}
+
+const handleRemoveEntry = (idx: number) => {
+  if (idx < 0) return
+  if (form.entries.length <= 1) {
+    message.warning('至少保留一条医嘱')
+    return
+  }
+  form.entries.splice(idx, 1)
+}
+
+/** 单条医嘱启用 / 停止切换(走 entries 子表内按钮) */
+const handleToggleEntryStatus = async (row: LongDoctorOrderEntry) => {
+  if (!row.id) {
+    message.warning('新添加的条目请先保存后再启用/停止')
+    return
+  }
+  // 按"按钮当前显示的语义"决定动作:在用 → 停止;非在用(未开始/已停止)→ 启用
+  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
+    await updateLongDoctorOrderEntryStatus(row.id, targetStatus)
+    message.success(`已${action}`)
+    row.status = targetStatus
+    row.statusText = targetStatus === LONG_ORDER_STATUS.STOPPED ? '已停止' : '在用'
+  } catch {
+  } finally {
+    togglingId.value = undefined
+  }
+}
+
+/** 从详情页打开打印 */
+const handlePrint = () => {
+  printRef.value?.open({ id: form.id })
+}
+
+const emit = defineEmits(['success'])
+
+const submitForm = async () => {
+  if (formLoading.value) return
+  const valid = await formRef.value?.validate().catch(() => false)
+  if (!valid) return
+  formLoading.value = true
+  try {
+    const payload: LongDoctorOrderForm = {
+      id: form.id,
+      elderId: form.elderId,
+      entries: form.entries.map((e) => ({ ...e }))
+    }
+    if (form.id) {
+      await updateLongDoctorOrder(payload)
+    } else {
+      await createLongDoctorOrder(payload)
+    }
+    message.success(t(form.id ? 'common.updateSuccess' : 'common.createSuccess'))
+    emit('success')
+    handleClosed()
+  } finally {
+    setTimeout(() => {
+      formLoading.value = false
+    }, 300)
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.long-doctor-order-dialog :deep(.el-form-item) {
+  margin-bottom: 18px;
+}
+.empty-tip {
+  text-align: center;
+  color: #999;
+  padding: 24px 0;
+  font-size: 14px;
+  border: 1px dashed #ddd;
+  margin-top: 8px;
+}
+</style>

+ 346 - 0
src/views/elderly/elder/doctor-orders/long-doctor-order/Print.vue

@@ -0,0 +1,346 @@
+<template>
+  <Dialog v-model="dialogVisible" title="长期医嘱单" width="95%" scroll>
+    <div class="long-order-print" id="longOrderPrintArea" v-loading="loading">
+      <h2 class="doc-title">长期医嘱单</h2>
+
+      <!-- 基本信息 -->
+      <div class="info-row">
+        <div class="info-cell"><span class="lbl">姓名:</span>{{ form.elderName || '' }}</div>
+        <div class="info-cell"><span class="lbl">性别:</span>{{ genderText }}</div>
+        <div class="info-cell"><span class="lbl">年龄:</span>{{ form.elderAge || '' }}</div>
+        <div class="info-cell"><span class="lbl">床号:</span>{{ form.bedName || '' }}</div>
+        <div class="info-cell">
+          <span class="lbl">护理等级:</span>{{ form.nurseLevelName || '' }}
+        </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 %) -->
+      <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%" />
+        </colgroup>
+        <thead>
+          <tr>
+            <th>序号</th>
+            <th>医嘱时间</th>
+            <th>医嘱内容</th>
+            <th>频次</th>
+            <th>给药时间</th>
+            <th>途径</th>
+            <th>医生<br />签名</th>
+            <th>护士<br />签名</th>
+            <th>停止时间</th>
+            <th>停止<br />医生签名</th>
+            <th>停止<br />护士签名</th>
+            <th>备注</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-for="(item, idx) in paddedEntries" :key="idx">
+            <td>
+              <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">{{ getMedCn(MED_FREQUENCY_OPTIONS, item.frequency) }}</span>
+            </td>
+            <td>
+              <span class="cell-value">{{ getMedCn(MED_TIME_OPTIONS, item.medTime) }}</span>
+            </td>
+            <td>
+              <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>
+                <div class="t-line">{{ formatDateTimeSplit(item.stopTime).time }}</div>
+              </div>
+            </td>
+            <td>{{ item.stopDoctorSign || '' }}</td>
+            <td>{{ item.stopNurseSign || '' }}</td>
+            <td>
+              <span class="cell-value pre">{{ item.remark || '' }}</span>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+
+    <template #footer>
+      <el-button @click="handleClosed">关闭</el-button>
+      <el-button type="primary" v-print="print">打印</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script lang="ts" setup>
+import {
+  getLongDoctorOrder,
+  getMedCn,
+  MED_FREQUENCY_OPTIONS,
+  MED_TIME_OPTIONS,
+  MED_ROUTE_OPTIONS,
+  type LongDoctorOrderEntry
+} from '@/api/elderly/elder/bed-change'
+import { getDictLabel, DICT_TYPE } from '@/utils/dict'
+
+defineOptions({ name: 'LongDoctorOrderPrint' })
+
+const dialogVisible = ref(false)
+const loading = ref(false)
+
+const defaultForm = {
+  id: undefined,
+  elderId: undefined,
+  elderName: '',
+  gender: '',
+  elderAge: '',
+  bedName: '',
+  nurseLevelName: '',
+  entries: [] as LongDoctorOrderEntry[]
+}
+const form = reactive({ ...defaultForm })
+
+const genderText = computed(() =>
+  form.gender === '' || form.gender === undefined || form.gender === null
+    ? ''
+    : getDictLabel(DICT_TYPE.SYSTEM_USER_SEX, form.gender)
+)
+
+/** 打印页最少展示 6 行,不足补空行 */
+const MIN_ROWS = 6
+const paddedEntries = computed<LongDoctorOrderEntry[]>(() => {
+  const list = form.entries || []
+  const padding: LongDoctorOrderEntry[] = Array.from(
+    { length: Math.max(0, MIN_ROWS - list.length) },
+    () => ({
+      orderTime: '',
+      orderContent: '',
+      frequency: '',
+      medTime: '',
+      route: '',
+      doctorSign: '',
+      nurseSign: '',
+      stopTime: '',
+      stopDoctorSign: '',
+      stopNurseSign: '',
+      remark: ''
+    })
+  )
+  return [...list, ...padding]
+})
+
+/**
+ * 标准格式化时间为 YYYY-MM-DD HH:mm
+ * - 字符串日期直接匹配;如已是标准格式则原样返回,否则尽量截前 16 位
+ * - 数字时间戳按本地时区解析
+ * - 空值/无效返回空串
+ */
+/**
+ * 把时间拆成 { 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') {
+    const m = val.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/)
+    if (m) return `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}`
+    // 兜底:返回原字符串(不再做任何神秘切分)
+    return val
+  }
+  if (typeof val === 'number') {
+    const d = new Date(val)
+    if (isNaN(d.getTime())) return ''
+    const pad = (n: number) => String(n).padStart(2, '0')
+    return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
+  }
+  return ''
+}
+
+/** 打开打印(详情页) */
+const open = async (row: any = {}) => {
+  dialogVisible.value = true
+  loading.value = true
+  try {
+    Object.assign(form, defaultForm, { entries: [] })
+    if (row?.id) {
+      const data: any = await getLongDoctorOrder(row.id)
+      Object.assign(form, defaultForm, data)
+      form.entries = (data.entries || []) as LongDoctorOrderEntry[]
+    }
+  } finally {
+    loading.value = false
+  }
+}
+defineExpose({ open })
+
+const handleClosed = () => {
+  dialogVisible.value = false
+  Object.assign(form, defaultForm, { entries: [] })
+}
+
+// 打印配置(vue3-print-nb)
+const print = {
+  id: 'longOrderPrintArea',
+  popTitle: '长期医嘱单',
+  extraCss: '',
+  preview: false,
+  zIndex: 20002
+}
+</script>
+
+<style lang="scss" scoped>
+.long-order-print {
+  padding: 4px 0; /* 左右 padding 去掉,让容器贴满 dialog */
+  color: #000;
+  background: #fff;
+  /* 屏幕预览弹填 dialog(width:100%),左侧距 dialog 内壁 4px,右贴边。
+     打印时 @page A4 portrait 纸张可用 ~194mm,容器按其自动调整。 */
+  width: 100%;
+  margin: 0;
+  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 #555;
+      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;
+    }
+  }
+}
+</style>
+
+<!--
+  @page 必须 un-scoped(CSS 规范限制),单独放一个 style 块。
+  A4 竖版 210×297mm,纸张方向 + 边距由浏览器根据此规则应用到打印渲染。
+  - thead { display: table-header-group }:浏览器自动在每页重复表头
+  - tr { page-break-inside: avoid }:单条医嘱行不被切断
+  - 表格整体允许跨页(默认行为)
+-->
+<style lang="scss">
+@media print {
+  @page {
+    size: A4 portrait;
+    margin: 8mm;
+  }
+  .long-order-print .order-table thead {
+    display: table-header-group;
+  }
+  .long-order-print .order-table tr {
+    page-break-inside: avoid;
+  }
+}
+</style>

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

@@ -0,0 +1,45 @@
+import { DICT_TYPE } from '@/utils/dict'
+
+// ==================长期医嘱单====================
+export const LongDoctorOrderColumns = reactive([
+  {
+    label: '长者姓名',
+    field: 'elderName',
+    width: 120
+  },
+  {
+    label: '性别',
+    field: 'gender',
+    type: '1',
+    dictArr: DICT_TYPE.SYSTEM_USER_SEX,
+    width: 80
+  },
+  {
+    label: '床位号',
+    field: 'bedName',
+    width: 180
+  },
+  {
+    label: '护理等级',
+    field: 'nurseLevelName',
+    width: 120
+  },
+  {
+    label: '医嘱条数',
+    field: 'entryCount',
+    width: 100,
+    align: 'center'
+  },
+  {
+    label: '最新医嘱时间',
+    field: 'latestOrderTime',
+    format: true,
+    width: 170
+  },
+  {
+    label: '创建时间',
+    field: 'createTime',
+    format: true,
+    width: 170
+  }
+])

+ 338 - 0
src/views/elderly/elder/doctor-orders/long-doctor-order/index.vue

@@ -0,0 +1,338 @@
+<template>
+  <!-- 搜索 -->
+  <ContentWrap>
+    <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
+          @keyup.enter="handleQuery"
+          v-model="queryParams.elderName"
+          placeholder="请输入长者姓名"
+          class="!w-240px"
+          clearable
+        />
+      </el-form-item>
+      <el-form-item label="医嘱时间" prop="orderTime">
+        <el-date-picker
+          style="width: 240px"
+          v-model="queryParams.orderTime"
+          type="daterange"
+          :clearable="true"
+          placeholder="选择开始时间范围"
+          value-format="YYYY-MM-DD"
+          format="YYYY-MM-DD"
+          date-format="YYYY-MM-DD"
+        />
+      </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-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <TabBarBtn @add="openForm()" />
+    <Table2
+      v-loading="loading"
+      :data="list"
+      :columns="LongDoctorOrderColumns"
+      :queryParams="queryParams"
+      :op-width="240"
+      :show-edit-condition="() => false"
+      :show-delete-condition="() => false"
+      :show-detail-condition="() => false"
+      @del="handleDelete"
+    >
+      <template #next="{ scope }">
+        <el-button
+          link
+          type="primary"
+          @click="openForm(scope)"
+          v-hasPermi="['long-doctor-order:edit']"
+          >编辑</el-button
+        >
+        <el-button
+          link
+          type="danger"
+          @click="handleDelete(scope.id)"
+          v-hasPermi="['long-doctor-order:delete']"
+          >删除</el-button
+        >
+        <el-button link type="primary" @click="openPrint(scope)">打印</el-button>
+      </template>
+    </Table2>
+
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 新增 / 编辑弹窗 -->
+  <Form ref="formRef" @success="getList" />
+
+  <!-- 打印(详情页) -->
+  <Print ref="printRef" />
+</template>
+
+<script lang="ts" setup>
+import {
+  getLongDoctorOrderPage,
+  deleteLongDoctorOrder,
+  getLongDoctorOrderStatusText
+} from '@/api/elderly/elder/bed-change'
+import { LongDoctorOrderColumns } from './columns'
+import Form from './Form.vue'
+import Print from './Print.vue'
+import { useUserStore } from '@/store/modules/user'
+
+defineOptions({ name: 'LongDoctorOrder' })
+
+const userStore = useUserStore()
+const message = useMessage()
+const { t } = useI18n()
+
+const loading = ref(true)
+const total = ref(0)
+const list = ref<any[]>([])
+
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  elderName: undefined as string | undefined,
+  orderTime: [] as string[] | undefined,
+  tenantIds: userStore.orgTenantId as any
+})
+const queryFormRef = ref()
+
+/** 测试假数据:列表请求失败时填充,方便前端联调 */
+const buildMockLongDoctorOrders = () => {
+  const base = [
+    {
+      id: 1001,
+      elderId: 801,
+      elderName: '张建国',
+      gender: 1,
+      genderName: '男',
+      bedName: '1号楼-301-01',
+      nurseLevelName: '二级护理',
+      entryCount: 2,
+      latestOrderTime: '2026-08-10 09:00:00',
+      entries: [
+        {
+          id: 9001,
+          orderTime: '2026-08-10 09:00:00',
+          orderContent: '每日晨间测量血压,记录于护理记录单;低盐低脂饮食。',
+          doctorSign: '李医生',
+          nurseSign: '王护士',
+          stopTime: '',
+          stopDoctorSign: '',
+          stopNurseSign: '',
+          remark: '家属已告知注意事项',
+          status: 1,
+          statusText: '在用'
+        },
+        {
+          id: 9002,
+          orderTime: '2026-08-10 09:30:00',
+          orderContent: '口服降压药苯磺酸氨氯地平 5mg,每日一次,晨起服用。',
+          doctorSign: '李医生',
+          nurseSign: '',
+          stopTime: '',
+          stopDoctorSign: '',
+          stopNurseSign: '',
+          remark: '',
+          status: 0,
+          statusText: '未开始'
+        }
+      ]
+    },
+    {
+      id: 1002,
+      elderId: 802,
+      elderName: '刘秀英',
+      gender: 2,
+      genderName: '女',
+      bedName: '2号楼-205-03',
+      nurseLevelName: '一级护理',
+      entryCount: 1,
+      latestOrderTime: '2026-08-12 14:30:00',
+      entries: [
+        {
+          id: 9003,
+          orderTime: '2026-08-12 14:30:00',
+          orderContent: '口服阿司匹林肠溶片 100mg,每日一次,晚饭后服用。',
+          doctorSign: '陈医生',
+          nurseSign: '赵护士',
+          stopTime: '',
+          stopDoctorSign: '',
+          stopNurseSign: '',
+          remark: '',
+          status: 1,
+          statusText: '在用'
+        }
+      ]
+    },
+    {
+      id: 1003,
+      elderId: 803,
+      elderName: '王德海',
+      gender: 1,
+      genderName: '男',
+      bedName: '1号楼-402-02',
+      nurseLevelName: '三级护理',
+      entryCount: 1,
+      latestOrderTime: '2026-07-20 08:15:00',
+      entries: [
+        {
+          id: 9004,
+          orderTime: '2026-07-20 08:15:00',
+          orderContent: '康复科会诊,每周二次肢体功能训练。',
+          doctorSign: '周医生',
+          nurseSign: '孙护士',
+          stopTime: '2026-08-15 10:00:00',
+          stopDoctorSign: '周医生',
+          stopNurseSign: '孙护士',
+          remark: '长者转院,遵医嘱停止',
+          status: 2,
+          statusText: '已停止'
+        }
+      ]
+    },
+    {
+      id: 1004,
+      elderId: 804,
+      elderName: '赵桂芳',
+      gender: 2,
+      genderName: '女',
+      bedName: '3号楼-108-01',
+      nurseLevelName: '一级护理',
+      entryCount: 1,
+      latestOrderTime: '2026-08-18 16:00:00',
+      entries: [
+        {
+          id: 9005,
+          orderTime: '2026-08-18 16:00:00',
+          orderContent: '胰岛素皮下注射,餐前 15 分钟,剂量遵医嘱调整。',
+          doctorSign: '',
+          nurseSign: '',
+          stopTime: '',
+          stopDoctorSign: '',
+          stopNurseSign: '',
+          remark: '待主管医生签字',
+          status: 1,
+          statusText: '在用'
+        }
+      ]
+    }
+  ]
+  return base.map((item: any) => ({
+    ...item,
+    tenantId: queryParams.tenantIds
+  }))
+}
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const params: Recordable = {
+      pageNo: queryParams.pageNo,
+      pageSize: queryParams.pageSize,
+      elderName: queryParams.elderName || undefined,
+      tenantIds: queryParams.tenantIds
+    }
+    // 时间范围
+    if (Array.isArray(queryParams.orderTime) && queryParams.orderTime.length === 2) {
+      params.beginTime = queryParams.orderTime[0]
+      params.endTime = queryParams.orderTime[1]
+    }
+    const data = await getLongDoctorOrderPage(params)
+    list.value = (data.list || []).map((item: any) => ({
+      ...item,
+      entryCount: item.entryCount ?? (item.entries ? item.entries.length : 0)
+    }))
+    total.value = data.total || 0
+  } catch (e) {
+    // 列表请求失败时填充测试假数据,便于前端联调(控制台有提示,不影响页面渲染)
+    console.warn('[长期医嘱单] 列表请求失败,已使用测试假数据。', e)
+    list.value = buildMockLongDoctorOrders()
+    total.value = list.value.length
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索 */
+const handleQuery = async () => {
+  await queryFormRef.value?.validate().catch(() => {})
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置 */
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  queryParams.orderTime = []
+  handleQuery()
+}
+
+/** 新增 / 编辑 */
+const formRef = ref()
+const printRef = ref()
+const openForm = (row: any = {}) => {
+  if (
+    !queryParams.tenantIds ||
+    (Array.isArray(queryParams.tenantIds) && queryParams.tenantIds.length === 0) ||
+    (Array.isArray(queryParams.tenantIds) && queryParams.tenantIds.length > 1 && !row.tenantId)
+  ) {
+    message.error('新增只能选择一个机构')
+    return
+  }
+  formRef.value.open(row)
+}
+
+/** 打开详情(只读) */
+const openDetail = (row: any = {}) => {
+  formRef.value.open(row, true)
+}
+
+/** 打印 */
+const openPrint = (row: any = {}) => {
+  printRef.value.open(row)
+}
+
+/** 删除 */
+const handleDelete = async (id: number) => {
+  try {
+    await message.confirm('确定删除该长期医嘱单吗?删除后不可恢复。', '提示')
+    await deleteLongDoctorOrder(id)
+    message.success(t('common.delSuccess'))
+    await getList()
+  } catch {}
+}
+
+/** 路由携带 elderName 时自动带入 */
+const route = useRoute()
+onMounted(() => {
+  if (route.query && route.query.elderName) {
+    queryParams.elderName = route.query.elderName as string
+  }
+  getList()
+})
+</script>

+ 421 - 0
src/views/elderly/elder/doctor-orders/short-doctor-order/Form.vue

@@ -0,0 +1,421 @@
+<template>
+  <Dialog
+    v-model="dialogVisible"
+    :title="form.id ? '编辑临时医嘱单' : '新增临时医嘱单'"
+    width="80%"
+    class="short-doctor-order-dialog"
+    @close="handleClosed"
+  >
+    <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"
+          v-model:bed-name="form.bedName"
+          v-model:elder-age="form.elderAge"
+          :disabled="!!form.id"
+          @elder="handleSelectElder"
+        />
+        <el-input v-else :model-value="form.elderName" disabled />
+      </el-form-item>
+
+      <el-row :gutter="16">
+        <el-col :span="6">
+          <el-form-item label="性别">
+            <el-input :model-value="form.genderName" disabled placeholder="选择长者后自动填充" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="6">
+          <el-form-item label="年龄">
+            <el-input
+              :model-value="form.elderAge !== '' && form.elderAge !== undefined && form.elderAge !== null ? `${form.elderAge}岁` : ''"
+              disabled
+              placeholder="选择长者后自动填充"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="6">
+          <el-form-item label="床位号">
+            <el-input :model-value="form.bedName" disabled placeholder="选择长者后自动填充" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="6">
+          <el-form-item label="护理等级">
+            <el-input
+              :model-value="form.nurseLevelName"
+              disabled
+              placeholder="选择长者后自动填充"
+            />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-divider content-position="left">
+        医嘱条目
+        <el-button
+          v-if="!isDetail"
+          link
+          type="primary"
+          class="!ml-10px"
+          @click="handleAddEntry"
+        >
+          <Icon icon="ep:plus" class="mr-2px" />新增条目
+        </el-button>
+      </el-divider>
+
+      <el-table :data="form.entries" border style="width: 100%" size="default">
+        <el-table-column label="序号" type="index" width="55" align="center" />
+        <el-table-column label="医嘱时间" width="190">
+          <template #default="{ row }">
+            <el-date-picker
+              v-model="row.orderTime"
+              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="医嘱内容" min-width="200">
+          <template #default="{ row }">
+            <el-input
+              v-model="row.orderContent"
+              type="textarea"
+              :rows="2"
+              placeholder="请输入医嘱内容"
+              :disabled="isDetail"
+            />
+          </template>
+        </el-table-column>
+        <el-table-column label="给药频次" width="140">
+          <template #default="{ row }">
+            <el-select
+              v-model="row.frequency"
+              placeholder="频次"
+              clearable
+              filterable
+              :disabled="isDetail"
+              style="width: 100%"
+            >
+              <el-option
+                v-for="o in MED_FREQUENCY_OPTIONS"
+                :key="o.value"
+                :value="o.value"
+                :label="o.label"
+                :title="o.tip"
+              />
+            </el-select>
+          </template>
+        </el-table-column>
+        <el-table-column label="给药时间" width="130">
+          <template #default="{ row }">
+            <el-select
+              v-model="row.medTime"
+              placeholder="时间"
+              clearable
+              filterable
+              :disabled="isDetail"
+              style="width: 100%"
+            >
+              <el-option
+                v-for="o in MED_TIME_OPTIONS"
+                :key="o.value"
+                :value="o.value"
+                :label="o.label"
+                :title="o.tip"
+              />
+            </el-select>
+          </template>
+        </el-table-column>
+        <el-table-column label="给药途径" width="140">
+          <template #default="{ row }">
+            <el-select
+              v-model="row.route"
+              placeholder="途径"
+              clearable
+              filterable
+              :disabled="isDetail"
+              style="width: 100%"
+            >
+              <el-option
+                v-for="o in MED_ROUTE_OPTIONS"
+                :key="o.value"
+                :value="o.value"
+                :label="o.label"
+                :title="o.tip"
+              />
+            </el-select>
+          </template>
+        </el-table-column>
+        <el-table-column label="医生签名" width="140">
+          <template #default="{ row }">
+            <el-input
+              v-model="row.doctorSign"
+              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" />
+          </template>
+        </el-table-column>
+        <el-table-column v-if="!isDetail" label="操作" width="80" align="center" fixed="right">
+          <template #default="{ $index }">
+            <el-button link type="danger" @click="handleRemoveEntry($index)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <div v-if="!form.entries || form.entries.length === 0" class="empty-tip">
+        暂无医嘱,点击右上「新增条目」添加
+      </div>
+    </el-form>
+
+    <template #footer>
+      <el-button @click="handleClosed">关闭</el-button>
+      <el-button v-if="isDetail" type="primary" @click="handlePrint">打印</el-button>
+      <el-button v-if="!isDetail" v-loading="formLoading" type="primary" @click="submitForm">
+        提交
+      </el-button>
+    </template>
+  </Dialog>
+
+  <!-- 打印(详情页) -->
+  <Print ref="printRef" />
+</template>
+
+<script lang="ts" setup>
+import {
+  getShortDoctorOrder,
+  createShortDoctorOrder,
+  updateShortDoctorOrder,
+  MED_FREQUENCY_OPTIONS,
+  MED_TIME_OPTIONS,
+  MED_ROUTE_OPTIONS,
+  type ShortDoctorOrderForm,
+  type ShortDoctorOrderEntry
+} from '@/api/elderly/elder/bed-change'
+import { getDictLabel, DICT_TYPE } from '@/utils/dict'
+import Print from './Print.vue'
+
+defineOptions({ name: 'ShortDoctorOrderForm' })
+
+const message = useMessage()
+const { t } = useI18n()
+
+const dialogVisible = ref(false)
+const formLoading = ref(false)
+const isDetail = ref(false)
+const formRef = ref()
+const printRef = ref()
+// 每次"开弹窗"递增,强制 selectElder 重新挂载,避免其内部 list 残留
+const selectKey = ref(0)
+
+const emptyEntry = (): ShortDoctorOrderEntry => ({
+  orderTime: '',
+  orderContent: '',
+  frequency: '',
+  medTime: '',
+  route: '',
+  doctorSign: '',
+  executeTime: '',
+  nurseSign: '',
+  remark: ''
+})
+
+const defaultForm: ShortDoctorOrderForm & {
+  elderName?: string
+  gender?: number | string
+  genderName?: string
+  bedName?: string
+  nurseLevelName?: string
+  elderAge?: string | number
+} = {
+  id: undefined,
+  elderId: undefined,
+  elderName: '',
+  gender: '',
+  genderName: '',
+  bedName: '',
+  nurseLevelName: '',
+  elderAge: '',
+  entries: [emptyEntry()]
+}
+
+const form = reactive(JSON.parse(JSON.stringify(defaultForm)))
+
+const rules = {
+  elderId: [{ required: true, message: '请选择长者', trigger: 'change' }],
+  entries: [
+    {
+      validator: (_rule: any, _value: any, callback: any) => {
+        if (!form.entries || form.entries.length === 0) {
+          callback(new Error('请至少添加一条医嘱'))
+        } else if (form.entries.some((e) => !e.orderContent)) {
+          callback(new Error('每条医嘱必须填写医嘱内容'))
+        } else {
+          callback()
+        }
+      },
+      trigger: 'change'
+    }
+  ]
+}
+
+/** 选择长者后补全护理等级、性别字典、年龄 */
+const handleSelectElder = (elder: any) => {
+  if (!elder) return
+  form.nurseLevelName = elder.nurseLevelName || elder.nurseLevel || ''
+  form.bedName = elder.bedName || form.bedName || ''
+  form.gender = elder.elderSex ?? elder.gender ?? form.gender
+  form.genderName = getDictLabel(DICT_TYPE.SYSTEM_USER_SEX, form.gender) || ''
+  form.elderAge = elder.elderAge ?? ''
+}
+
+/** 打开弹窗: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()
+  if (row?.id) {
+    formLoading.value = true
+    try {
+      const data: any = await getShortDoctorOrder(row.id)
+      Object.assign(form, defaultForm, data)
+      form.genderName = getDictLabel(DICT_TYPE.SYSTEM_USER_SEX, form.gender) || ''
+      form.nurseLevelName = data.nurseLevelName || ''
+      form.bedName = data.bedName || ''
+      form.entries = (data.entries || []).map((e: any) => ({ ...emptyEntry(), ...e }))
+      if (form.entries.length === 0) form.entries = [emptyEntry()]
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open })
+
+const resetForm = () => {
+  // 注意:不能用 Object.assign(form, JSON.parse(JSON.stringify(defaultForm))),
+  // 因为 JSON.stringify 会丢弃值为 undefined 的字段(如 id / elderId),
+  // 导致 Object.assign 不会覆盖它们,编辑→新增切换时 form.id 残留引发:
+  //   1. Dialog 标题误判为「编辑」
+  //   2. selectElder :disabled="!!form.id" 锁住,长者选不了
+  //   3. 残留值被提交出去
+  // 这里逐字段显式重置,最稳。
+  form.id = undefined
+  form.elderId = undefined
+  form.elderName = ''
+  form.gender = ''
+  form.genderName = ''
+  form.bedName = ''
+  form.nurseLevelName = ''
+  form.elderAge = ''
+  form.entries = [emptyEntry()]
+  formRef.value?.clearValidate()
+}
+
+const handleClosed = () => {
+  resetForm()
+  dialogVisible.value = false
+}
+
+/** 新增 / 删除医嘱条目 */
+const handleAddEntry = () => {
+  form.entries.push(emptyEntry())
+}
+const handleRemoveEntry = (idx: number) => {
+  if (form.entries.length <= 1) {
+    message.warning('至少保留一条医嘱')
+    return
+  }
+  form.entries.splice(idx, 1)
+}
+
+/** 从详情页打开打印 */
+const handlePrint = () => {
+  printRef.value?.open({ id: form.id })
+}
+
+const emit = defineEmits(['success'])
+
+const submitForm = async () => {
+  if (formLoading.value) return
+  const valid = await formRef.value?.validate().catch(() => false)
+  if (!valid) return
+  formLoading.value = true
+  try {
+    const payload: ShortDoctorOrderForm = {
+      id: form.id,
+      elderId: form.elderId,
+      entries: form.entries.map((e) => ({ ...e }))
+    }
+    if (form.id) {
+      await updateShortDoctorOrder(payload)
+    } else {
+      await createShortDoctorOrder(payload)
+    }
+    message.success(t(form.id ? 'common.updateSuccess' : 'common.createSuccess'))
+    emit('success')
+    handleClosed()
+  } finally {
+    setTimeout(() => {
+      formLoading.value = false
+    }, 300)
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.short-doctor-order-dialog :deep(.el-form-item) {
+  margin-bottom: 18px;
+}
+.empty-tip {
+  text-align: center;
+  color: #999;
+  padding: 24px 0;
+  font-size: 14px;
+  border: 1px dashed #ddd;
+  margin-top: 8px;
+}
+</style>

+ 311 - 0
src/views/elderly/elder/doctor-orders/short-doctor-order/Print.vue

@@ -0,0 +1,311 @@
+<template>
+  <Dialog v-model="dialogVisible" title="临时医嘱单" width="95%" scroll>
+    <div class="short-order-print" id="shortOrderPrintArea" v-loading="loading">
+      <h2 class="doc-title">临时医嘱单</h2>
+
+      <!-- 基本信息 -->
+      <div class="info-row">
+        <div class="info-cell"><span class="lbl">姓名:</span>{{ form.elderName || '' }}</div>
+        <div class="info-cell"><span class="lbl">性别:</span>{{ genderText }}</div>
+        <div class="info-cell"><span class="lbl">年龄:</span>{{ form.elderAge || '' }}</div>
+        <div class="info-cell"><span class="lbl">床号:</span>{{ form.bedName || '' }}</div>
+        <div class="info-cell">
+          <span class="lbl">护理等级:</span>{{ form.nurseLevelName || '' }}
+        </div>
+      </div>
+
+      <!-- 医嘱表(A4 竖版:弹填 dialog 95%,按 194mm 内容宽分配:
+           医嘱时间18/医嘱75/频次10/给药时间10/途径11/医生签名18/护士签名18/备注34
+           = 9.28/38.66/5.15/5.15/5.67/9.28/9.28/17.53 % ) -->
+      <table class="order-table">
+        <colgroup>
+          <col style="width: 9.28%" />
+          <col style="width: 38.66%" />
+          <col style="width: 5.15%" />
+          <col style="width: 5.15%" />
+          <col style="width: 5.67%" />
+          <col style="width: 9.28%" />
+          <col style="width: 9.28%" />
+          <col style="width: 17.53%" />
+        </colgroup>
+        <thead>
+          <tr>
+            <th>医嘱时间</th>
+            <th>医嘱</th>
+            <th>频次</th>
+            <th>给药时间</th>
+            <th>途径</th>
+            <th>医生<br />签名</th>
+            <th>护士<br />签名</th>
+            <th>备注</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-for="(item, idx) in paddedEntries" :key="idx">
+            <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">{{ getMedCn(MED_FREQUENCY_OPTIONS, item.frequency) }}</span>
+            </td>
+            <td>
+              <span class="cell-value">{{ getMedCn(MED_TIME_OPTIONS, item.medTime) }}</span>
+            </td>
+            <td>
+              <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 pre">{{ item.remark || '' }}</span>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+
+    <template #footer>
+      <el-button @click="handleClosed">关闭</el-button>
+      <el-button type="primary" v-print="print">打印</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script lang="ts" setup>
+import {
+  getShortDoctorOrder,
+  getMedCn,
+  MED_FREQUENCY_OPTIONS,
+  MED_TIME_OPTIONS,
+  MED_ROUTE_OPTIONS,
+  type ShortDoctorOrderEntry
+} from '@/api/elderly/elder/bed-change'
+import { getDictLabel, DICT_TYPE } from '@/utils/dict'
+
+defineOptions({ name: 'ShortDoctorOrderPrint' })
+
+const dialogVisible = ref(false)
+const loading = ref(false)
+
+const defaultForm = {
+  id: undefined,
+  elderId: undefined,
+  elderName: '',
+  gender: '',
+  elderAge: '',
+  bedName: '',
+  nurseLevelName: '',
+  entries: [] as ShortDoctorOrderEntry[]
+}
+const form = reactive({ ...defaultForm })
+
+const genderText = computed(() =>
+  form.gender === '' || form.gender === undefined || form.gender === null
+    ? ''
+    : getDictLabel(DICT_TYPE.SYSTEM_USER_SEX, form.gender)
+)
+
+/** 打印页最少展示 6 行,不足补空行(与截图默认空白行一致) */
+const MIN_ROWS = 6
+const paddedEntries = computed<ShortDoctorOrderEntry[]>(() => {
+  const list = form.entries || []
+  const padding: ShortDoctorOrderEntry[] = Array.from(
+    { length: Math.max(0, MIN_ROWS - list.length) },
+    () => ({
+      orderTime: '',
+      orderContent: '',
+      frequency: '',
+      medTime: '',
+      route: '',
+      doctorSign: '',
+      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 位
+ * - 数字时间戳按本地时区解析
+ * - 空值/无效返回空串
+ */
+const formatFullTime = (val?: any): string => {
+  if (val === null || val === undefined || val === '') return ''
+  if (typeof val === 'string') {
+    const m = val.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/)
+    if (m) return `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}`
+    return val
+  }
+  if (typeof val === 'number') {
+    const d = new Date(val)
+    if (isNaN(d.getTime())) return ''
+    const pad = (n: number) => String(n).padStart(2, '0')
+    return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
+  }
+  return ''
+}
+
+/** 打开打印(详情页) */
+const open = async (row: any = {}) => {
+  dialogVisible.value = true
+  loading.value = true
+  try {
+    Object.assign(form, defaultForm, { entries: [] })
+    if (row?.id) {
+      const data: any = await getShortDoctorOrder(row.id)
+      Object.assign(form, defaultForm, data)
+      form.entries = (data.entries || []) as ShortDoctorOrderEntry[]
+    }
+  } finally {
+    loading.value = false
+  }
+}
+defineExpose({ open })
+
+const handleClosed = () => {
+  dialogVisible.value = false
+  Object.assign(form, defaultForm, { entries: [] })
+}
+
+// 打印配置(vue3-print-nb)
+const print = {
+  id: 'shortOrderPrintArea',
+  popTitle: '临时医嘱单',
+  extraCss: '',
+  preview: false,
+  zIndex: 20002
+}
+</script>
+
+<style lang="scss" scoped>
+.short-order-print {
+  padding: 4px 0;
+  color: #000;
+  background: #fff;
+  /* 屏幕预览弹填 dialog(width:100%),左侧距 dialog 内壁 4px,右贴边。
+     打印时 @page A4 portrait 纸张可用 ~194mm,容器按其自动调整。 */
+  width: 100%;
+  margin: 0;
+  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 #555;
+      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;
+    }
+  }
+}
+</style>
+
+<!--
+  @page 必须 un-scoped(CSS 规范限制),单独放一个 style 块。
+  A4 竖版 210×297mm,纸张方向 + 边距由浏览器根据此规则应用到打印渲染。
+  - thead { display: table-header-group }:浏览器自动在每页重复表头
+  - tr { page-break-inside: avoid }:单条医嘱行不被切断
+  - 表格整体允许跨页(默认行为)
+-->
+<style lang="scss">
+@media print {
+  @page {
+    size: A4 portrait;
+    margin: 8mm;
+  }
+  .short-order-print .order-table thead {
+    display: table-header-group;
+  }
+  .short-order-print .order-table tr {
+    page-break-inside: avoid;
+  }
+}
+</style>

+ 44 - 0
src/views/elderly/elder/doctor-orders/short-doctor-order/columns.ts

@@ -0,0 +1,44 @@
+import { DICT_TYPE } from '@/utils/dict'
+
+// ==================临时医嘱单====================
+export const ShortDoctorOrderColumns = reactive([
+  {
+    label: '长者姓名',
+    field: 'elderName',
+    width: 120
+  },
+  {
+    label: '性别',
+    field: 'gender',
+    type: '1',
+    dictArr: DICT_TYPE.SYSTEM_USER_SEX,
+    width: 80
+  },
+  {
+    label: '床位号',
+    field: 'bedName',
+    width: 180
+  },
+  {
+    label: '护理等级',
+    field: 'nurseLevelName',
+    width: 120
+  },
+  {
+    label: '医嘱条数',
+    field: 'entryCount',
+    width: 100
+  },
+  {
+    label: '最新医嘱时间',
+    field: 'latestOrderTime',
+    format: true,
+    width: 170
+  },
+  {
+    label: '创建时间',
+    field: 'createTime',
+    format: true,
+    width: 170
+  }
+])

+ 235 - 0
src/views/elderly/elder/doctor-orders/short-doctor-order/index.vue

@@ -0,0 +1,235 @@
+<template>
+  <!-- 搜索 -->
+  <ContentWrap>
+    <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
+          @keyup.enter="handleQuery"
+          v-model="queryParams.elderName"
+          placeholder="请输入长者姓名"
+          class="!w-240px"
+          clearable
+        />
+      </el-form-item>
+      <el-form-item label="医嘱时间" prop="orderTime">
+        <el-date-picker
+          style="width: 240px"
+          v-model="queryParams.orderTime"
+          type="daterange"
+          :clearable="true"
+          placeholder="选择医嘱时间范围"
+          value-format="YYYY-MM-DD"
+          format="YYYY-MM-DD"
+          date-format="YYYY-MM-DD"
+        />
+      </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-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <TabBarBtn @add="openForm()" />
+    <Table2
+      v-loading="loading"
+      :data="list"
+      :columns="ShortDoctorOrderColumns"
+      :queryParams="queryParams"
+      :op-width="240"
+      :show-edit-condition="() => false"
+      :show-delete-condition="() => false"
+      :show-detail-condition="() => false"
+      @del="handleDelete"
+    >
+      <template #next="{ scope }">
+        <el-button
+          link
+          type="primary"
+          @click="openForm(scope)"
+          v-hasPermi="['short-doctor-order:edit']"
+          >编辑</el-button
+        >
+        <el-button
+          link
+          type="danger"
+          @click="handleDelete(scope.id)"
+          v-hasPermi="['short-doctor-order:delete']"
+          >删除</el-button
+        >
+        <el-button link type="primary" @click="openPrint(scope)">打印</el-button>
+      </template>
+    </Table2>
+
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 新增 / 编辑弹窗 -->
+  <Form ref="formRef" @success="getList" />
+
+  <!-- 打印(详情页) -->
+  <Print ref="printRef" />
+</template>
+
+<script lang="ts" setup>
+import {
+  getShortDoctorOrderPage,
+  deleteShortDoctorOrder
+} from '@/api/elderly/elder/bed-change'
+import { ShortDoctorOrderColumns } from './columns'
+import Form from './Form.vue'
+import Print from './Print.vue'
+import { useUserStore } from '@/store/modules/user'
+
+defineOptions({ name: 'ShortDoctorOrder' })
+
+const userStore = useUserStore()
+const message = useMessage()
+const { t } = useI18n()
+
+const loading = ref(true)
+const total = ref(0)
+const list = ref<any[]>([])
+
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  elderName: undefined as string | undefined,
+  orderTime: [] as string[] | undefined,
+  tenantIds: userStore.orgTenantId as any
+})
+const queryFormRef = ref()
+
+/** 测试假数据:列表请求失败时填充 */
+const buildMockShortDoctorOrders = () => {
+  const base = [
+    {
+      id: 2001,
+      elderId: 901,
+      elderName: '陈金凤',
+      gender: 2,
+      genderName: '女',
+      bedName: '1号楼-205-01',
+      nurseLevelName: '二级护理',
+      entryCount: 3,
+      latestOrderTime: '2026-08-22 10:15:00',
+      latestExecuteTime: '2026-08-22 10:30:00',
+      createTime: '2026-08-22 10:15:00',
+      tenantId: queryParams.tenantIds
+    },
+    {
+      id: 2002,
+      elderId: 902,
+      elderName: '孙志远',
+      gender: 1,
+      genderName: '男',
+      bedName: '2号楼-310-02',
+      nurseLevelName: '一级护理',
+      entryCount: 5,
+      latestOrderTime: '2026-08-23 14:00:00',
+      latestExecuteTime: '2026-08-23 14:25:00',
+      createTime: '2026-08-23 14:00:00',
+      tenantId: queryParams.tenantIds
+    }
+  ]
+  return base
+}
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const params: Recordable = {
+      pageNo: queryParams.pageNo,
+      pageSize: queryParams.pageSize,
+      elderName: queryParams.elderName || undefined,
+      tenantIds: queryParams.tenantIds
+    }
+    if (Array.isArray(queryParams.orderTime) && queryParams.orderTime.length === 2) {
+      params.beginTime = queryParams.orderTime[0]
+      params.endTime = queryParams.orderTime[1]
+    }
+    const data = await getShortDoctorOrderPage(params)
+    list.value = data.list || []
+    total.value = data.total || 0
+  } catch (e) {
+    console.warn('[临时医嘱单] 列表请求失败,已使用测试假数据。', e)
+    list.value = buildMockShortDoctorOrders()
+    total.value = list.value.length
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索 */
+const handleQuery = async () => {
+  await queryFormRef.value?.validate().catch(() => {})
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置 */
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  queryParams.orderTime = []
+  handleQuery()
+}
+
+/** 新增 / 编辑 */
+const formRef = ref()
+const printRef = ref()
+const openForm = (row: any = {}) => {
+  if (
+    !queryParams.tenantIds ||
+    (Array.isArray(queryParams.tenantIds) && queryParams.tenantIds.length === 0) ||
+    (Array.isArray(queryParams.tenantIds) && queryParams.tenantIds.length > 1 && !row.tenantId)
+  ) {
+    message.error('新增只能选择一个机构')
+    return
+  }
+  formRef.value.open(row)
+}
+
+/** 打印 */
+const openPrint = (row: any = {}) => {
+  printRef.value.open(row)
+}
+
+/** 删除 */
+const handleDelete = async (id: number) => {
+  try {
+    await message.confirm('确定删除该临时医嘱单吗?删除后不可恢复。', '提示')
+    await deleteShortDoctorOrder(id)
+    message.success(t('common.delSuccess'))
+    await getList()
+  } catch {}
+}
+
+/** 路由携带 elderName 时自动带入 */
+const route = useRoute()
+onMounted(() => {
+  if (route.query && route.query.elderName) {
+    queryParams.elderName = route.query.elderName as string
+  }
+  getList()
+})
+</script>

+ 18 - 0
src/views/elderly/elder/elder-file/Detail.vue

@@ -128,6 +128,7 @@
     @import-card="importCardFun"
     :title="'床头卡预览'"
     :width="'1080px'"
+    @edit="handleEdit"
     @close="handleDialogClose"
   />
   <!-- 分享卡弹窗组件 -->
@@ -150,6 +151,8 @@
     :width="'600px'"
     @close="handleDialogClose"
   />
+
+  <Form ref="formRefE" @success="getElderInfoById(idE)" />
 </template>
 <script setup lang="ts">
 import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
@@ -179,8 +182,10 @@ import { formatTime } from '@/utils'
 
 import { getElderHealthRecordById } from '@/api/elderly/elder/health-record'
 import { getDictLabel, DICT_TYPE } from '@/utils/dict'
+import Form from '@/views/elderly/elder/health-record/Form.vue'
 defineOptions({ name: 'ElderFileDetail' })
 const formRef = ref() // 表单 Ref
+const formRefE = ref() // 表单 Ref
 const bed = ref() // 表单 Ref
 const dataForm = ref<{
   elderName?: string
@@ -281,8 +286,10 @@ const routeArr = [
 
 const route = useRoute()
 const healthRecord = ref({})
+let idE = undefined
 onMounted(async () => {
   const { id } = route.params
+  idE = id
   activeName.value = 1
   // 获取长者详情
   try {
@@ -342,6 +349,17 @@ const showCardDoor = () => {
 const handleDialogClose = () => {
   console.log('床头卡弹窗已关闭')
 }
+const handleEdit = () => {
+  // 先显式关闭床头卡弹窗,再等关闭动画结束(约 300ms)后再跳转。
+  // 否则 el-dialog 的关闭动画会被 <keep-alive> 的路由切换打断,
+  // 弹窗内部动画状态会卡住,导致返回后再点关闭预览关不掉(log 仍会打)。
+  dialogVisible.value = false
+  setTimeout(() => {
+    router.push({
+      path: '/trend-charts/health-record'
+    })
+  }, 350)
+}
 
 const importCardFun = () => {
   importCard(healthRecord.value, dataForm.value)

+ 1 - 0
src/views/elderly/elder/health-record/Form.vue

@@ -1025,6 +1025,7 @@ const {
 const open = async (row, elderId, detail) => {
   dialogVisible.value = true
   isDetail.value = detail
+  console.log(row,elderId,detail)
   if (row.id) {
     try {
       const res = await getElderHealthRecordById(elderId)

+ 59 - 2
src/views/elderly/fee/bill-pay/preview.vue

@@ -84,6 +84,10 @@
               </tr> -->
             </table>
           </div>
+          <!-- 机构签章:叠在表格右下角,旋转 -45° -->
+          <div v-if="sealUrl" class="seal-overlay">
+            <img :src="sealUrl" class="seal-img" alt="机构公章" />
+          </div>
         </div>
       </el-tab-pane>
       <el-tab-pane label="打印类型B(不含固定费明细)" name="second">
@@ -149,6 +153,10 @@
               </tr>
             </table>
           </div>
+          <!-- 机构签章:叠在表格右下角,旋转 -45° -->
+          <div v-if="sealUrl" class="seal-overlay">
+            <img :src="sealUrl" class="seal-img" alt="机构公章" />
+          </div>
         </div>
       </el-tab-pane>
     </el-tabs>
@@ -163,6 +171,8 @@
 import { getExpenseOrderDetail } from '@/api/elderly/fee/bill-pay'
 import dayjs from 'dayjs'
 import { useUserStore } from '@/store/modules/user'
+import { getTenant } from '@/api/system/tenant'
+import { getTenantId } from '@/utils/auth'
 import {
   fixedNum,
   formatCeil,
@@ -202,6 +212,9 @@ const longTermAmount = ref(0)
 const payText = ref('')
 const roundDown = ref(0)
 
+// 当前机构公章(来自租户接口 expenseCachetUrl);为空字符串时签章区不渲染
+const sealUrl = ref('')
+
 const formatPayCreatedTime = (createdTime) => {
   if (!createdTime) return ''
   const timestamp = String(createdTime).length === 10 ? Number(createdTime) * 1000 : Number(createdTime)
@@ -211,7 +224,12 @@ const formatPayCreatedTime = (createdTime) => {
 /** 打开弹窗 */
 const open = async (id, row) => {
   dialogVisible.value = true
-  const res = await getExpenseOrderDetail(id)
+  // 并行:账单详情 + 当前机构公章(容错,租户接口失败不影响主流程)
+  const [res, tenant] = await Promise.all([
+    getExpenseOrderDetail(id),
+    getTenant(getTenantId() as number).catch(() => null)
+  ])
+  sealUrl.value = tenant?.expenseCachetUrl || ''
   let flag = false
   dataForm.value = res
   res.items = res.items.filter((item) => item.isShow == 1)
@@ -362,6 +380,7 @@ const handleClosed = () => {
   payText.value = ''
   activeName.value = 'first'
   longTermAmount.value = 0
+  sealUrl.value = ''
   dialogVisible.value = false
 }
 
@@ -639,13 +658,18 @@ const formatStr = (dateStr) => {
   return ''
 }
 
+// 打印样式:与预览样式保持一致
+// - .first/.second 加 padding-bottom 给签章留出空间,否则签章会贴到纸张边缘或被外框裁掉
+// - 取消容器滚动限制(v-print 复制到 iframe 时容易继承 height/overflow 限制导致内容被裁)
+// - 同步印章位置:right: 5mm / bottom: 8mm,让印章向下偏移并完整可见
 const printExtraCss = `
   html, body { height: auto !important; overflow: visible !important; }
-  #printTab1, #printTab2 { height: auto !important; overflow: visible !important; }
+  #printTab1, #printTab2 { height: auto !important; overflow: visible !important; padding-bottom: 12mm !important; }
   table { page-break-inside: auto; width: 100%; border-collapse: collapse; }
   tr { page-break-inside: avoid; page-break-after: auto; }
   thead { display: table-header-group; }
   tfoot { display: table-footer-group; }
+  .seal-overlay { right: 5mm !important; bottom: 0mm !important; }
 `
 // 打印
 const printTab1 = {
@@ -709,6 +733,14 @@ const printTab2 = {
 }
 </script>
 <style lang="scss" scoped>
+.first,
+.second {
+  position: relative; // 让 .seal-overlay 相对本容器定位
+  // 给"向下偏移"的印章留出空间,避免被外层(dialog 滚动容器 / 打印纸张)裁掉
+  // 注意:打印样式 printExtraCss 同步设置 padding-bottom: 40mm,保证预览/打印一致
+  padding-bottom: 12mm;
+}
+
 .first {
   .header {
     .title {
@@ -765,4 +797,29 @@ const printTab2 = {
 .pay-text-cell {
   white-space: pre-line;
 }
+
+// 机构签章:绝对定位叠在表格右下角,旋转 -45°
+// - 位置:right: 5mm / bottom: 8mm,让印章向下偏移并在 .first 容器的 padding-bottom 区域内完整可见
+//   (之前 bottom: -10mm 会让印章底边超出容器底边,被 dialog 滚动容器或打印纸张裁切)
+// - z-index: 20 + 父级 position: relative 保证印章叠在表格之上
+// - pointer-events: none 不挡表格交互(hover/点击备注等不受影响)
+// - opacity: 0.85 略透,不完全遮住备注列文字
+.seal-overlay {
+  position: absolute;
+  right: 5mm;
+  bottom: 0mm;
+  width: 30mm;
+  height: 30mm;
+  z-index: 20;
+  pointer-events: none;
+
+  .seal-img {
+    width: 100%;
+    height: 100%;
+    object-fit: contain;
+    transform: rotate(0deg);
+    transform-origin: 100% 100%;
+    opacity: 0.85;
+  }
+}
 </style>

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

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

+ 151 - 0
src/views/system/official-seal/index.vue

@@ -0,0 +1,151 @@
+<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>
+
+      <div class="seal-box">
+        <UploadImg
+          v-model="sealUrl"
+          v-model:fileName="fileName"
+          :width="'220px'"
+          :height="'220px'"
+          :show-delete="false"
+        />
+      </div>
+
+      <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>