yxh
yxh
5 天以前 09dc74e264b6262cbe41c9c8476e24705871d4a6
yxh
已修改25个文件
1680 ■■■■■ 文件已修改
src/api/ecg/appointment/index.ts 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/ecg/room/index.ts 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/DoctorPageLayout/src/DoctorPageLayout.vue 166 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/RoomBedSelect/src/RoomBedSelect.vue 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/layout/components/CurrentRoomTag.vue 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/permission.ts 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/modules/room.ts 216 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/modules/user.ts 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/styles/index.scss 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/releaseBed.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/appointment/AppointmentForm.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/appointment/CheckItemPanel.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/appointment/index.vue 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/callingscreen/bigscreen.vue 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/checktype/QueueSequenceForm.vue 112 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/doctor/DevDataEntry.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/doctor/DevDismantle.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/doctor/Routine.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/doctor/components/BedSwitchSelect.vue 208 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/doctor/components/DevInstallPanel.vue 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/doctor/components/DevReadyPanel.vue 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/doctor/components/PatientInfoBlock.vue 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/doctor/components/RoutinePanel.vue 438 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/room/RoomLoginSelect.vue 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/room/RoomSetting.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/ecg/appointment/index.ts
@@ -26,6 +26,11 @@
  bookCheckType: number // 预约检查项目
  paid: number //已付款
  bookSeqNum: number
  /**
   * 【本需求】非当天预约签到的"已确认"标记。
   * 前端弹窗确认后置 true 传给后端:后端据此放行非当天预约,并按**预留号**取号。
   */
  forceCheckIn?: boolean
  // ========== 以下字段后端 AppointmentRespVO 一直有返回,此前 TS 未声明 ==========
  // 需求1 使用:状态 / 开单医师 / 开单日期 / 距今天数(由 doctorTime 计算)/ 就诊卡号
  status?: string // 状态(字典 ecg_status)
@@ -44,11 +49,26 @@
  createTime?: Date | number | string // 创建时间
}
/**
 * 【本需求】预约记录页的「预约日期」用日期范围选择器,前端字段是 `bookDateRange`(['2026-11-01','2026-11-30']),
 * 而后端接收的是 `bookDateStart` / `bookDateEnd`(含首尾两天)。
 * 这里统一转换,避免每个调用点各写一遍:分页、导出都走它。
 */
const withBookDateRange = (params: any) => {
  if (!params) return params
  const { bookDateRange, ...rest } = params
  if (Array.isArray(bookDateRange) && bookDateRange.length === 2 && bookDateRange[0] && bookDateRange[1]) {
    rest.bookDateStart = `${bookDateRange[0]} 00:00:00`
    rest.bookDateEnd = `${bookDateRange[1]} 23:59:59`
  }
  return rest
}
// 预约 API
export const AppointmentApi = {
  // 查询预约分页
  getAppointmentPage: async (params: any) => {
    return await request.get({ url: `/ecg/appointment/page`, params })
    return await request.get({ url: `/ecg/appointment/page`, params: withBookDateRange(params) })
  },
  // 查询预约详情
@@ -69,6 +89,20 @@
  // 从HIS查询预约或开单信息
  queryAppointmentFromHis: async (patId: string) => {
    return await request.get({ url: `/ecg/appointment/query-his-by-patient?patId=` + patId })
  },
  /**
   * 【本需求:紧急患者不排队直接检查】按「申请单号 / 就诊号 / 患者编号」查近一个月(±30 天)的预约记录。
   * 结果按预约时间倒序;`checkTypes` 为本窗口开展的检查项目(逗号分隔,空则不限)。
   */
  getEmergencyCandidates: async (code: string, checkTypes?: number[]) => {
    const types = Array.isArray(checkTypes) && checkTypes.length > 0 ? checkTypes.join(',') : ''
    return await request.get({
      url:
        `/ecg/appointment/emergency-candidates?code=` +
        encodeURIComponent(code) +
        (types ? `&checkTypes=${types}` : '')
    })
  },
  // 新增预约
@@ -92,7 +126,7 @@
  // 导出预约 Excel
  exportAppointment: async (params) => {
    return await request.download({ url: `/ecg/appointment/export-excel`, params })
    return await request.download({ url: `/ecg/appointment/export-excel`, params: withBookDateRange(params) })
  },
  // 预约确认
src/api/ecg/room/index.ts
@@ -85,7 +85,10 @@
export interface MonitorInfo {
  openingBedNum : number
  /** 在座工位数(一人多工位时会大于医生数) */
  docBedNum : number
  /** 【本需求·统计按人合并】在座医生数(按 doc_id 去重) */
  docNum : number
  routingBedNum : number
  openingFlag : number
  checkTypeBedInfo: object
@@ -187,18 +190,34 @@
   *
   * 相比原来「getIpBed → bindIpToBed → bedDoctorOn」三步拼装,这里只失败一次即可排查:
   * 返回体带 clientIp 与 reason(ip_invalid / nomatch / multi / occupied / error)。
   * 服务端同时保证:一个用户只占一个工位(入座成功后释放其其它工位)。
   * 服务端保证:命中"我的工位"时幂等恢复;不会重复新增(一人多工位由前端切换)。
   */
  autoSeat: async () => {
    return await request.post({ url: `/clinic/room/auto-seat` })
  },
  /** 查询当前登录人已入座的工位(系统顶栏"当前工位"标签用) */
  /** 查询当前登录人已入座的工位(系统顶栏"当前工位"标签用,单值;多工位请用 getMySeats) */
  getMySeat: async () => {
    return await request.get({ url: `/clinic/room/my-seat` })
  },
  /**
   * 【本需求·一人多工位】查询当前登录人占据的**全部**工位(节假日值班多岗位)。
   * 返回数组,按 roomId + bedNo 升序;未占工位返回空数组。
   */
  getMySeats: async () => {
    return await request.get({ url: `/clinic/room/my-seats` })
  },
  /**
   * 【本需求·接班自助接管】把该工位从"别人占用"改为"我占用"。
   * 后端:审计日志 → 强制原占用人离座 → 立即让我入座(同一事务)。
   */
  takeOverBed: async (params: { roomId: number | null; bedNo: string | null }) => {
    return await request.post({ url: `/clinic/room/take-over-bed`, params })
  },
  /**
   * 客户端 IP 识别诊断:返回各代理头、直连地址、规范化结果、是否服务器自身地址、服务器网卡列表与结论。
   * 现场排查"工位绑定的 IP 不对"用(选工位页的「IP 诊断」按钮)。
   */
src/components/DoctorPageLayout/src/DoctorPageLayout.vue
@@ -38,7 +38,7 @@
    footer   底部功能按钮(可选;固定在页面底部)
-->
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import BedSwitchSelect from '@/views/ecg/doctor/components/BedSwitchSelect.vue'
import { useRoomStore } from '@/store/modules/room'
@@ -47,6 +47,47 @@
/* ==================== 主/围观(只读模式) ==================== */
const roomStore = useRoomStore()
/* ==================== 显示密度(分辨率适配) ==================== */
/**
 * 【为什么是"密度"而不是"整体缩放"】
 * 曾考虑用 `document.body.style.zoom = 0.9` 做整体缩放,**实测不可行**:
 *   ① CSS zoom **不会改变媒体查询**(zoom:1.25 时 innerWidth 仍是 1342、
 *      `(min-width:1200px)` 仍命中)⇒ 栅格不会随之降级,"缩小"反而让字段更挤;
 *   ② CSS zoom 会让 Element Plus 的下拉/弹窗定位错位 —— 它们靠
 *      getBoundingClientRect + translate 计算位置,rect 已含缩放倍率,
 *      写回的 px 又在缩放坐标系里,等于被放大两次。
 *
 * 所以改为**只压缩间距**(纯 CSS,零定位风险):表单行距、表格单元格内边距、
 * 底栏内边距。矮屏能多显示几行记录,视觉上等效于"缩放",但不破坏任何弹层。
 *
 * 真·整体缩放请用**浏览器自带缩放**(Ctrl + / Ctrl - / Ctrl 0):
 * 栅格已做响应式,浏览器缩放会改变 innerWidth,从而自动在 4/2/1 列之间切换。
 */
const DENSITY_KEY = 'ecg.doctor.density'
type Density = 'normal' | 'compact'
const density = ref<Density>('normal')
const isCompact = computed(() => density.value === 'compact')
const loadDensity = () => {
  try {
    const v = localStorage.getItem(DENSITY_KEY)
    if (v === 'compact' || v === 'normal') density.value = v
  } catch {
    /* 隐私模式/禁用存储时忽略 */
  }
}
// 记住选择(用 watch 而不是 el-select 的 @change:避免 EP 回调参数类型与联合类型不匹配)
watch(density, (v) => {
  try {
    localStorage.setItem(DENSITY_KEY, v)
  } catch {
    /* 隐私模式/禁用存储时忽略 */
  }
})
// 在 setup 阶段就读取,避免首帧从"标准"跳到"紧凑"的闪动
loadDensity()
/**
 * 是否显示顶部提示条。
@@ -94,11 +135,33 @@
  }
)
/** 默认宽度(px):由 asideWidth 解析而来,用作初始值与"双击复位"值 */
const defaultAsidePx = (() => {
/**
 * 「默认」右栏宽度(px):**按窗口宽度自适应**。
 *
 * 背景:原来固定 400px。医生电脑分辨率差异大(1920 / 1366 / 1024,且常带 125% 缩放),
 * 窄屏上右栏吃掉 400px 后,左侧患者信息(4 列栅格,每列要 100px 标签)只剩几百像素,
 * 字段被压成一条缝 —— 现场表现为"部分内容看不到"。
 *
 * 规则:`min(配置值, 窗口宽 × 28%)`,且不低于 minAsideWidth。
 *   1920 → 400(与原来一致,宽屏无变化)
 *   1600 → 400
 *   1366 → 382
 *   1280 → 358
 *   1024 → 287
 */
const calcDefaultAsidePx = () => {
  const n = parseInt(props.asideWidth, 10)
  return Number.isFinite(n) && n > 0 ? n : 400
})()
  const base = Number.isFinite(n) && n > 0 ? n : 400
  const vw = typeof window !== 'undefined' ? window.innerWidth : 1920
  return Math.max(props.minAsideWidth, Math.min(base, Math.round(vw * 0.28)))
}
/**
 * 医生是否**手动**调过宽度(拖过分界线 / 用键盘调过 / 有记忆值)。
 * 手动调过 → 尊重他的选择,窗口变化时只做"夹紧";
 * 没调过 → 跟随窗口自适应(换到小屏自动收窄)。
 */
const asideUserSet = ref(false)
/**
 * 【关键】不依赖 CSS `height: 100%` 的解析,改为**实测父容器可用高度**。
@@ -125,8 +188,8 @@
/** 主体行容器:拖动时用它算"右侧栏最多能有多宽" */
const bodyRef = ref<HTMLElement>()
/** 右侧栏当前宽度(px) */
const asidePx = ref<number>(defaultAsidePx)
/** 右侧栏当前宽度(px):初始值也走自适应,窄屏不会一上来就占掉 400px */
const asidePx = ref<number>(calcDefaultAsidePx())
/** 是否正在拖动(用于换光标、禁选中) */
const dragging = ref(false)
@@ -149,6 +212,7 @@
/** 记住拖动结果(隐私模式/禁用存储时静默忽略) */
const saveAsidePx = () => {
  asideUserSet.value = true
  if (!props.storageKey) return
  try {
    localStorage.setItem(props.storageKey, String(asidePx.value))
@@ -162,15 +226,19 @@
  if (!props.storageKey) return
  try {
    const v = parseInt(localStorage.getItem(props.storageKey) || '', 10)
    if (Number.isFinite(v) && v > 0) asidePx.value = clampAsidePx(v)
    if (Number.isFinite(v) && v > 0) {
      asidePx.value = clampAsidePx(v)
      asideUserSet.value = true // 有记忆值 = 医生手动调过,后续不再跟随窗口自适应
    }
  } catch {
    /* ignore */
  }
}
/** 双击分界线:恢复默认宽度并清除记忆 */
/** 双击分界线:恢复默认宽度(重新按当前窗口自适应)并清除记忆 */
const resetAsideWidth = () => {
  asidePx.value = clampAsidePx(defaultAsidePx)
  asideUserSet.value = false
  asidePx.value = clampAsidePx(calcDefaultAsidePx())
  if (props.storageKey) {
    try {
      localStorage.removeItem(props.storageKey)
@@ -244,7 +312,12 @@
  // 宽度变化(含窗口缩放)时重新夹一次右栏宽度,
  // 否则拖宽后把窗口缩小,右侧栏可能把左侧内容挤没。
  asidePx.value = clampAsidePx(asidePx.value)
  //
  // 【分辨率自适应】没手动调过的(首次使用 / 双击复位过)→ 跟随窗口重新算默认宽度,
  // 这样同一位医生换到小屏笔记本上自动收窄;手动调过的只做夹紧,尊重他的选择。
  asidePx.value = asideUserSet.value
    ? clampAsidePx(asidePx.value)
    : clampAsidePx(calcDefaultAsidePx())
}
/** 用 rAF 合并连续触发,避免 ResizeObserver 循环告警 */
@@ -280,7 +353,7 @@
  <div
    ref="rootRef"
    class="doctor-page"
    :class="{ 'is-resizing': dragging }"
    :class="{ 'is-resizing': dragging, 'is-compact': isCompact }"
    :style="{ height: measuredHeight }"
  >
    <!--
@@ -292,7 +365,22 @@
      <div class="doctor-page__header-left">
        <slot name="header"></slot>
      </div>
      <BedSwitchSelect :enabled="bedSwitchEnabled" :op-types="bedSwitchOpTypes" />
      <div class="doctor-page__header-tools">
        <!--
          【分辨率适配】显示密度:紧凑模式压缩行高/内边距,矮屏(768 高)
          能多显示几行记录。选择记在 localStorage,换页保持。
        -->
        <el-select
          v-model="density"
          size="small"
          class="doctor-page__density"
          title="显示密度:紧凑模式适合小屏幕/已放大缩放的电脑"
        >
          <el-option label="标准显示" value="normal" />
          <el-option label="紧凑显示" value="compact" />
        </el-select>
        <BedSwitchSelect :enabled="bedSwitchEnabled" :op-types="bedSwitchOpTypes" />
      </div>
    </div>
    <!-- 围观模式(只读)/ 未选工位 提示条:让医生一眼知道"现在只能看,不能操作" -->
@@ -395,6 +483,18 @@
  flex: 1;
  min-width: 0;
  overflow: hidden;
}
/* 右上角工具区:显示密度 + 工位切换(都固定靠右,不参与压缩) */
.doctor-page__header-tools {
  display: flex;
  flex: none;
  align-items: center;
  gap: 8px;
}
.doctor-page__density {
  width: 104px;
}
/* ==========================================================================
@@ -577,4 +677,44 @@
    margin-left: 0;
  }
}
/* ==========================================================================
   【分辨率适配】紧凑显示模式
   --------------------------------------------------------------------------
   只压缩「间距」,不改字号 —— 默认(标准)**完全不受影响**,所有规则都挂在
   `.is-compact` 下,切回标准即恢复原样。
   针对的现场问题:768 高的笔记本(再叠加 125% 缩放)上,患者信息 + 已完成记录
   + 底部按钮加起来超过可用高度,记录列表只剩一两行。压缩后能把高度还给内容。
   ========================================================================== */
.doctor-page.is-compact {
  /* 患者信息 18 项用的是 EP 默认 18px 行距,是最大的高度浪费点 */
  :deep(.el-form-item) {
    margin-bottom: 4px;
  }
  /* 记录表格:单元格上下内边距 8px → 3px,行高收到 18px */
  :deep(.el-table .cell) {
    padding: 0 8px;
    line-height: 18px;
  }
  :deep(.el-table td.el-table__cell),
  :deep(.el-table th.el-table__cell) {
    padding: 3px 0;
  }
  .doctor-page__header {
    height: 36px;
  }
  .doctor-page__readonly-tip {
    padding: 2px 12px;
    line-height: 18px;
  }
  .doctor-page__footer {
    padding: 2px 0 2px 8px;
  }
}
</style>
src/components/RoomBedSelect/src/RoomBedSelect.vue
@@ -7,7 +7,7 @@
import openImg from '@/assets/room/open.jpg'
import doctorImg from '@/assets/room/desk-doctor.jpg'
const emit = defineEmits(['event-haveseat', 'event-leaveseat', 'event-setcurseat'])
const emit = defineEmits(['event-haveseat', 'event-leaveseat', 'event-setcurseat', 'event-takeover'])
defineComponent({
  name: 'RoomBedSelect'
@@ -57,6 +57,10 @@
}
const onSetCurSeatclick = (bed: RoomVO) => {
  emit('event-setcurseat', bed)
}
/** 【本需求·接班自助接管】把"别人占用"的工位接管过来(父组件负责二次确认与调接口) */
const onTakeOverclick = (bed: RoomVO) => {
  emit('event-takeover', bed)
}
</script>
@@ -110,18 +114,28 @@
    </div>
    <div class="bed-card__actions">
      <!-- 已被**别人**占用时禁用「入座」(不挤掉正在操作的人),并用 tooltip 说明原因 -->
      <!-- 已被**别人**占用时禁用「入座」(不挤掉正在操作的人),并用 tooltip 说明原因;
           同时提供「接管」——上一班医生下班没点离座时,接班医生可自助接管 -->
      <el-tooltip
        v-if="!isMine"
        :content="
          occupiedByOther
            ? `该工位由「${bed.docName || '其他医生'}」操作中,不能入座(不挤掉正在操作的人)`
            ? `该工位由「${bed.docName || '其他医生'}」操作中。若是上一班医生忘记离座,可点「接管」`
            : '入座到该工位'
        "
        placement="top"
      >
        <span>
          <el-button :disabled="occupiedByOther" @click="onHaveSeatclick(bed)"> 入座 </el-button>
          <!-- 【本需求·接班自助接管】仅"被他人占用"时出现;接管后对方会被强制离座 -->
          <el-button
            v-if="occupiedByOther"
            type="warning"
            plain
            @click="onTakeOverclick(bed)"
          >
            接管
          </el-button>
        </span>
      </el-tooltip>
      <template v-else>
src/layout/components/CurrentRoomTag.vue
@@ -30,19 +30,30 @@
})
const tagText = computed(() => {
  if (!hasView.value) return '未选择工位'
  if (canOperate.value) return `诊室:${roomStore.getViewText}`
  // 【本需求】顶栏统一显示「当前工位:+ 实际工位」,替代原来的「诊室:…」/「围观:…」前缀
  if (!hasView.value) return '当前工位:未选择'
  // 【本需求·一人多工位】占多个工位时补一个数量提示,便于知道"我还在别的岗位"
  const seatCount = roomStore.getMySeatCount
  const countSuffix = seatCount > 1 ? `(我的工位 ${seatCount} 个)` : ''
  if (canOperate.value) return `当前工位:${roomStore.getViewText}${countSuffix}`
  const owner = roomStore.viewOwnerName
  return `围观:${roomStore.getViewText}${owner ? `(主人 ${owner})` : '(只读)'}`
  return `当前工位:${roomStore.getViewText}(围观${owner ? `,主人 ${owner}` : ',只读'})`
})
const tooltip = computed(() => {
  if (!hasView.value) return '尚未选择工位,点击选择要查看/入座的工位'
  if (canOperate.value) return `当前工位:${roomStore.getViewText}(点击切换工位)`
  const seats = roomStore.getMySeats || []
  const seatListText =
    seats.length > 0
      ? '我的工位:' + seats.map((s) => `${s.roomName || '诊室' + (s.roomId ?? '')} · ${s.bedNo ?? ''}`).join('、')
      : ''
  if (canOperate.value) {
    return `当前工位:${roomStore.getViewText}(点击切换工位)` + (seatListText ? `;${seatListText}` : '')
  }
  const mine = roomStore.getSeatText
  return `围观模式(只读):正在查看「${roomStore.getViewText}」`
    + (roomStore.viewOwnerName ? `,主人 ${roomStore.viewOwnerName}` : '')
    + (mine ? `;我入座的工位:${mine}` : ';我还没有入座')
    + (seatListText ? `;${seatListText}` : (mine ? `;我入座的工位:${mine}` : ';我还没有入座'))
    + '。点击可切换工位'
})
src/permission.ts
@@ -55,7 +55,31 @@
    if (res.seated) {
      return true
    }
    // 【主/围观·本次需求】没自动入座也**不再强制跳"选工位页"**:
    // 【本需求】本机 IP 对应的工位**有人坐着**(上一班医生没退出工位)→ 提示是否接管。
    // 接管后立即成为该工位主人,并按现有逻辑自动加载该工位「就诊中」患者。
    if (res.reason === 'occupied' && res.roomId != null && res.bedNo) {
      const bedText = `${res.roomName || '诊室' + res.roomId} · ${res.bedNo}`
      try {
        await ElMessageBox.confirm(
          `本机对应的工位「${bedText}」当前由「${res.occupiedBy || '其他医生'}」占用。\n` +
            `是否接管该工位继续工作?(接管后对方将被强制离座)`,
          '工位被占用',
          { confirmButtonText: '接管工位', cancelButtonText: '先不接管(只读围观)', type: 'warning' }
        )
        const ok = await roomStore.takeOverBed(res.roomId, res.bedNo)
        if (ok) {
          ElMessage.success(`已接管工位:${bedText}`)
          return true
        }
        ElMessage.warning('接管失败,已按只读围观进入(可稍后在顶栏「工位」里再试)')
      } catch {
        // 用户选择不接管 → 按只读围观进入
        console.info('[room] 用户放弃接管工位,按只读围观进入')
      }
    }
    // 【主/围观】没自动入座也**不再强制跳"选工位页"**:
    // 允许以"未入座 / 围观"身份进入工作台(只读),由工作台顶部的「工位」下拉
    // 选择要查看(围观)或要入座的工位。若一律弹回选工位页,就永远无法围观别人。
    console.info('[room] 未自动入座(' + (res.reason || 'nomatch') + '),按"可围观"放行')
src/store/modules/room.ts
@@ -38,6 +38,10 @@
  reason?: 'nomatch' | 'occupied' | 'multi' | 'ip_invalid' | 'ip_is_server' | 'error'
  /** 被占用时的占用人,便于提示 */
  occupiedBy?: string | null
  /** 【本需求】被占用时命中的候选工位(供"是否接管"确认后直接调用 takeOverBed) */
  roomId?: number | null
  bedNo?: string | null
  roomName?: string | null
}
interface RoomInfoVO {
@@ -54,6 +58,13 @@
   * 两者相同 → 可操作;不同 → 只读围观。
   */
  currentSeat: RoomSeatResp | null
  /**
   * 【本需求·一人多工位】当前登录人占据的**全部**工位(节假日值班多岗位)。
   *
   * 与 `currentSeat` 的区别:`currentSeat` 只是"默认/首选取"的那一个(顶栏文案用),
   * `mySeats` 才是完整集合;`getCanOperate` 用它判断"当前查看的工位是否属于我"。
   */
  mySeats: RoomSeatResp[]
  /** 当前查看工位的主人的姓名(围观时用于提示"正在围观 X(主人 张三)") */
  viewOwnerName: string | null
  /** 当前查看的工位现在是否**无人操作**(主人已离座;只提示,不自动接管) */
@@ -81,6 +92,8 @@
    roomSelectMode: -1,
    // 当前入座的工位(顶栏显示;未入座为 null)
    currentSeat: null,
    // 【本需求·一人多工位】我占据的全部工位(未入座为空数组)
    mySeats: [],
    // 正在查看的工位的主人姓名(围观提示用)
    viewOwnerName: null,
    // 正在查看的工位现在是否无人操作(主人已离座 → 只提示,不自动接管)
@@ -98,16 +111,35 @@
    getCurrentSeat(): RoomSeatResp | null {
      return this.currentSeat
    },
    /** 【本需求·一人多工位】我占据的全部工位 */
    getMySeats(): RoomSeatResp[] {
      return this.mySeats || []
    },
    /** 【本需求·一人多工位】我占据的工位数量(顶栏提示"我的工位 N 个") */
    getMySeatCount(): number {
      return (this.mySeats || []).length
    },
    /**
     * 【主/围观】当前是否**可以操作业务**。
     *
     * 判定:我入座的工位 == 我正在看的工位(服务端另有一道硬校验:doc_id 必须是我)。
     * 为 false 时前端应禁用所有写操作按钮,界面显示"围观模式 · 只读"。
     * 判定(本需求改为"包含"):
     *   当前查看的工位 ∈ 我的工位列表 ⇒ 可操作(服务端另有一道硬校验:该工位 doc_id 必须是我)。
     * 一键多工位后,我不可能同时只在"一个"工位上,所以不能再用"相等"判断。
     * 兼容:`mySeats` 还没取到(旧接口/旧缓存)时回退到 `currentSeat` 相等判断。
     */
    getCanOperate(): boolean {
      const seat = this.currentSeat
      const view = this.room
      if (!seat || !seat.seated || !view || view.roomId == null || !view.bedNo) {
      if (!view || view.roomId == null || !view.bedNo) {
        return false
      }
      const seats = this.mySeats || []
      if (seats.length > 0) {
        return seats.some(
          (s) => String(s.roomId) === String(view.roomId) && String(s.bedNo) === String(view.bedNo)
        )
      }
      const seat = this.currentSeat
      if (!seat || !seat.seated) {
        return false
      }
      return String(seat.roomId) === String(view.roomId) && String(seat.bedNo) === String(view.bedNo)
@@ -166,6 +198,9 @@
      this.viewOwnerName = room.docName ?? null
      this.viewTargetVacant = false
      // 【本需求·一人多工位】把该工位并入「我的工位」(已存在则更新,不存在则新增)
      this.upsertMySeat(this.currentSeat)
      // 【修复】写入缓存(sessionStorage)。
      //
      // 原实现是 `const bedInfo = wsCache.get(...); if (bedInfo) { ...; wsCache.set(...) }`
@@ -176,7 +211,38 @@
      //     才启动取数定时器 ⇒ **页面一片空白,看起来像"没加载排队病人"**;
      //   · 角色为 doctor 的用户还会被路由守卫弹回 /roomselect 重新选诊室。
      // 这里改为**无条件写入**;恢复见文件末尾的 setupRoomStorePersistence()。
      wsCache.set(CACHE_KEY.ROOM, { isSetRoom: true, room: this.room, currentSeat: this.currentSeat })
      wsCache.set(CACHE_KEY.ROOM, {
        isSetRoom: true,
        room: this.room,
        currentSeat: this.currentSeat,
        mySeats: this.mySeats
      })
    },
    /** 【本需求·一人多工位】把一条工位并入 mySeats(按 roomId+bedNo 去重) */
    upsertMySeat(seat: RoomSeatResp | null) {
      if (!seat || seat.roomId == null || !seat.bedNo) return
      const list = (this.mySeats || []).filter(
        (s) => !(String(s.roomId) === String(seat.roomId) && String(s.bedNo) === String(seat.bedNo))
      )
      list.push(seat)
      this.mySeats = list
    },
    /** 【本需求·一人多工位】用后端返回的完整列表替换 mySeats,并同步 currentSeat */
    applyMySeats(seats: RoomSeatResp[]) {
      this.mySeats = Array.isArray(seats) ? seats : []
      if (this.mySeats.length === 0) {
        this.currentSeat = null
        return
      }
      // currentSeat:优先取"当前查看的工位"对应的那条,否则第一条
      const view = this.room
      const matched = this.mySeats.find(
        (s) =>
          null != view &&
          String(s.roomId) === String(view.roomId) &&
          String(s.bedNo) === String(view.bedNo)
      )
      this.currentSeat = matched ?? this.mySeats[0]
    },
    /**
     * 【主/围观】把"我正在看的工位"切到别人(或切回自己)——**不改我的入座工位**。
@@ -201,7 +267,12 @@
      this.isSetRoom = true
      this.viewOwnerName = ownerName ?? bed.docName ?? null
      this.viewTargetVacant = bed.docId == null
      wsCache.set(CACHE_KEY.ROOM, { isSetRoom: true, room: this.room, currentSeat: this.currentSeat })
      wsCache.set(CACHE_KEY.ROOM, {
        isSetRoom: true,
        room: this.room,
        currentSeat: this.currentSeat,
        mySeats: this.mySeats
      })
    },
    /**
     * 【主/围观】刷新"我正在看的工位"的最新状态(用于发现主人已离座)。
@@ -232,6 +303,8 @@
      this.isSetRoom = false
      // 顶栏标签同步清空(离座后显示"未入座")
      this.currentSeat = null
      // 【本需求·一人多工位】离座后 mySeats 一并清空
      this.mySeats = []
      this.viewOwnerName = null
      this.viewTargetVacant = false
@@ -253,6 +326,12 @@
        this.room = { ...this.room, ...cached.room }
        this.isSetRoom = true
        // 【主/围观】连"我入座的工位"一起恢复:否则刷新后 getCanOperate 会误判为只读
        // 【本需求·一人多工位】优先恢复完整列表;旧缓存没有 mySeats 时用 currentSeat 兜底
        if (Array.isArray(cached?.mySeats) && cached.mySeats.length > 0) {
          this.mySeats = cached.mySeats
        } else if (cached?.currentSeat) {
          this.mySeats = [cached.currentSeat]
        }
        if (cached?.currentSeat) {
          this.currentSeat = cached.currentSeat
          this.viewOwnerName = cached.currentSeat.docName ?? null
@@ -340,15 +419,21 @@
          } as RoomVO)
          // 顶栏"当前工位"标签用
          this.currentSeat = resp as RoomSeatResp
          // 【本需求·一人多工位】把该工位并入我的工位列表
          this.upsertMySeat(this.currentSeat)
          console.info('[room] 按IP自动入座成功', resp)
          return { seated: true, clientIp }
        }
        console.info('[room] 按IP自动入座未成功,转人工选工位', resp)
        console.info('[room] 按IP自动入座未成功', resp)
        return {
          seated: false,
          clientIp,
          reason: (resp.reason || 'nomatch') as AutoSeatResult['reason'],
          occupiedBy: resp.ownerName ?? null
          occupiedBy: resp.ownerName ?? resp.docName ?? null,
          // 【本需求】被占用时后端会带回候选工位信息,供"是否接管"确认后直接调用
          roomId: resp.roomId ?? null,
          bedNo: resp.bedNo ?? null,
          roomName: resp.roomName ?? null
        }
      } catch (e) {
        console.error('[tryAutoSeatByIp] 自动入座接口异常', e)
@@ -367,39 +452,106 @@
     */
    async fetchMySeat(): Promise<RoomSeatResp | null> {
      try {
        const resp: any = (await RoomApi.getMySeat()) || {}
        if (!resp.seated) {
        // 【本需求·一人多工位】优先取"我的全部工位";后端未升级(404)时回退单值接口
        let seats: RoomSeatResp[] = []
        let fallbackSingle: any = null
        try {
          const list: any = await RoomApi.getMySeats()
          seats = Array.isArray(list) ? (list as RoomSeatResp[]) : []
        } catch (e) {
          console.warn('[fetchMySeat] /my-seats 不可用,回退 /my-seat', e)
          const resp: any = (await RoomApi.getMySeat()) || {}
          if (resp.seated) {
            fallbackSingle = resp
            seats = [resp as RoomSeatResp]
          }
        }
        if (seats.length === 0 && !fallbackSingle) {
          this.mySeats = []
          this.currentSeat = null
          return null
        }
        this.currentSeat = resp as RoomSeatResp
        this.applyMySeats(seats)
        const noViewTarget = !this.isSetRoom || this.room.roomId == null || !this.room.bedNo
        const viewingMyself =
          !noViewTarget &&
          String(this.room.roomId) === String(resp.roomId) &&
          String(this.room.bedNo) === String(resp.bedNo)
        if (!noViewTarget && !viewingMyself) {
          // 正在围观别人 → 只更新"我入座的工位",不动查看目标
          console.info('[room] 查询到我入座的工位(当前正在围观他人,不切换查看目标)', resp)
        const viewingMine = !noViewTarget && this.getCanOperate
        if (!noViewTarget && !viewingMine) {
          // 正在围观别人 → 只更新"我的工位列表",不动查看目标
          console.info('[room] 已刷新我的工位列表(当前正在围观他人,不切换查看目标)', seats)
          return this.currentSeat
        }
        await this.setRoomInfoAction({
          id: resp.id ?? null,
          roomId: resp.roomId ?? null,
          roomName: resp.roomName ?? null,
          bedNo: resp.bedNo ?? null,
          ip: resp.ip ?? null,
          status: resp.status ?? null,
          docId: resp.docId ?? null,
          docName: resp.docName ?? null,
          checkTypes: resp.checkTypes ?? null,
          opType: resp.opType ?? null
        } as RoomVO)
        console.info('[room] 查询到当前入座工位', resp)
        // 未选查看目标 / 看的就是自己的某个工位 → 对齐到该工位(优先当前查看的那个)
        const target = this.currentSeat ?? seats[0]
        if (target) {
          await this.setRoomInfoAction({
            id: target.id ?? null,
            roomId: target.roomId ?? null,
            roomName: target.roomName ?? null,
            bedNo: target.bedNo ?? null,
            ip: target.ip ?? null,
            status: target.status ?? null,
            docId: target.docId ?? null,
            docName: target.docName ?? null,
            checkTypes: target.checkTypes ?? null,
            opType: target.opType ?? null
          } as RoomVO)
        }
        console.info('[room] 查询到我的工位列表', seats)
        return this.currentSeat
      } catch (e) {
        console.error('[fetchMySeat] 查询当前工位失败', e)
        return null
      }
    },
    /**
     * 【本需求·接班自助接管】把指定工位从"别人占用"改到我名下,并切换到该工位。
     *
     * @returns 是否接管成功
     */
    async takeOverBed(roomId: number | null, bedNo: string | null): Promise<boolean> {
      if (roomId == null || !bedNo) return false
      try {
        await RoomApi.takeOverBed({ roomId, bedNo })
        // 刷新"我的工位"(此时后端已把该工位 doc_id 设为我)
        await this.fetchMySeat()
        // 切换到该工位(若是刚接管、且不在列表里,则手动补一条并切过去)
        let seat = (this.mySeats || []).find(
          (s) => String(s.roomId) === String(roomId) && String(s.bedNo) === String(bedNo)
        )
        if (!seat) {
          const userStore = useUserStore()
          const curUser: any = userStore.getUser || {}
          seat = {
            seated: true,
            roomId,
            bedNo,
            roomName: this.room?.roomName ?? null,
            docId: curUser.id ?? null,
            docName: curUser.nickname ?? null
          } as RoomSeatResp
          this.mySeats = [...(this.mySeats || []), seat]
        }
        await this.setRoomInfoAction({
          id: seat.id ?? null,
          roomId: seat.roomId ?? null,
          roomName: seat.roomName ?? null,
          bedNo: seat.bedNo ?? null,
          ip: seat.ip ?? null,
          status: seat.status ?? null,
          docId: seat.docId ?? null,
          docName: seat.docName ?? null,
          checkTypes: seat.checkTypes ?? null,
          opType: seat.opType ?? null
        } as RoomVO)
        console.info('[room] 接管工位成功', roomId, bedNo)
        return true
      } catch (e) {
        console.error('[takeOverBed] 接管工位失败', e)
        return false
      }
    },
@@ -428,6 +580,8 @@
        return false
      }
      await this.setRoomInfoAction(tempRoomVO)
      // 【本需求·一人多工位】入座后刷新"我的全部工位"(可能已有多个)
      await this.fetchMySeat()
      return true
    },
src/store/modules/user.ts
@@ -3,7 +3,6 @@
import { getAccessToken, removeToken } from '@/utils/auth'
import { CACHE_KEY, useCache, deleteUserCache } from '@/hooks/web/useCache'
import { getInfo, loginOut } from '@/api/login'
import { releaseBedOnExit } from '@/utils/releaseBed'
const { wsCache } = useCache()
@@ -80,11 +79,14 @@
      wsCache.set(CACHE_KEY.USER, userInfo)
    },
    async loginOut() {
      // 先释放诊疗床位(医生常在「诊室选择」页占了床位就直接退出系统)。
      // 后端 `/system/auth/logout` 内部虽然也会离座,但:
      //   ① 若该请求失败/网络中断,床位就留在占用状态;
      //   ② 本接口失败也不会阻断退出流程(releaseBedOnExit 内部已兜底)。
      await releaseBedOnExit('manual-logout')
      // 【本需求·选 B】退出登录**不再释放工位**。
      //
      // 为什么:
      //   ① 一人多工位后,同一医生可能同时在多台电脑上各操作一个岗位 ——
      //      其中一台退出登录若按 userId 释放"全部工位",会把另一台正在用的岗位也清掉;
      //   ② 工位释放改为三种明确途径:医生点「离座」;闭诊统一清空;
      //      管理员「清空工位」/ 接班医生「接管」(`POST /clinic/room/take-over-bed`)。
      // 原先这里调用 `releaseBedOnExit('manual-logout')` → `/ecg/doctor/release-my-beds`。
      await loginOut()
      removeToken()
      deleteUserCache() // 删除用户缓存
src/styles/index.scss
@@ -34,3 +34,27 @@
    border-left-color: var(--el-color-primary);
  }
}
/* ==========================================================================
   【需求·分辨率适配】弹窗在小屏幕上不超出可视区
   背景:医生工作电脑分辨率差异大(1920 / 1366 / 1024,且常带 125% 缩放)。
   项目里多处弹窗写死了 `width="880px"` / `900px`,在 1024 宽或放大后
   会超出屏幕 —— 右侧的「确定/取消」和内容直接看不到。
   处理(全局一处生效,不用逐个改 9 个弹窗组件):
     · Element Plus 2.7 的 `.el-dialog` 宽度是 `var(--el-dialog-width)`,
       这里再加 `max-width`,写死的 px 会被自动夹住;
     · 矮屏(≤800px 高)把顶部留白从默认 15vh 收到 6vh,
       把宝贵的垂直空间还给内容(`.el-overlay-dialog` 本身已 overflow:auto,
       内容过高时整体可滚动)。
   ========================================================================== */
.el-dialog {
  max-width: 92vw;
}
@media (max-height: 800px) {
  .el-dialog {
    --el-dialog-margin-top: 6vh;
  }
}
src/utils/releaseBed.ts
@@ -2,6 +2,14 @@
import { getAccessToken } from '@/utils/auth'
/**
 * 【2026-11-05 起:本文件已无调用方】
 * ---------------------------------------------------
 * 业务改为「退出登录**不释放**工位」(一人多工位/多端登录:一台退出不能清掉另一台的岗位),
 * 释放工位改为三种明确途径:
 *   ① 医生点「离座」;② 闭诊统一清空;③ 管理员「清空工位」/ 接班医生「接管」。
 * 本工具函数保留(后端 /ecg/doctor/release-my-beds 也保留),仅用于运维/管理侧需要时手动调用。
 *
 * ---------- 以下为原注释 ----------
 * 手动「退出登录」时释放诊室工位。
 *
 * 背景
src/views/ecg/appointment/AppointmentForm.vue
@@ -286,7 +286,11 @@
    bookTime: undefined,
    bookDate: new Date().getTime(),
    bookTimeslot: 9000930,
    bookCheckType: getCheckTypeName(100),
    // 【本次修复】原来写的是 getCheckTypeName(100),把**中文名称**("常规心电图")塞进了
    // bookCheckType,而后端字段是 Integer。后果:新建/重置后不重新选检查项目直接保存,
    // 会提交字符串 → Jackson 反序列化失败 → 500「系统异常」。
    // 正确写法:与新建初始值一致,用检查类型的**数字值**(100=常规心电图)。
    bookCheckType: 100,
    paid: 0
  }
  formRef.value?.resetFields()
src/views/ecg/appointment/CheckItemPanel.vue
@@ -47,16 +47,29 @@
const _confirmAppointment = async () => {
  if (!isCurrentDay(props.appointment.bookDate)) {
    ElMessageBox.confirm('非当天预约项,确定要今天检查吗?', 'Warning', {
      confirmButtonText: '好的',
      cancelButtonText: '不用',
      type: 'warning'
    })
    // 【本需求】非当天预约:先提醒,确认后**按预留号**签入(不占用今天同时段的正常预约号)
    // 注意 bookDate 从后端回来是**时间戳数值**(如 1792339200000),必须格式化后再拼进提示,
    // 否则会直接显示一串数字。formatTimestamp → dayjs(x).format('YYYY-MM-DD')。
    const bookDateText = props.appointment.bookDate
      ? formatTimestamp(props.appointment.bookDate)
      : '-'
    ElMessageBox.confirm(
      `该患者预约日期为 ${bookDateText},不是今天。\n` +
        '是否仍要今天签到?确认后将分配【预留号】(不会占用今天该时段的正常预约号)。',
      '非当天预约签到',
      {
        confirmButtonText: '继续签到',
        cancelButtonText: '取消',
        type: 'warning'
      }
    )
      .then(async () => {
        const tempAppointment = cloneDeep(props.appointment)
        if (undefined !== bookTimeSlotVip.value && null !== bookTimeSlotVip.value) {
          tempAppointment.bookTimeslot = bookTimeSlotVip.value
        }
        // 【本需求】显式带上"已确认签入"标记:后端据此放行非当天预约,并按预留号取号
        tempAppointment.forceCheckIn = true
        const data = await AppointmentApi.confirmAppointmentVip(tempAppointment)
        seqNum.value = data
        // 发送操作成功的事件
src/views/ecg/appointment/index.vue
@@ -10,9 +10,19 @@
        <el-input v-model="queryParams.patName" placeholder="请输入患者姓名" clearable @keyup.enter="handleQuery"
          class="!w-240px" />
      </el-form-item>
      <el-form-item label="患者手机" prop="patMobile">
        <el-input v-model="queryParams.patMobile" placeholder="请输入患者手机" clearable @keyup.enter="handleQuery"
          class="!w-240px" />
      <!-- 【本需求】预约日期时间段查询(替代已去掉的「患者手机」条件,减少查询条件数量) -->
      <el-form-item label="预约日期" prop="bookDateRange">
        <el-date-picker
          v-model="queryParams.bookDateRange"
          type="daterange"
          value-format="YYYY-MM-DD"
          range-separator="至"
          start-placeholder="开始日期"
          end-placeholder="结束日期"
          unlink-panels
          clearable
          class="!w-320px"
        />
      </el-form-item>
      <!--
      <el-form-item label="患者电话" prop="patPhone">
@@ -219,7 +229,6 @@
  patName: undefined,
  patGender: undefined,
  patBirthday: undefined,
  patMobile: undefined,
  patPhone: undefined,
  patIdentityId: undefined,
  patAddr: undefined,
@@ -229,6 +238,8 @@
  patWardDesc: undefined,
  patBedNo: undefined,
  bookDate: undefined,
  // 【本需求】预约日期时间段:[]=[开始,结束],API 层会拆成 bookDateStart(00:00:00) / bookDateEnd(23:59:59)
  bookDateRange: [] as string[] | undefined,
  bookTimeslot: undefined,
  bookCheckType: undefined,
  bookSrc: undefined
src/views/ecg/callingscreen/bigscreen.vue
@@ -273,20 +273,27 @@
    border-left: none;
  }
  
  /*
    ⚠️ 这里原本是:
        display: flex; flex-wrap: wrap;
        .patient-item { width: 48%; margin-right: 2%; }
        .patient-item:nth-child(2n) { margin-right: 0; }
      意图是"该列数据多时,在列内部再分成两小列"。
    但它会让患者条目**左右并排**,导致大屏上看起来"患者跑到画面中间":
      · .patient-item 没有 box-sizing: border-box,padding 为 10px 8px;
        单个外宽 = 48% + 2% + 16px,两个合计 = 0.98W + 32px;
        只有列内容宽 W ≥ 1600px 时才放得下两个 →
        大屏(1920 单列 / 宽屏多列)上就会并排,第 2 个患者正好落在画面水平中间;
      · 列一窄又退回单列,于是同一块大屏不同列表现不一致,观感很怪。
    现改为**始终单列、从上往下排列**;
    数据多(模板里 >13 条)时由 v-if 分支拆成两个 .patient-column,
    不依赖 flex 换行,宽度合计仍是 2 份,与表头 .column-title.double-column 对齐。
  */
  &.double-column {
    flex: 2; /* 占据两倍宽度 */
    flex: 2; /* 占据两倍宽度(与表头保持一致) */
    min-width: 600px;
    display: flex;
    flex-wrap: wrap;
    .patient-item {
      width: 48%; /* 两列布局,留一点间距 */
      margin-right: 2%;
      &:nth-child(2n) {
        margin-right: 0;
      }
    }
  }
  
src/views/ecg/checktype/QueueSequenceForm.vue
@@ -13,15 +13,18 @@
      <template #title>
        <div class="text-13px leading-6">
          正在维护检查项目:<b>{{ checkTypeName }}</b>
          <span class="text-gray-500">(检查项目值 {{ checkTypeValue }},共 {{ list.length }} 个时段)</span>
          <span class="text-gray-500">(检查项目值 {{ checkTypeValue }},共 {{ slotList.length }} 个时段)</span>
        </div>
      </template>
      <div class="text-12px leading-5">
        · <b>起始序号</b>:每天自动重置时回到该值;<b>上限</b>:到达后该时段视为「已满」,患者签到会顺延到后续时段。<br />
        · <b>当前序号</b>:已发到几号,一般无需手工改(每日重置会自动回到"起始序号")。<br />
        · <b>预定额度</b> = 上限 − 起始序号(只读)。<b>上限 = 起始序号 表示该时段没有名额</b>(允许保存,预定额度显示 0 并标红)。<br />
        · 上限 <b>不能小于</b>起始序号,否则该时段永远无法取号。<br />
        · 同一检查项目下<b>同一时段只能有一条规则</b>,重复添加会被拒绝。
        · <b>号段规则</b>:每个时段按顺序占用一段连续号 —— 正常预约号在前,预留号紧跟其后(号更大),
        下一段从上一段的最大号继续,因此<b>号随时段递增、段内连续</b>。<br />
        · <b>起始序号</b>:由系统自动计算(上一段的最大号),<b>不可手工修改</b>。<br />
        · <b>上限</b>:该号池「号满的最大序号」;<b>当前序号</b>:已发到几号(每日重置回起始序号)。<br />
        · <b>取号顺序</b>:普通患者 → 本时段正常预约号;满了 → 本时段<b>预留号</b>(给临时加号);
        再满 → <b>总预留池</b>(无预约时段的患者也直接走总预留池)。<b>总预留池不设上限,保证一定能签到。</b><br />
        · <b>预定额度</b> = 上限 − 起始序号(只读)。上限 = 起始序号 表示该号池无名额(允许保存,显示 0 并标红)。<br />
        · 上限 <b>不能小于</b>起始序号;同一检查项目下<b>同一时段只能有一条规则</b>。
      </div>
    </el-alert>
@@ -39,7 +42,7 @@
    <el-table
      v-loading="loading"
      :data="list"
      :data="slotList"
      :stripe="true"
      border
      class="qs-table"
@@ -58,14 +61,10 @@
      <el-table-column label="正常预约" align="center">
        <el-table-column label="起始序号" align="center" width="90">
          <template #default="scope">
            <el-input-number
              v-model="scope.row.queueSet"
              :min="0"
              :controls="false"
              size="small"
              style="width: 100%"
              @change="markChanged(scope.row)"
            />
            <!-- 【本需求】起始序号 = 号段起点,由系统按顺序自动计算,不允许手工改 -->
            <span class="qs-fixed" :title="'系统自动计算(上一段的最大号)'">
              {{ scope.row.queueSet ?? 0 }}
            </span>
          </template>
        </el-table-column>
        <el-table-column label="上限" align="center" width="80">
@@ -115,14 +114,10 @@
      <el-table-column label="预留" align="center">
        <el-table-column label="起始序号" align="center" width="90">
          <template #default="scope">
            <el-input-number
              v-model="scope.row.queueVipSet"
              :min="0"
              :controls="false"
              size="small"
              style="width: 100%"
              @change="markChanged(scope.row)"
            />
            <!-- 【本需求】预留号段起点,同样由系统自动计算 -->
            <span class="qs-fixed" :title="'系统自动计算(正常预约的上限)'">
              {{ scope.row.queueVipSet ?? 0 }}
            </span>
          </template>
        </el-table-column>
        <el-table-column label="上限" align="center" width="80">
@@ -190,6 +185,28 @@
      </el-table-column>
    </el-table>
    <!--
      【本需求】总预留池(time_slot = 0)
      · 本段普通号 + 本段预留号都发完,或患者没有预约时段时,从总预留池取号;
      · 号段放在当天所有时段之后(号最大 ⇒ 排全天最后),且**不设上限**(永远不会满)。
    -->
    <el-alert type="warning" :closable="false" class="mt-10px">
      <template #title>
        <div class="text-13px">
          总预留池(无时段 / 所有号源用尽时的兜底):
          <template v-if="totalPool">
            起始 <b>{{ totalPool.queueVipSet ?? 0 }}</b> ·
            已发 <b>{{ totalPool.queueVipNo ?? 0 }}</b> ·
            上限 <b>{{ totalPool.queueVipFull ?? 0 }}</b>
            <span class="text-gray-500">(不设上限,满后自动继续发号,保证一定能签到)</span>
          </template>
          <template v-else>
            <span class="text-gray-500">尚未初始化(首次签到时系统会自动创建,号段接在当天最后一段之后)</span>
          </template>
        </div>
      </template>
    </el-alert>
    <!-- 新增 / 编辑表单 -->
    <Dialog v-model="formVisible" :title="formTitle" width="520px" append-to-body>
      <el-form
@@ -219,27 +236,35 @@
            选项来自字典 <code>ecg_book_timeslot</code>;也支持直接输入起始/结束时间后回车新建。
          </div>
        </el-form-item>
        <el-form-item label="普通-起始序号" prop="queueSet">
          <el-input-number v-model="formData.queueSet" :min="0" class="w-full" />
        <el-form-item label="正常预约-起始序号" prop="queueSet">
          <!-- 【本需求】起始序号 = 号段起点,由系统按「检查类型 → 时段顺序」自动计算,不可手工改 -->
          <el-input-number v-model="formData.queueSet" :min="0" class="w-full" disabled />
          <div class="text-12px text-gray-500 leading-5 mt-1">
            系统自动计算(= 上一段的最大号),不需要填写。
          </div>
        </el-form-item>
        <el-form-item label="普通-上限" prop="queueFull">
        <el-form-item label="正常预约-上限" prop="queueFull">
          <!-- 【本次需求】上限允许等于起始序号(=该时段无名额),故 min 改为 0 -->
          <el-input-number v-model="formData.queueFull" :min="0" class="w-full" />
        </el-form-item>
        <el-form-item label="VIP-起始序号" prop="queueVipSet">
          <el-input-number v-model="formData.queueVipSet" :min="0" class="w-full" />
        <el-form-item label="预留-起始序号" prop="queueVipSet">
          <!-- 【本需求】预留号段紧跟正常预约号段之后,由系统自动计算 -->
          <el-input-number v-model="formData.queueVipSet" :min="0" class="w-full" disabled />
          <div class="text-12px text-gray-500 leading-5 mt-1">
            系统自动计算(= 正常预约的上限),不需要填写。
          </div>
        </el-form-item>
        <el-form-item label="VIP-上限" prop="queueVipFull">
        <el-form-item label="预留-上限" prop="queueVipFull">
          <!-- 【本次需求】同普通上限:允许等于起始序号 -->
          <el-input-number v-model="formData.queueVipFull" :min="0" class="w-full" />
        </el-form-item>
        <el-form-item label="普通-当前序号">
        <el-form-item label="正常预约-当前序号">
          <el-input-number v-model="formData.queueNo" :min="0" class="w-full" />
          <div class="text-12px text-gray-500 leading-5 mt-1">
            新增时段时建议与「普通-起始序号」一致。
            新增时段时建议与「正常预约-起始序号」一致。
          </div>
        </el-form-item>
        <el-form-item label="VIP-当前序号">
        <el-form-item label="预留-当前序号">
          <el-input-number v-model="formData.queueVipNo" :min="0" class="w-full" />
        </el-form-item>
      </el-form>
@@ -276,6 +301,19 @@
  () => `预约时段维护${checkTypeName.value ? ' - ' + checkTypeName.value : ''}`
)
const changedCount = computed(() => changedRows.value.size)
/**
 * 【本需求】表格只显示"真实时段"(time_slot <> 0);
 * time_slot = 0 的那一行是「总预留池」(无时段 / 全满时的兜底号池),单独在表格下方展示。
 */
const slotList = computed(() =>
  (list.value || []).filter((r: QueueSequenceVO) => Number(r.timeSlot ?? 0) !== 0)
)
/** 总预留池行(time_slot = 0);未初始化时为 undefined */
const totalPool = computed(() =>
  (list.value || []).find((r: QueueSequenceVO) => Number(r.timeSlot ?? 0) === 0)
)
/** 时段下拉选项:取自字典 ecg_book_timeslot(value 为 HHMMHHMM 数字字符串,label 形如 08:00-08:30) */
const timeSlotOptions = computed(() => {
@@ -503,6 +541,16 @@
}
/* 预定额度(只读):值为 0 时浅红底,表示该时段没有名额 */
/* 【本需求】号段起点(系统自动计算,只读展示) */
.qs-fixed {
  display: inline-block;
  min-width: 34px;
  padding: 1px 4px;
  border-radius: 3px;
  color: var(--el-text-color-secondary);
  background-color: var(--el-fill-color-light);
  font-variant-numeric: tabular-nums;
}
.qs-quota {
  display: inline-block;
  padding: 1px 6px;
src/views/ecg/doctor/DevDataEntry.vue
@@ -8,7 +8,7 @@
      v-loading="formLoading"
    >
      <el-row :gutter="20">
        <el-col :span="12">
        <el-col :xs="24" :sm="24" :md="12" :lg="12">
          <el-form-item label="患者编号" prop="patId">
            <el-input v-model="formData.patId" placeholder="请输入患者编号" @input="getRentInfoByPatId" />
          </el-form-item>
@@ -32,7 +32,7 @@
            <el-input v-model="formData.interference" placeholder="请输入干扰数" />
          </el-form-item>
        </el-col>
        <el-col :span="12">
        <el-col :xs="24" :sm="24" :md="12" :lg="12">
          <el-form-item label="设备编号" prop="devId">
            <el-input v-model="formData.devId" placeholder="请输入设备编号" @input="getRentInfoByDevId" />
          </el-form-item>
src/views/ecg/doctor/DevDismantle.vue
@@ -10,32 +10,32 @@
    >
      <!-- ===== 编辑区(需要录入的字段)===== -->
      <el-row :gutter="20">
        <el-col :span="6">
        <el-col :xs="24" :sm="12" :md="12" :lg="6">
        <el-form-item label="申请单号" prop="applyNo">
          <el-input v-model="formData.applyNo" placeholder="请输入申请单号" />
        </el-form-item>
      </el-col>
       <el-col :span="6">
       <el-col :xs="24" :sm="12" :md="12" :lg="6">
        <el-form-item label="检查项目" prop="">
          {{ formData?.checkType && getCheckTypeName(formData?.checkType) }}
        </el-form-item>
      </el-col>      
        <el-col :span="6">
        <el-col :xs="24" :sm="12" :md="12" :lg="6">
          <el-form-item label="患者编号" prop="">
            <el-input v-model="formData.patId" placeholder="请输入患者编号" @input="getRentInfoByPatId" />
          </el-form-item>
        </el-col>
      <el-col :span="6">
      <el-col :xs="24" :sm="12" :md="12" :lg="6">
        <el-form-item label="患者姓名" prop="patName">
          <el-input v-model="formData.episodeId" placeholder="请输入患者姓名" />
        </el-form-item>
      </el-col>    
        <el-col :span="6">
        <el-col :xs="24" :sm="12" :md="12" :lg="6">
          <el-form-item label="设备编号" prop="devId">
            <el-input v-model="formData.devId" placeholder="请输入设备编号" @input="getRentInfoByDevId" />
          </el-form-item>
        </el-col>
        <el-col :span="6">
        <el-col :xs="24" :sm="12" :md="12" :lg="6">
          <el-form-item label="拆机时间" prop="returnTime">
            <el-date-picker
                v-model="formData.returnTime"
@@ -45,17 +45,17 @@
            />
          </el-form-item>
        </el-col>
        <el-col :span="6">
        <el-col :xs="24" :sm="12" :md="12" :lg="6">
          <el-form-item label="干扰数" prop="interference">
            <el-input v-model="formData.interference" placeholder="请输入干扰数" />
          </el-form-item>
        </el-col>
        <el-col :span="6">
        <el-col :xs="24" :sm="12" :md="12" :lg="6">
          <el-form-item label="脱落" prop="detachment">
            <el-switch v-model="formData.detachment" :active-value="1" :inactive-value="0" />
          </el-form-item>
        </el-col>
        <el-col :span="6">
        <el-col :xs="24" :sm="12" :md="12" :lg="6">
          <el-form-item label="基线粗" prop="baseline">
            <el-switch v-model="formData.baseline" :active-value="1" :inactive-value="0" />
          </el-form-item>
src/views/ecg/doctor/Routine.vue
@@ -74,8 +74,11 @@
// ==================== 需求3:停止候诊开关 ====================
// 语义:开启后【已有候诊病人继续叫号,但不再新增候诊病人】;关闭后恢复。
// 与上面的"暂停/恢复"是**两个独立概念**:暂停会把就诊中患者强制完成、
// 并把候诊患者踢出工位;本开关只拦截"新增",不动已有患者。
// 与上面的"暂停/恢复"是**两个独立概念**(注意:两者都只拦截"新增",都不动已有患者):
//   · 暂停:工位状态 20「在座」→ 30「暂停」(管理端/监控页可见),同时摘出分流队列;
//   · 停止候诊:工位状态仍是 20「在座」,只写 clinic_room.stop_ready 并摘出分流队列。
// 两者都**不会**强制完成「就诊中」患者、也**不会**清空「候诊中」患者;
// 叫号接口(nextPatient)不检查这两个状态,所以已有候诊仍可正常叫号。
const stopReady = ref<boolean>(false)
const stopReadyFlag = ref<boolean>(true) // 防连点
src/views/ecg/doctor/components/BedSwitchSelect.vue
@@ -16,6 +16,7 @@
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { RoomApi, RoomVO } from '@/api/ecg/room'
import { DoctorApi } from '@/api/ecg/doctor'
import { useRoomStore } from '@/store/modules/room'
defineOptions({ name: 'BedSwitchSelect' })
@@ -46,28 +47,72 @@
const canOperate = computed(() => roomStore.getCanOperate)
const dropdownText = computed(() => {
  if (!roomStore.getIsSetRoom || !roomStore.getViewText) return '选择工位'
  return canOperate.value
    ? roomStore.getViewText
    : `围观 ${roomStore.getViewText}`
  const seatCount = roomStore.getMySeatCount
  const suffix = seatCount > 1 ? `(我的工位 ${seatCount} 个)` : ''
  const base = canOperate.value ? roomStore.getViewText : `围观 ${roomStore.getViewText}`
  return `${base}${suffix}`
})
const isSameBed = (a?: { roomId?: any; bedNo?: any } | null, b?: RoomVO | null) =>
  !!a && !!b && String(a.roomId) === String(b.roomId) && String(a.bedNo) === String(b.bedNo)
const isMySeat = (bed: RoomVO) => isSameBed(roomStore.getCurrentSeat, bed)
const isViewing = (bed: RoomVO) => isSameBed(roomStore.getRoom, bed)
const isMySeat = (bed: RoomVO) => {
  // 【本需求·一人多工位】按"我的工位列表"判断,而不是单个 currentSeat
  const seats = roomStore.getMySeats || []
  if (seats.some((s) => isSameBed(s, bed))) return true
  return isSameBed(roomStore.getCurrentSeat, bed)
}
/**
 * 本页是否允许**出现/操作**该作业类型的工位。
 *
 * 【口径确认(2026-11-06)】每个工作台**只显示本台允许操作的作业类型与诊室**:
 *   · 叫号检查页 → opType=0(检查)
 *   · 领用 / 装机页 → 本页不提供切换(`enabled=false`,工位固定)
 * 因此该过滤同时作用于「我的工位 / 空闲工位 / 他人占用(围观·接管)」三类。
 */
const isOpAllowed = (bed: RoomVO) => {
  const types = props.opTypes
  if (!Array.isArray(types) || types.length === 0) return true
  return types.some((t) => Number(t) === Number(bed.opType))
}
/** 我的工位(下拉里"我的工位"分组,按 roomId+bedNo 去重;同样只保留本台允许的作业类型) */
const myBeds = computed(() => (roomStore.getMySeats || [])
  .map((s) => ({
    id: s.id ?? null,
    roomId: s.roomId ?? null,
    roomName: s.roomName ?? null,
    bedNo: s.bedNo ?? null,
    ip: s.ip ?? null,
    status: s.status ?? null,
    docId: s.docId ?? null,
    docName: s.docName ?? null,
    checkTypes: s.checkTypes ?? null,
    opType: s.opType ?? null
  } as RoomVO))
  .filter((b) => isOpAllowed(b)))
/** 其它工位(排除我的工位;`beds` 已在 loadBeds 里按作业类型过滤) */
const otherBeds = computed(() => beds.value.filter((b) => !isMySeat(b)))
/**
 * 按 `opTypes` 过滤工位(null/空 = 不过滤)。
 *
 * 【本次需求】叫号检查页只允许切到**检查类**工位(opType=0);
 * 领用/装机页不提供切换(`enabled=false`),此函数只服务于前者。
 * <p>【口径 2026-11-06 确认】每个工作台**只显示本台允许操作的作业类型与诊室**:
 * 叫号检查页只看 `opType=0`(检查);领用/装机页不提供切换(`enabled=false`)。
 * 非本页类型的工位整条不显示(连围观/接管入口也不给),避免"在检查页操作装机诊室"。
 */
const filterBeds = (all: RoomVO[]): RoomVO[] => {
  const types = props.opTypes
  if (!Array.isArray(types) || types.length === 0) return all
  return all.filter((b) => types.some((t) => Number(t) === Number(b.opType)))
}
/** 其它工位里的「空闲」工位(点击 = 入座) */
const freeBeds = computed(() => otherBeds.value.filter((b) => b.docId == null))
/** 其它工位里的「被他人占用」工位(可围观、可接管)—— `beds` 已按作业类型过滤 */
const occupiedBeds = computed(() => otherBeds.value.filter((b) => b.docId != null))
const isViewing = (bed: RoomVO) => isSameBed(roomStore.getRoom, bed)
/** 固定工位的只读文案(`enabled=false` 时显示) */
const fixedText = computed(() => {
@@ -93,10 +138,9 @@
      const arr = map[key]
      if (Array.isArray(arr)) all.push(...arr)
    }
    beds.value = all
    // 【本次需求】按作业类型过滤"可切换的工位"(叫号检查页只列检查类工位)
    const visible = filterBeds(all)
    beds.value = visible
    // 【口径 2026-11-06 确认】每个工作台只显示本台允许操作的作业类型与诊室:
    // 非本页类型(如检查页里的装机诊室 opType=2)**整条不显示**,到对应工作台/选工位页处理。
    beds.value = filterBeds(all)
    // 正在围观时顺便刷新"主人是否已离开"(只提示,不自动接管)
    const cur = roomStore.getRoom
    if (cur?.roomId != null && cur.bedNo) {
@@ -137,27 +181,95 @@
  }
}
/** 选中一个工位 */
const onPick = async (bed: RoomVO) => {
  // ① 我的工位 → 切回自己
  if (isMySeat(bed)) {
    await roomStore.setRoomInfoAction(bed)
    ElMessage.success(`已切回我的工位:${bed.roomName || ''} ${bed.bedNo || ''}`)
/**
 * 【本需求】切换工位前检查"当前工位是否还有就诊中患者"。
 *
 * 为什么提示:叫号逻辑是「本工位已有就诊中患者就不再叫下一位」,
 * 医生切走后该患者会一直停在就诊中(大屏也一直显示),所以切换前要提醒。
 *
 * @returns true 表示可以继续切换(无就诊中患者,或用户确认切换)
 */
const confirmSwitchWhenOnstage = async (): Promise<boolean> => {
  const cur = roomStore.getRoom
  if (!cur || cur.roomId == null || !cur.bedNo || !roomStore.getCanOperate) {
    return true // 不是"自己的工位"(围观)→ 不提示
  }
  try {
    const list: any = await DoctorApi.getToBeCheckedPatientList({
      roomId: cur.roomId,
      bedNo: cur.bedNo
    } as any)
    const onstage = (Array.isArray(list) ? list : []).find((p: any) => Number(p?.status) === 30)
    if (!onstage) return true
    await ElMessageBox.confirm(
      `当前工位「${cur.roomName || ''} · ${cur.bedNo}」还有就诊中患者(${onstage.patName || onstage.patId})。\n` +
        `切换后该患者仍停留在「就诊中」,且本工位不会再叫下一位。是否继续切换?`,
      '切换工位',
      { confirmButtonText: '继续切换', cancelButtonText: '留在本工位', type: 'warning' }
    )
    return true
  } catch (e: any) {
    // 用户点"留在本工位"(ElMessageBox reject) → 不切换;查询异常 → 不阻断
    if (e === 'cancel' || e === 'close') return false
    console.warn('[BedSwitchSelect] 查询当前工位就诊中患者失败(不阻断切换)', e)
    return true
  }
}
/** 【本需求·接班自助接管】接管"别人占用"的工位 */
const takeOverBed = async (bed: RoomVO) => {
  const bedText = `${bed.roomName || ''} · ${bed.bedNo || ''}`
  const owner = bed.docName || '其他医生'
  try {
    await ElMessageBox.confirm(
      `工位「${bedText}」当前由「${owner}」占用。\n确认接管?接管后「${owner}」将被强制离座(其页面会提示重新就坐)。`,
      '接管工位',
      { confirmButtonText: '确认接管', cancelButtonText: '取消', type: 'warning' }
    )
  } catch {
    return
  }
  // ② 空闲 → 询问后入座
  const ok = await roomStore.takeOverBed(bed.roomId, bed.bedNo)
  if (!ok) {
    ElMessage({ message: '接管失败,请稍后重试或联系管理员', type: 'error', duration: 4000 })
    return
  }
  ElMessage.success(`已接管工位:${bedText}`)
  await loadBeds()
}
/** 选中一个工位(也兼容"接管"这种伪命令) */
const onPick = async (cmd: any) => {
  // ⓪ 接管命令(下拉里"接管"那一项传的是 { takeover: bed })
  if (cmd && cmd.takeover) {
    await takeOverBed(cmd.takeover as RoomVO)
    return
  }
  const bed = cmd as RoomVO
  if (!bed) return
  // 点的是当前正在看的工位 → 无需切换
  if (isViewing(bed)) return
  // ① 我的工位(多工位之一)→ 只切换查看目标,不重入座、不释放其它工位
  if (isMySeat(bed)) {
    if (!(await confirmSwitchWhenOnstage())) return
    await roomStore.setRoomInfoAction(bed)
    ElMessage.success(`已切换到我的工位:${bed.roomName || ''} ${bed.bedNo || ''}`)
    return
  }
  // ② 空闲 → 询问后入座(多工位:**新增**一个我的工位,不释放其它工位)
  if (bed.docId == null) {
    const mine = roomStore.getSeatText
    try {
      await ElMessageBox.confirm(
        `入座到「${bed.roomName || ''} / ${bed.bedNo || ''}」?` +
          (mine ? `(会退出原来的「${mine}」)` : ''),
          `(将新增一个"我的工位",不释放其它工位)`,
        '入座确认',
        { confirmButtonText: '入座', cancelButtonText: '取消', type: 'warning' }
      )
    } catch {
      return
    }
    if (!(await confirmSwitchWhenOnstage())) return
    const ok = await roomStore.seatRoom(bed)
    if (!ok) {
      ElMessage({ message: '入座失败:该工位可能已被他人占用,或状态不允许(已关闭)', type: 'warning', duration: 4000 })
@@ -168,6 +280,7 @@
    return
  }
  // ③ 已被别人占用 → 围观(只读)
  if (!(await confirmSwitchWhenOnstage())) return
  await roomStore.setViewTargetAction(bed, bed.docName)
  ElMessage({
    message: `正在围观「${bed.roomName || ''} / ${bed.bedNo || ''}」(主人 ${bed.docName || '其他医生'}),只读查看`,
@@ -209,14 +322,54 @@
      </el-button>
      <template #dropdown>
        <el-dropdown-menu>
          <!-- 【本需求·一人多工位】我的工位分组:可一键切换(不重入座、不释放其它工位) -->
          <el-dropdown-item v-if="myBeds.length > 0" disabled class="bed-switch__group">
            我的工位({{ myBeds.length }} 个)
          </el-dropdown-item>
          <el-dropdown-item
            v-for="bed in beds"
            :key="`${bed.roomId}-${bed.bedNo}`"
            v-for="bed in myBeds"
            :key="`mine-${bed.roomId}-${bed.bedNo}`"
            :command="bed"
            :class="{ 'is-current': isViewing(bed) }"
          >
            {{ bedLabel(bed) }}
          </el-dropdown-item>
          <!-- 空闲工位(点击 = 入座,新增一个"我的工位") -->
          <el-dropdown-item v-if="freeBeds.length > 0" disabled class="bed-switch__group">
            空闲工位(可入座)
          </el-dropdown-item>
          <el-dropdown-item
            v-for="bed in freeBeds"
            :key="`free-${bed.roomId}-${bed.bedNo}`"
            :command="bed"
            :class="{ 'is-current': isViewing(bed) }"
          >
            {{ bedLabel(bed) }}
          </el-dropdown-item>
          <!--
            【口径 2026-11-06】每个工作台只显示本台允许操作的作业类型/诊室:
            非本页作业类型的工位(如检查页里的领用/装机诊室)**整条不显示**,
            需要去对应工作台(领用/装机页)或「选工位页」查看。
          -->
          <!--
            【本需求】被他人占用的工位:**围观 / 接管 两个入口都保留**
              · 「围观」= 切换查看(只读),用于看别人叫号情况;
              · 「接管」= 上一班医生忘记离座时,把工位强制转给我。
          -->
          <el-dropdown-item v-if="occupiedBeds.length > 0" disabled class="bed-switch__group">
            他人占用(可围观 / 可接管)
          </el-dropdown-item>
          <template v-for="bed in occupiedBeds" :key="`occ-${bed.roomId}-${bed.bedNo}`">
            <el-dropdown-item :command="bed" :class="{ 'is-current': isViewing(bed) }">
              围观「{{ bed.roomName || '' }} · {{ bed.bedNo }}」({{ bed.docName || '其他医生' }})
            </el-dropdown-item>
            <el-dropdown-item :command="{ takeover: bed }" class="bed-switch__takeover">
              接管「{{ bed.roomName || '' }} · {{ bed.bedNo }}」({{ bed.docName || '其他医生' }})
            </el-dropdown-item>
          </template>
          <el-dropdown-item v-if="beds.length === 0" disabled>暂无已开放工位</el-dropdown-item>
          <el-dropdown-item divided @click="router.push({ path: '/roomselect' })">
            去「选工位页」…
@@ -250,6 +403,17 @@
  color: var(--el-text-color-secondary);
}
/* 【本需求·一人多工位】下拉分组标题 */
:deep(.el-dropdown-menu__item.bed-switch__group) {
  font-size: 12px;
  color: var(--el-text-color-secondary);
  cursor: default;
}
/* 【本需求·接班自助接管】接管项用警示色,避免与"围观"混淆 */
:deep(.el-dropdown-menu__item.bed-switch__takeover) {
  color: var(--el-color-warning);
}
:deep(.el-dropdown-menu__item.is-current) {
  color: var(--el-color-primary);
  font-weight: 600;
src/views/ecg/doctor/components/DevInstallPanel.vue
@@ -2,19 +2,19 @@
  <el-form class="patient-compact-form" ref="formRef" :model="formData" :rules="formRules" label-width="100px" v-loading="formLoading">
    <!-- ===== 编辑区(需要录入的字段)===== -->
    <el-row :gutter="20">
      <el-col :span="6">
      <el-col :xs="24" :sm="12" :md="12" :lg="6">
        <el-form-item label="检查项目" prop="">
          {{ getCheckTypeName(formData.checkType) }}
        </el-form-item>
      </el-col>
      <el-col :span="12">
      <el-col :xs="24" :sm="24" :md="12" :lg="12">
        <el-form-item label="患者编号" prop="patId">
          <el-input v-model="formData.patId" placeholder="请输入患者编号" @input="getRentInfoByPatId" />
          <Barcode v-if="displayBarCode" :value="displayBarCode" :width=100 />
          <el-button @click="copyBarcode()">复制</el-button>
          <Barcode v-if="showBarCode" :value="barcodeContent" :width=100 />
          <el-button v-if="showCopyButton" :disabled="!barcodeContent" @click="copyBarcode()">复制</el-button>
        </el-form-item>
      </el-col>
      <el-col :span="6">
      <el-col :xs="24" :sm="12" :md="12" :lg="6">
        <el-form-item label="设备编号" prop="devId">
          <el-input v-model="formData.devId" placeholder="请输入设备编号" @input="getDevInfo" />
        </el-form-item>
@@ -171,7 +171,6 @@
const formRef = ref() // 表单 Ref
const displayBarCode = ref<string>()
const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
@@ -234,7 +233,6 @@
    formData.value.doctorAdviceNo = queueVO?.doctorAdviceNo
  formData.value.patIdentityId = queueVO?.patIdentityId
  getReadyRentInfo(formData.value)    // 获取领用信息
  copyBarcode()
}
defineExpose({ setPatient, getList: () => getList() }) // 提供 setPatient 方法,用于设置患者
@@ -344,7 +342,6 @@
  formData.value.patId = tempPatId;
  getReadyRentInfo(formData.value)
  copyBarcode()
}
// 获取患者的领用信息
@@ -474,46 +471,45 @@
  getList()
}
const getDisplayBarcode = (): string => {
  if (!formData.value.checkType)
    return ""
/**
 * 【本需求】患者条码口径(门诊/急诊/体检 = 就诊流水号;住院不出条码,只留「复制」)
 *
 *  · 门诊(1) / 急诊(2) / 体检(4) → 条码内容 = **就诊流水号**(episodeId)
 *  · 住院(3)                    → **不显示条码**,只保留「复制」按钮(复制内容同样是就诊流水号)
 *  · 「复制」按钮与条码一致:同受「检查类型 → 患者条码显示」配置控制
 *    (该检查类型未勾选这个患者来源时,条码和复制按钮都不显示)
 */
const patSourceValue = computed<number | null>(() => {
  const source = formData.value.patDetails?.source ?? formData.value.patSrc
  return source === null || source === undefined || source === '' ? null : Number(source)
})
  // 【本次修复】「患者条码显示」未配置(null/空)→ 不显示(按现场确认口径)
  const displayBarcode: number[] | undefined = checkTypeStore.getCheckTypeDispBarCode(formData.value.checkType!)
  if (displayBarcode == null || displayBarcode.length === 0)
    return ""
/** 是否通过了「患者条码显示」配置(勾选了当前患者来源) */
const barcodeEnabled = computed<boolean>(() => {
  if (formData.value.checkType == null) return false
  const displayBarcode: number[] | undefined = checkTypeStore.getCheckTypeDispBarCode(formData.value.checkType)
  if (displayBarcode == null || displayBarcode.length === 0) return false
  const patSource = patSourceValue.value
  if (patSource == null) return false
  return displayBarcode.map(Number).includes(patSource)
})
  // 【本次修复】来源优先取 patDetails.source,缺失时回退到平铺的 patSrc
  // (原来直接取 patDetails.source,patDetails 缺失时 `.includes` 还会抛异常)
  const patSource = formData.value.patDetails?.source ?? formData.value.patSrc
  if (patSource == null)
    return ""
  if (!displayBarcode.map(Number).includes(Number(patSource)))
    return ""
/** 条码 / 复制的内容:统一取「就诊流水号」 */
const barcodeContent = computed<string>(() =>
  barcodeEnabled.value ? (formData.value.episodeId ?? '') : ''
)
  return getBarcode()
}
/** 是否显示条码:住院(来源 3)不显示;内容为空时也不显示 */
const showBarCode = computed<boolean>(
  () => barcodeEnabled.value && patSourceValue.value !== 3 && barcodeContent.value !== ''
)
const getBarcode = (): string => {
  if (!formData.value.checkType)
    return ""
  const patSource = formData.value.patDetails?.source ?? formData.value.patSrc
  if (patSource == null)
    return ""
  // 门诊/急诊 → 申请单号;住院/体检 → 就诊流水号
  if (1 === Number(patSource) || 2 === Number(patSource))
    return formData.value.applyNo ?? ""
  else if (3 === Number(patSource) || 4 === Number(patSource))
    return formData.value.episodeId ?? ""
  return ""
}
/** 是否显示「复制」按钮:与条码一致(同受配置控制) */
const showCopyButton = computed<boolean>(() => barcodeEnabled.value)
const copyBarcode = async () => {
  displayBarCode.value = getDisplayBarcode()
  await navigator.clipboard.writeText(getBarcode());
  if (!barcodeContent.value) return
  await navigator.clipboard.writeText(barcodeContent.value)
}
/** 初始化 **/
src/views/ecg/doctor/components/DevReadyPanel.vue
@@ -14,19 +14,19 @@
    >
      <!-- ===== 编辑区(需要录入的字段)===== -->
      <el-row :gutter="20">
        <el-col :span="6">
        <el-col :xs="24" :sm="12" :md="12" :lg="6">
          <el-form-item label="检查项目" prop="">
            {{getCheckTypeName(formData.checkType)}}
          </el-form-item>
        </el-col>
        <el-col :span="12">
        <el-col :xs="24" :sm="24" :md="12" :lg="12">
          <el-form-item label="患者编号" prop="patId">
            <el-input v-model="formData.patId" placeholder="请输入患者编号" @input="getRentInfoByPatId" />
            <Barcode v-if="displayBarCode" :value="displayBarCode" :width=100 />
            <el-button @click="copyBarcode()">复制</el-button>
            <Barcode v-if="showBarCode" :value="barcodeContent" :width=100 />
            <el-button v-if="showCopyButton" :disabled="!barcodeContent" @click="copyBarcode()">复制</el-button>
          </el-form-item>
        </el-col>
        <el-col :span="6">
        <el-col :xs="24" :sm="12" :md="12" :lg="6">
          <el-form-item label="设备编号" prop="devId">
            <el-input v-model="formData.devId" placeholder="请输入设备编号" @input="getDevInfo"/>
          </el-form-item>
@@ -258,7 +258,6 @@
  state: undefined,
})
const displayBarCode = ref<string> ()
const getRentInfoByPatId = () => {
  const tempPatId = formData.value.patId;
@@ -268,7 +267,6 @@
  formData.value.patId = tempPatId;
  getFreeRentInfo(formData.value)
  copyBarcode()
}
// 根据患者或设备, 获取 Free的租赁
@@ -437,7 +435,6 @@
    formData.value.doctorAdviceNo = queueVO?.doctorAdviceNo
  formData.value.patIdentityId = queueVO?.patIdentityId
  getFreeRentInfo(formData.value)  // 待检查的信息  @ dev_rent 表
  copyBarcode()
}
defineExpose({ setPatient, getList: () => getList() }) // 提供 setPatient 方法,用于设置患者
@@ -642,51 +639,45 @@
/**
 * 获取条形码,显示与否  同一个值
 */
const getDisplayBarcode = (): string => {
  if (formData.value.checkType == null)
    return ""
/**
 * 【本需求】患者条码口径(门诊/急诊/体检 = 就诊流水号;住院不出条码,只留「复制」)
 *
 *  · 门诊(1) / 急诊(2) / 体检(4) → 条码内容 = **就诊流水号**(episodeId)
 *  · 住院(3)                    → **不显示条码**,只保留「复制」按钮(复制内容同样是就诊流水号)
 *  · 「复制」按钮与条码一致:同受「检查类型 → 患者条码显示」配置控制
 *    (该检查类型未勾选这个患者来源时,条码和复制按钮都不显示)
 */
const patSourceValue = computed<number | null>(() => {
  const source = formData.value.patDetails?.source ?? formData.value.patSrc
  return source === null || source === undefined || source === '' ? null : Number(source)
})
  // 【本次修复】「患者条码显示」未配置(null/空)→ 不显示(按现场确认口径)
/** 是否通过了「患者条码显示」配置(勾选了当前患者来源) */
const barcodeEnabled = computed<boolean>(() => {
  if (formData.value.checkType == null) return false
  const displayBarcode: number[] | undefined = checkTypeStore.getCheckTypeDispBarCode(formData.value.checkType)
  if (displayBarcode == null || displayBarcode.length === 0)
    return ""
  if (displayBarcode == null || displayBarcode.length === 0) return false
  const patSource = patSourceValue.value
  if (patSource == null) return false
  return displayBarcode.map(Number).includes(patSource)
})
  // 【本次修复】来源优先取 patDetails.source,缺失时回退到平铺的 patSrc
  const patSource = formData.value.patDetails?.source ?? formData.value.patSrc
  if (patSource == null)
    return ""
/** 条码 / 复制的内容:统一取「就诊流水号」 */
const barcodeContent = computed<string>(() =>
  barcodeEnabled.value ? (formData.value.episodeId ?? '') : ''
)
  if (!displayBarcode.map(Number).includes(Number(patSource)))
    return ""
/** 是否显示条码:住院(来源 3)不显示;内容为空时也不显示 */
const showBarCode = computed<boolean>(
  () => barcodeEnabled.value && patSourceValue.value !== 3 && barcodeContent.value !== ''
)
  return getBarcode()
}
const getBarcode = (): string => {
  if (formData.value.checkType == null)
    return ""
  const patSource = formData.value.patDetails?.source ?? formData.value.patSrc
  if (patSource == null)
    return ""
  // 门诊/急诊 → 申请单号;住院/体检 → 就诊流水号
  if (1 === Number(patSource) || 2 === Number(patSource))
    return formData.value.applyNo??""
  else if (3 === Number(patSource) || 4 === Number(patSource))
    return formData.value.episodeId??""
  return ""
}
/** 是否显示「复制」按钮:与条码一致(同受配置控制) */
const showCopyButton = computed<boolean>(() => barcodeEnabled.value)
const copyBarcode = async () => {
  displayBarCode.value = getDisplayBarcode()
  const barCode = getBarcode()
  if (isStringEmpty(barCode))
    return
  await navigator.clipboard.writeText( barCode );
  if (!barcodeContent.value) return
  await navigator.clipboard.writeText(barcodeContent.value)
}
/** 初始化 **/
src/views/ecg/doctor/components/PatientInfoBlock.vue
@@ -55,7 +55,15 @@
   * 显示 `-`,属预期。
   */
  showAdviceNo?: boolean
  /** 每列栅格宽度(默认 6 = 一行 4 个) */
  /**
   * 【分辨率自适应】**≥1200px** 时每列的栅格宽度(默认 6 = 一行 4 个)。
   *
   * 窄屏会自动降级,避免医生电脑分辨率不同导致字段挤成一条缝:
   *   · ≥1200px(lg)      → 用本参数(默认 6,一行 4 个)
   *   · 992~1199(md)      → 12,一行 2 个
   *   · 768~991(sm)       → 12,一行 2 个
   *   · <768(xs)          → 24,一行 1 个
   */
  span?: number
}>()
@@ -125,21 +133,21 @@
    </div>
    <el-row :gutter="20">
      <el-col :span="colSpan">
      <el-col :xs="24" :sm="12" :md="12" :lg="colSpan">
        <el-form-item label="姓名">
          {{ d.patName || '-' }}
        </el-form-item>
      </el-col>
      <el-col :span="colSpan">
      <el-col :xs="24" :sm="12" :md="12" :lg="colSpan">
        <el-form-item label="就诊号">
          {{ d.episodeId || '-' }}
        </el-form-item>
      </el-col>
      <el-col v-if="showSeqNum" :span="colSpan">
      <el-col v-if="showSeqNum" :xs="24" :sm="12" :md="12" :lg="colSpan">
        <el-form-item label="排队序号">
          {{ seqNumText }}
        </el-form-item>
      </el-col><el-col :span="colSpan">
      </el-col><el-col :xs="24" :sm="12" :md="12" :lg="colSpan">
        <!-- 标签由「就诊卡号」改为「门诊号」(经确认,详见《部署清单》5.1):
           HIS 在卡号槽位传空,medical_card 全库 159,116 行无一非空;
           该位置实际展示 outpatient_no。cardNo.from 标注值的真实来源,
@@ -150,34 +158,34 @@
            {{ cardNo.from }}
          </el-tag>
        </el-form-item>
      </el-col> <el-col :span="colSpan">
      </el-col> <el-col :xs="24" :sm="12" :md="12" :lg="colSpan">
        <el-form-item label="状态">
          <dict-tag v-if="d.status !== undefined && d.status !== null && statusDict" :type="statusDict"
            :value="d.status" />
          <span v-else>-</span>
        </el-form-item>
      </el-col>
      <el-col :span="colSpan">
      <el-col :xs="24" :sm="12" :md="12" :lg="colSpan">
        <el-form-item label="申请编号">
          {{ d.applyNo || '-' }}
        </el-form-item>
      </el-col><el-col :span="colSpan">
      </el-col><el-col :xs="24" :sm="12" :md="12" :lg="colSpan">
        <el-form-item label="项目">
          {{ checkType ? getCheckTypeName(checkType) : '-' }}
        </el-form-item>
      </el-col>
      <el-col :span="colSpan">
      <el-col :xs="24" :sm="12" :md="12" :lg="colSpan">
        <el-form-item label="多耐">
          <dict-tag :type="DICT_TYPE.ECG_TOLERANCE" :value="d.tolerance ?? 0" />
        </el-form-item>
      </el-col><el-col :span="colSpan">
      </el-col><el-col :xs="24" :sm="12" :md="12" :lg="colSpan">
        <el-form-item label="来源">
          <dict-tag v-if="source !== undefined" :type="DICT_TYPE.ECG_PAT_SOURCE" :value="source" />
          <span v-else>-</span>
        </el-form-item>
      </el-col>
      <el-col :span="colSpan">
      <el-col :xs="24" :sm="12" :md="12" :lg="colSpan">
        <el-form-item label="性别">
          <!-- 1 男 / 2 女;0 或缺失(HIS 未提供,实测库中有 2123 行 = 0)显示"未知"。
             注意不能直接把 0 交给 dict-tag —— 字典里没有 0 这一项,会渲染成空白。 -->
@@ -186,53 +194,53 @@
          <span v-else>未知</span>
        </el-form-item>
      </el-col>
      <el-col :span="colSpan">
      <el-col :xs="24" :sm="12" :md="12" :lg="colSpan">
        <el-form-item label="出生日期">
          {{ formatDateOnly(d.patBirthday) || '-' }}
        </el-form-item>
      </el-col>
      <el-col :span="colSpan">
      <el-col :xs="24" :sm="12" :md="12" :lg="colSpan">
        <el-form-item label="年龄">
          {{ formatAge(d.patBirthday) || '-' }}
        </el-form-item>
      </el-col>
      <el-col :span="colSpan">
      <el-col :xs="24" :sm="12" :md="12" :lg="colSpan">
        <el-form-item label="患者电话">
          {{ phone }}
        </el-form-item>
      </el-col>
      <el-col :span="colSpan">
      <el-col :xs="24" :sm="12" :md="12" :lg="colSpan">
        <el-form-item label="病区">
          {{ ward }}
        </el-form-item>
      </el-col>
      <el-col :span="colSpan">
      <el-col :xs="24" :sm="12" :md="12" :lg="colSpan">
        <el-form-item label="病床号">
          {{ patBed }}
        </el-form-item>
      </el-col>
      <el-col :span="colSpan">
      <el-col :xs="24" :sm="12" :md="12" :lg="colSpan">
        <el-form-item label="登记时间">
          {{ formatDateTime(d.registerDate) || '-' }}
        </el-form-item>
      </el-col><el-col :span="colSpan">
      </el-col><el-col :xs="24" :sm="12" :md="12" :lg="colSpan">
        <el-form-item label="开单医师">
          {{ d.doctor || '-' }}
        </el-form-item>
      </el-col>
      <el-col :span="colSpan">
      <el-col :xs="24" :sm="12" :md="12" :lg="colSpan">
        <el-form-item label="开单日期">
          {{ formatDateTime(d.doctorTime) || '-' }}
        </el-form-item>
      </el-col>
      <el-col :span="colSpan">
      <el-col :xs="24" :sm="12" :md="12" :lg="colSpan">
        <el-form-item label="距今天数">
          {{ formatDaysFromOrder(d.doctorTime) || '-' }}
        </el-form-item>
      </el-col>
      <el-col v-if="showAdvice" :span="colSpan">
      <el-col v-if="showAdvice" :xs="24" :sm="12" :md="12" :lg="colSpan">
        <el-form-item label="医嘱号">
          {{ d.doctorAdviceNo || '-' }}
        </el-form-item>
src/views/ecg/doctor/components/RoutinePanel.vue
@@ -1,43 +1,84 @@
<!-- 常规检查 医生界面-->
<template>
  <el-form class="patient-compact-form" ref="formRef" :model="formData" :rules="formRules" label-width="100px" v-loading="formLoading">
    <!--
      【本需求】紧急加做操作提示(编辑区上方,常驻)
      紧急病人不排队:在下面三个输入框任一个里输入后按回车,查到预约记录即可直接做检查。
    -->
    <el-alert type="warning" :closable="false" class="emergency-tip">
      <template #title>
        <span class="emergency-tip__text">
          有紧急病人时,请输入<span class="emergency-tip__key">申请单号</span>、或<span class="emergency-tip__key">就诊号</span>、或<span class="emergency-tip__key">患者编号</span>后按<span class="emergency-tip__enter">回车键</span>,查到病人预约记录即可做检查!!!
        </span>
      </template>
    </el-alert>
    <!-- ===== 编辑区(需要录入的字段)===== -->
    <el-row :gutter="20">
      <el-col :span="6">
        <el-form-item label="申请单号" prop="applyNo">
          <el-input v-model="formData.applyNo" placeholder="请输入申请单号" />
        </el-form-item>
      </el-col>
       <el-col :span="6">
        <el-form-item label="检查项目" prop="">
          {{ formData?.checkType && getCheckTypeName(formData?.checkType) }}
        </el-form-item>
      </el-col>
        <el-col :span="6">
        <el-form-item label="患者编号" prop="patId">
          <el-input v-model="formData.patId" placeholder="请输入患者编号" @input="getRentInfoByPatId" />
          <Barcode v-if="displayBarCode" :value="displayBarCode" :width="100" />
          <el-button @click="copyBarcode()">复制</el-button>
        </el-form-item>
      </el-col>
      <el-col :span="6">
        <el-form-item label="患者姓名" prop="patName">
          <el-input v-model="formData.patName" placeholder="请输入患者姓名" />
        </el-form-item>
      </el-col>
    </el-row>
      <el-row :gutter="4">
        <el-col :xs="24" :sm="12" :md="12" :lg="6">
          <el-form-item label="申请单号" prop="applyNo">
            <!-- 【本需求:紧急加做】回车即按申请单号查近一个月的预约 -->
            <el-input
              v-model="formData.applyNo"
              placeholder="请输入申请单号"
              @keyup.enter="searchEmergencyCandidates('applyNo')"
            />
          </el-form-item>
        </el-col>
        <el-col :xs="24" :sm="12" :md="12" :lg="6">
          <el-form-item label="检查项目" prop="">
            {{ formData?.checkType && getCheckTypeName(formData?.checkType) }}
          </el-form-item>
        </el-col>
        <el-col :xs="24" :sm="12" :md="12" :lg="6">
          <el-form-item label="就诊号" prop="episodeId">
            <!-- 【本需求:紧急加做】回车即按就诊号查近一个月的预约 -->
            <el-input
              v-model="formData.episodeId"
              placeholder="请输入就诊流水号"
              @keyup.enter="searchEmergencyCandidates('episodeId')"
            />
            <!--
              【本需求】条码从「患者编号」挪到这里(就诊号下方):
                · 内容 = 就诊号;默认是否显示由「检查类型 → 是否显示条码」配置决定;
                · 医生可用切换按钮手动显隐(显示中→「隐藏」;隐藏中→「条码」);
                · 「复制」只在条码显示时出现。
            -->
            <div v-if="showBarcodeToggle || showBarCode" class="barcode-row">
              <Barcode v-if="showBarCode" :value="barcodeContent" :width="100" />
              <el-button v-if="showBarcodeToggle" size="small" @click="toggleBarcode()">
                {{ barcodeToggleLabel }}
              </el-button>
              <el-button v-if="showCopyButton" size="small" :disabled="!barcodeContent" @click="copyBarcode()">
                复制
              </el-button>
            </div>
          </el-form-item>
        </el-col>
        <!-- 【本需求】患者姓名这一列已去掉:下方 PatientInfoBlock 已有姓名展示 -->
        <el-col :xs="24" :sm="12" :md="12" :lg="6">
          <el-form-item label="患者编号" prop="patId">
            <!-- 【本需求:紧急加做】回车即按患者编号查近一个月的预约 -->
            <el-input
              v-model="formData.patId"
              placeholder="请输入患者编号"
              @input="getRentInfoByPatId"
              @keyup.enter="searchEmergencyCandidates('patId')"
            />
          </el-form-item>
        </el-col>
      </el-row>
    <!-- ===== 患者信息(需求1:18 项统一展示,**一行四列**)=====
         原先本面板把 18 项手写在多行 el-col(span=6/18 混排),
         现统一改用 PatientInfoBlock 组件,与预约签到/设备领用/装机/拆机各页**口径一致**。 -->
    <PatientInfoBlock :data="formData" check-type-field="checkType" :span="6" />
    <el-row>     
      <el-col :span="6">
      <el-col :xs="24" :sm="12" :md="12" :lg="6">
        <el-form-item label="检查时间" prop="rentTime">
          <el-date-picker v-model="formData.rentTime" type="date" value-format="x" placeholder="选择时间" />
        </el-form-item>
      </el-col> 
      <el-col :span="18">
      <el-col :xs="24" :sm="24" :md="12" :lg="18">
        <el-form-item label="备注" prop="remark">
          <el-input v-model="formData.remark" placeholder="请输入备注" />
        </el-form-item>
@@ -50,8 +91,8 @@
  <el-button size="small" @click="resetForm()">重 置</el-button>
  <el-button size="small" @click="yddownload()" :loading="yddownloadLoading" :disabled="yddownloadLoading">平板运动心电图知情同意书</el-button>
  <el-button size="small" @click="sgdownload()" :loading="sgdownloadLoading" :disabled="sgdownloadLoading">食管心脏电生理诊疗知情同意书</el-button>
  <el-button size="small" v-if="needAutoFeeConfirm(formData.checkType)" @click="feeConfirm" :disabled="formLoading">确费</el-button>
  <el-button size="small" v-if="needAutoFeeConfirm(formData.checkType)" @click="feeCancel" :disabled="formLoading">取消确费</el-button>
  <el-button size="small" v-if="needAutoFeeConfirm(formData.checkType)" @click="feeConfirm" :disabled="formLoading || formData.id == null">确费</el-button>
  <el-button size="small" v-if="needAutoFeeConfirm(formData.checkType)" @click="feeCancel" :disabled="formLoading || formData.id == null">取消确费</el-button>
  </div>
  <!-- 医生装机列表 -->
@@ -145,13 +186,84 @@
      @pagination="getList"
    />
  </div>
  <!--
    【本需求:紧急患者不排队直接检查】预约选择框
    触发:在「申请单号 / 就诊号 / 患者编号」任一输入框内按回车。
    多条预约时弹此框,按预约时间倒序(最近的在前)。
  -->
  <Dialog v-model="emergencyVisible" title="选择预约记录(紧急加做)" width="880px" append-to-body>
    <el-alert type="warning" :closable="false" class="mb-10px">
      <template #title>
        <span class="text-13px">
          找到 {{ emergencyList.length }} 条预约记录:**点选一行**(整行高亮 + 左侧蓝条 + 「已选」标签)后点「选择并检查」,
          也可直接**双击**该行。选中后可直接检查,完成后同样进入「已完成列表」(不排队、不占号)。
        </span>
      </template>
    </el-alert>
    <el-table
      ref="emergencyTableRef"
      class="emergency-table"
      :data="emergencyList"
      v-loading="emergencyLoading"
      highlight-current-row
      border
      max-height="360px"
      :row-class-name="emergencyRowClass"
      @row-click="(row) => (emergencySelected = row)"
      @current-change="(row) => (emergencySelected = row)"
      @row-dblclick="(row) => confirmEmergency(row)"
    >
      <!-- 【本需求】选中标识列:选中行显示「✓ 已选」,避免只靠背景色分辨不清 -->
      <el-table-column label="选择" width="80" align="center" fixed="left">
        <template #default="scope">
          <el-tag v-if="isEmergencySelected(scope.row)" type="primary" effect="dark" size="small">
            ✓ 已选
          </el-tag>
          <span v-else class="text-gray-400">—</span>
        </template>
      </el-table-column>
      <el-table-column label="预约日期" align="center" prop="bookDate" width="120">
        <template #default="scope">{{ formatDate(scope.row.bookDate, 'YYYY-MM-DD') }}</template>
      </el-table-column>
      <el-table-column label="时段" align="center" width="120">
        <template #default="scope">{{ formatTimeslot(scope.row.bookTimeslot) }}</template>
      </el-table-column>
      <el-table-column label="检查项目" align="center" min-width="130">
        <template #default="scope">{{ getCheckTypeName(scope.row.bookCheckType) }}</template>
      </el-table-column>
      <el-table-column label="患者名称" align="center" prop="patName" min-width="100" />
      <el-table-column label="患者编号" align="center" prop="patId" min-width="110" />
      <el-table-column label="申请单号" align="center" prop="applyNo" min-width="130" />
      <el-table-column label="状态" align="center" width="90">
        <template #default="scope">
          <dict-tag :type="DICT_TYPE.ECG_STATUS" :value="scope.row.status" />
        </template>
      </el-table-column>
    </el-table>
    <template #footer>
      <!-- 底部回显已选哪一条,避免选错 -->
      <span v-if="emergencySelected" class="emergency-selected-hint">
        已选:{{ formatDate(emergencySelected.bookDate, 'YYYY-MM-DD') }}
        {{ formatTimeslot(emergencySelected.bookTimeslot) }}
        {{ getCheckTypeName(emergencySelected.bookCheckType) }}
        <span class="text-gray-500">({{ emergencySelected.patName }})</span>
      </span>
      <el-button @click="emergencyVisible = false">取 消</el-button>
      <el-button type="primary" :disabled="!emergencySelected" @click="confirmEmergency()">
        选择并检查
      </el-button>
    </template>
  </Dialog>
</template>
<script setup lang="ts">
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { DevRentApi, DevRentVO } from '@/api/ecg/devrent'
import { dateFormatter } from '@/utils/formatTime'
import { dateFormatter, formatDate } from '@/utils/formatTime'
import { formatTimeslot } from '@/utils/formatter'
import { AppointmentApi, AppointmentVO } from '@/api/ecg/appointment'
import { JobRecordApi, JobRecordVO } from '@/api/ecg/jobrecord'
import { isStringEmpty } from '@/utils/stringUtil'
import { calculateAge, curDayEnd, curDayStart, formatTimestamp } from '@/utils/dateUtil'
@@ -239,7 +351,6 @@
    formData.value.doctorAdviceNo = queueVO?.doctorAdviceNo
  formData.value.patIdentityId = queueVO?.patIdentityId
  getFreeRentInfo(formData.value)
  copyBarcode()
}
defineExpose({ setPatient, getList: () => getList() }) // 提供 setPatient 方法,用于设置患者
@@ -304,7 +415,106 @@
})
const formRef = ref() // 表单 Ref
const displayBarCode = ref<string>()
// =====================================================================================
// 【本需求:紧急患者不排队直接检查】
//   在「申请单号 / 就诊号 / 患者编号」任一输入框回车 → 查近一个月、本窗口检查项目的预约记录
//   → 1 条直接读入;多条弹选择框(按预约时间倒序)→ 读入后可直接「检查完成」
//   → 后端在"今天没有待检查单"时按预约单补建,从而像排队患者一样进入「已完成列表」
// =====================================================================================
const emergencyMode = ref(false) // 当前表单是否来自"紧急加做"
const emergencyVisible = ref(false) // 选择框
const emergencyLoading = ref(false)
const emergencyList = ref<AppointmentVO[]>([])
const emergencySelected = ref<AppointmentVO | undefined>()
/** 选择表格引用(保留:后续如需 scrollToRow 等能力可用) */
const emergencyTableRef = ref<any>()
/** 选中判断(模板里用,避免直接比较对象引用) */
const isEmergencySelected = (row: AppointmentVO) =>
  null != emergencySelected.value && emergencySelected.value.id === row?.id
/** 选中行加 is-selected 类,配合下方样式做「整行高亮 + 左侧蓝条 + 加粗」 */
const emergencyRowClass = ({ row }: { row: AppointmentVO }) =>
  isEmergencySelected(row) ? 'is-selected' : ''
/** 本窗口(诊室)开展的检查项目,用于过滤候选预约;为空表示不限制 */
const currentWindowCheckTypes = (): number[] | undefined => {
  const fromProp = props.room?.checkTypes
  const types =
    Array.isArray(fromProp) && fromProp.length > 0 ? fromProp : (roomStore.room?.checkTypes as number[] | undefined)
  return Array.isArray(types) && types.length > 0 ? [...types] : undefined
}
/** 回车:按输入框里的值查预约候选 */
const searchEmergencyCandidates = async (field: 'applyNo' | 'episodeId' | 'patId') => {
  const code = String((formData.value as Record<string, any>)[field] ?? '').trim()
  if (!code) return
  emergencyLoading.value = true
  try {
    const list = (await AppointmentApi.getEmergencyCandidates(code, currentWindowCheckTypes())) as AppointmentVO[]
    const rows = Array.isArray(list) ? list : []
    if (rows.length === 0) {
      message.warning('近一个月内没有找到本窗口检查项目的预约记录')
      return
    }
    if (rows.length === 1) {
      applyAppointment(rows[0])
      message.success('已读入预约记录,可直接点「检查完成」')
      return
    }
    emergencyList.value = rows
    emergencySelected.value = undefined
    emergencyVisible.value = true
  } finally {
    emergencyLoading.value = false
  }
}
/** 选择框确认(也支持双击行直接确认) */
const confirmEmergency = (row?: AppointmentVO) => {
  const target = row ?? emergencySelected.value
  if (!target) return
  applyAppointment(target)
  emergencyVisible.value = false
}
/**
 * 把预约单读入表单。
 * 注意:这里是"紧急加做",**不排队**——不建 queue 行、不取号;
 * 「检查完成」时由后端按需补建「待检查」检查单(见 DevRentServiceImpl#prepareEmergencyDevRent)。
 */
const applyAppointment = (appt: AppointmentVO) => {
  resetForm()
  emergencyMode.value = true
  formData.value.applyNo = appt.applyNo
  formData.value.episodeId = appt.episodeId
  formData.value.patId = appt.patId
  formData.value.patName = appt.patName
  formData.value.checkType = appt.bookCheckType
  formData.value.patGender = appt.patGender
  formData.value.patBirthday = appt.patBirthday
  formData.value.patSrc = appt.patSrc
  formData.value.tolerance = appt.tolerance
  formData.value.doctor = appt.doctor
  formData.value.doctorTime = appt.doctorTime
  formData.value.registerDate = appt.registerDate
  formData.value.paid = appt.paid
  formData.value.doctorAdviceNo = appt.doctorAdviceNo
  formData.value.patMobile = appt.patMobile
  formData.value.patPhone = appt.patPhone
  formData.value.patIdentityId = appt.patIdentityId
  formData.value.outpatientNo = appt.outpatientNo
  formData.value.patWardDesc = appt.patWardDesc
  formData.value.patBedNo = appt.patBedNo
  formData.value.medicalCard = appt.medicalCard
  // 条码/打印小票依赖 patDetails.source
  formData.value.patDetails = {
    source: appt.patSrc,
    appointmentId: appt.id
  } as unknown as typeof formData.value.patDetails
  message.success('已读入预约记录,可直接点「检查完成」')
}
/** 提交表单 */
const submitForm = async () => {
@@ -315,12 +525,16 @@
  try {
    formData.value.roomId = props.room.roomId
    formData.value.bedNo = props.room.bedNo
    const data = formData.value as unknown as DevRentVO
    await DevRentApi.routineFinish(data)
    // 【本需求:紧急患者不排队直接检查】带上"紧急加做"标记:
    //   后端据此在「今天没有待检查单」时按预约单补建一行,从而能正常完成并写入「已完成列表」
    const data = formData.value as unknown as DevRentVO & { emergency?: boolean }
    data.emergency = emergencyMode.value
    // 后端 routine-finish 已返回本次完成的检查单 id(紧急加做时是服务端新建的那条)
    const rentId = (await DevRentApi.routineFinish(data)) as unknown as number | string | undefined
    // 自动确费
    // 自动确费:用返回的 rentId;无 id 时静默跳过(不再弹「请先检查」)
    if (needAutoFeeConfirm(formData.value.checkType)) {
      feeConfirm()
      feeConfirm(rentId)
    }
    message.success(t('操作成功'))
@@ -360,6 +574,9 @@
    roomId: props.room.roomId,
    bedNo: props.room.bedNo
  }
  // 【本需求】换患者/重置时:条码显隐回到"跟随配置默认",紧急加做标记清空
  barcodeVisible.value = null
  emergencyMode.value = false
  formRef.value?.resetFields()
}
const yddownload = async () => {
@@ -439,7 +656,6 @@
  formData.value.patId = tempPatId
  getFreeRentInfo(formData.value)
  copyBarcode()
}
const getFreeRentInfo = async (devRentVO) => {
@@ -570,14 +786,21 @@
  message.success(t('操作成功'))
}
const feeConfirm = async () => {
  if (formData.value.id == null) {
    message.error(t('请先检查'))
/**
 * 确费。
 * @param rentId 本次检查完成返回的检查单 id;不传则用表单里的 id。
 * 【本次修复】原实现在 id 为空时弹「请先检查」—— 紧急加做患者没有 id,点「检查完成」会先弹这个错,
 * 再弹「操作成功」,让人误以为操作失败。现在改为静默跳过(按钮本身也会在无 id 时禁用)。
 */
const feeConfirm = async (rentId?: number | string | null) => {
  const id = rentId ?? formData.value.id
  if (id == null || id === '') {
    console.warn('[feeConfirm] 无检查单 id,跳过确费')
    return
  }
  try {
    const result = await DevRentApi.feeConfirm(formData.value.id)
    const result = await DevRentApi.feeConfirm(id as number)
    if (0 == result) {
      //message.success(t('确费操作成功'))
    }
@@ -587,8 +810,9 @@
}
const feeCancel = async () => {
  if (formData.value.id == null) {
    message.error(t('请先检查'))
  // 【本次修复】无检查单 id 时静默跳过(按钮已禁用),不再弹「请先检查」
  if (formData.value.id == null || formData.value.id === '') {
    console.warn('[feeCancel] 无检查单 id,跳过取消确费')
    return
  }
@@ -601,40 +825,57 @@
    // 框架 会给出 后台的 错误信息
  }
}
/**
 * 【本需求】患者条码:位置在「就诊号」下方,内容 = 就诊号(episodeId),可显隐
 *
 *  · 默认可见性 = 「检查类型 → 是否显示条码」配置里**包含该患者来源**
 *      - 已勾选该来源 → 默认显示
 *      - 未配置 / 配置里没有该来源 → 默认不显示(例如住院(3)通常不会勾选)
 *  · 医生可用按钮手动覆盖默认:显示中 → 「隐藏」;隐藏中 → 「条码」
 *  · 「复制」按钮只在条码显示时出现
 */
const barcodeVisible = ref<boolean | null>(null) // null = 未手动操作,跟随配置默认
const getDisplayBarcode = (): string => {
  if (formData.value.checkType == null) return ''
const patSourceValue = computed<number | null>(() => {
  const source = formData.value.patDetails?.source ?? formData.value.patSrc
  return source === null || source === undefined || source === '' ? null : Number(source)
})
  // 【本次修复】「患者条码显示」未配置(null/空)→ 不显示(按现场确认口径)
  const displayBarcode: number[] | undefined = checkTypeStore.getCheckTypeDispBarCode(formData.value.checkType!)
  if (displayBarcode == null || displayBarcode.length === 0) return ''
/** 配置口径:该检查类型的「显示条码」是否包含当前患者来源 */
const barcodeEnabled = computed<boolean>(() => {
  if (formData.value.checkType == null) return false
  const displayBarcode: number[] | undefined = checkTypeStore.getCheckTypeDispBarCode(formData.value.checkType)
  if (displayBarcode == null || displayBarcode.length === 0) return false
  const patSource = patSourceValue.value
  if (patSource == null) return false
  return displayBarcode.map(Number).includes(patSource)
})
  // 【本次修复】来源优先取 patDetails.source,缺失时回退到平铺的 patSrc。
  // 原来只读 patDetails.source,而 patDetails 原先只在"能查到待检查单"时才被赋值,
  // 大多数情况下是 undefined → 条码永远不显示。
  const patSource = formData.value.patDetails?.source ?? formData.value.patSrc
  if (patSource == null) return ''
  if (!displayBarcode.map(Number).includes(Number(patSource))) return ''
/** 条码 / 复制的内容:统一取「就诊号」 */
const barcodeContent = computed<string>(() => formData.value.episodeId ?? '')
  return getBarcode()
}
/** 实际是否显示:手动覆盖优先,否则跟配置默认 */
const barcodeVisibleEffective = computed<boolean>(() => barcodeVisible.value ?? barcodeEnabled.value)
const getBarcode = (): string => {
  if (formData.value.checkType == null) return ''
/** 条码:可见且内容非空才渲染 */
const showBarCode = computed<boolean>(() => barcodeVisibleEffective.value && barcodeContent.value !== '')
  const patSource = formData.value.patDetails?.source ?? formData.value.patSrc
  if (patSource == null) return ''
/** 「复制」按钮:只在条码显示时出现 */
const showCopyButton = computed<boolean>(() => showBarCode.value)
  // 门诊/急诊 → 申请单号;住院/体检 → 就诊流水号
  if (1 === Number(patSource) || 2 === Number(patSource)) return formData.value.applyNo ?? ''
  else if (3 === Number(patSource) || 4 === Number(patSource)) return formData.value.episodeId ?? ''
/** 切换按钮:只要有就诊号就给(不受配置限制,配置只管"默认是否显示") */
const showBarcodeToggle = computed<boolean>(() => barcodeContent.value !== '')
  return ''
/** 切换按钮文案:显示中 → 隐藏;隐藏中 → 条码 */
const barcodeToggleLabel = computed<string>(() => (showBarCode.value ? '隐藏' : '条码'))
const toggleBarcode = () => {
  barcodeVisible.value = !barcodeVisibleEffective.value
}
const copyBarcode = async () => {
  displayBarCode.value = getDisplayBarcode()
  await navigator.clipboard.writeText(getBarcode())
  if (!barcodeContent.value) return
  await navigator.clipboard.writeText(barcodeContent.value)
}
/** 初始化 **/
@@ -647,6 +888,73 @@
<style scoped lang="scss">
/* 【本次需求】「已完成列表」标题栏:左侧主色竖线 + 加粗小标题,紧凑高度不挤压表格 */
/* 【本需求】编辑区上方的「紧急加做」操作提示:一行显示、醒目但不占高度 */
.emergency-tip {
  padding: 4px 10px;
  margin-bottom: 6px;
  :deep(.el-alert__title) {
    padding: 0;
    font-size: 13px;
    line-height: 20px;
  }
  &__text {
    color: var(--el-color-warning-dark-2);
  }
  /* 关键字段/按键做强调,方便一眼看懂要输什么、按什么 */
  &__key {
    font-weight: 600;
    color: var(--el-color-danger);
  }
  &__enter {
    padding: 0 4px;
    margin: 0 2px;
    border: 1px solid var(--el-color-danger);
    border-radius: 3px;
    font-weight: 600;
    color: var(--el-color-danger);
    background-color: var(--el-color-danger-light-9);
  }
}
/* 【本需求】紧急加做:预约选择框的「选中行」要足够明显
   (原来只有默认的浅色 current-row 背景,现场反馈"只是变深一点,看不清") */
.emergency-table {
  :deep(.el-table__row) {
    cursor: pointer;
  }
  :deep(.el-table__row:hover > td) {
    background-color: var(--el-color-primary-light-9) !important;
  }
  /* 选中行:主色背景 + 加粗 + 左侧 4px 主色条 */
  :deep(.el-table__row.is-selected > td) {
    background-color: var(--el-color-primary-light-6) !important;
    color: var(--el-color-primary-dark-2);
    font-weight: 600;
  }
  :deep(.el-table__row.is-selected > td:first-child) {
    box-shadow: inset 4px 0 0 var(--el-color-primary);
  }
}
/* 选择框底部「已选」回显 */
.emergency-selected-hint {
  float: left;
  line-height: 32px;
  font-size: 13px;
  color: var(--el-color-primary);
  font-weight: 600;
}
/* 【本需求】就诊号下方的条码行:条码 + [隐藏|条码] + [复制] 同一行,紧凑不撑高 */
.barcode-row {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-top: 4px;
  flex-wrap: wrap;
}
.records-title {
  display: flex;
  align-items: center;
src/views/ecg/room/RoomLoginSelect.vue
@@ -33,7 +33,7 @@
    <div class="room-select__scroll">
      <div class="room-select__list">
        <div class="room-select__item" v-for="(bed, index) in bedList" :key="`${bed.roomId}-${bed.bedNo}-${index}`">
          <RoomBedSelect :bed="bed" :curBed="curSel" :curUser='curUser' @event-haveseat="haveSeat" @event-leaveseat="leaveSeat" @event-setcurseat="setCurSeat"/>
          <RoomBedSelect :bed="bed" :curBed="curSel" :curUser='curUser' @event-haveseat="haveSeat" @event-leaveseat="leaveSeat" @event-setcurseat="setCurSeat" @event-takeover="takeOverConfirm"/>
        </div>
        <el-empty v-if="isEmptyOpeningBed" class="room-select__empty" description="工位没有开放"/>
      </div>
@@ -397,8 +397,8 @@
    tempRoomVO.docId = curUser.id
    tempRoomVO.docName = curUser.nickname
    // 【本次需求】走 store 的入座动作:服务端入座成功后会**自动释放我这个账号的其它工位**
    // (一人一工位),并把工位信息写进会话缓存。
    // 【本需求·一人多工位】走 store 的入座动作:服务端**不再释放**我账号下的其它工位,
    // 因此这里是"新增一个我的工位"(值班多岗位),并把工位信息写进会话缓存。
    const seated = await roomStore.seatRoom(tempRoomVO)
    if (!seated) {
      // 失败时自动调诊断接口,把库里真实行与结论一起给出(便于现场一键定位)
@@ -425,6 +425,39 @@
  gotoAfterSeat(newRoomVO.opType, false)
}
/**
 * 【本需求·接班自助接管】对"别人占用"的工位执行接管。
 *
 * 场景:上一班医生下班没点「离座」,下一班医生在同一工位接班 —— 原来只能找管理员清空。
 * 现在:二次确认 → 后端强制原占用人离座并把工位给我 → 刷新列表/我的工位。
 */
const takeOverConfirm = async (roomVO: RoomVO) => {
  const bedText = `${roomVO.roomName || '诊室' + roomVO.roomId} · ${roomVO.bedNo}`
  const owner = roomVO.docName || '其他医生'
  try {
    await ElMessageBox.confirm(
      `工位「${bedText}」当前由「${owner}」占用。\n` +
        `确认接管?接管后「${owner}」将被强制离座(其页面会提示重新就坐),工位立即归你使用。`,
      '接管工位',
      { confirmButtonText: '确认接管', cancelButtonText: '取消', type: 'warning' }
    )
  } catch {
    return
  }
  const ok = await roomStore.takeOverBed(roomVO.roomId, roomVO.bedNo)
  if (!ok) {
    ElMessage({ message: '接管失败,请稍后重试或联系管理员', type: 'error', duration: 4000 })
    return
  }
  ElMessage.success(`已接管工位:${bedText}`)
  resetCurSel()
  curSel.value = { ...roomVO, docId: curUser.id, docName: curUser.nickname }
  await roomStore.fetchMySeat()
  await getList()
  // 接管后进入该岗位对应的工作台(接管会带上该工位"就诊中"患者)
  gotoAfterSeat(roomVO.opType, true)
}
const leaveSeatConfirm = async (roomVO: RoomVO) => {
  // 【本次需求】离座只能清**自己的** doc_id(服务端 setBedDoctorOff 已加 doc_id 条件),
  // 这里先拦一道,避免"旁观/其他医生"点离座把正在操作的人踢掉。
src/views/ecg/room/RoomSetting.vue
@@ -41,7 +41,11 @@
          {{ monitorInfo?.openingFlag === 1 ? '开诊中(会自动补人)' : '已闭诊(不会自动补人)' }}
        </el-tag>
      </div>
      <div>工位概况: {{monitorInfo?.openingBedNum}} {{monitorInfo?.docBedNum}} {{monitorInfo?.routingBedNum}} </div>
      <div>
          工位概况: 开通 {{ monitorInfo?.openingBedNum }} ·
          在座工位 {{ monitorInfo?.docBedNum }}(医生 {{ monitorInfo?.docNum ?? 0 }} 人)·
          参与分流 {{ monitorInfo?.routingBedNum }}
        </div>
      <div v-for="(value, key) in monitorInfo.checkTypeBedInfo" :key="key">
        {{ getCheckTypeName(Number(key)) }}  {{ value }}
      </div>