yxh
2026-10-18 1446994d504819c367e1ff667360eadb314f9ac6
src/components/DoctorPageLayout/src/DoctorPageLayout.vue
@@ -7,13 +7,13 @@
  「叫号 / 过号 / 重叫 / 暂停 / 撤销叫号」挡住;
  且外层容器没有确定高度,中间区无法产生滚动,内容只能一路把页面撑长。
  【布局契约】——纵向三段 + 右侧栏
  【布局契约】——纵向三段 + 右侧栏(中间有**可拖动分界线**)
  ┌────────────────────────────────────────────┐
  │ #header  标题栏(固定高度,不滚动)          │  flex: none
  ├──────────────────────────────┬─────────────┤
  │ #default 主体(滚动区)       │ #aside      │  仅中间区与右栏各自滚动
  │  · 患者信息(由各面板内部    │ (独立滚动)│
  │    设为 sticky,钉在顶部)   │             │
  │  · 患者信息(由各面板内部    │ (独立滚动)│  两者之间的竖线**可左右拖动**
  │    设为 sticky,钉在顶部)   │             │  以调整右栏宽度
  │  · 已完成记录列表(滚动)    │             │
  ├──────────────────────────────┴─────────────┤
  │ #footer  功能按钮(固定底部,永不被遮挡)    │  flex: none
@@ -29,6 +29,7 @@
     带不透明背景与上边框,内容永远不会透过来或盖住它。
  4. 根元素**自带不透明背景**:各面板内 sticky 的患者信息若不透明,
     滚动内容会从它与边缘的缝隙透出,故整页统一底色。
  5. 【本次新增】分界线可拖动(见下),宽度记在 localStorage 里,切页/刷新后保持。
  【插槽】
    header   标题栏(可选)
@@ -37,19 +38,67 @@
    footer   底部功能按钮(可选;固定在页面底部)
-->
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import BedSwitchSelect from '@/views/ecg/doctor/components/BedSwitchSelect.vue'
import { useRoomStore } from '@/store/modules/room'
defineOptions({ name: 'DoctorPageLayout' })
withDefaults(
/* ==================== 主/围观(只读模式) ==================== */
const roomStore = useRoomStore()
/**
 * 是否显示顶部提示条。
 *
 * 判定:**只要当前不可操作**就提示 —— 含两种情况:
 *   ① 还没选定要看哪个工位(未入座也没围观);
 *   ② 正在围观别人的工位(我入座的 ≠ 我正在看的)。
 */
const showReadonlyTip = computed(() => !roomStore.getCanOperate)
const readonlyTipText = computed(() =>
  roomStore.getViewText ? `「${roomStore.getViewText}」` : '(尚未选择工位)'
)
const readonlyTipPrefix = computed(() =>
  roomStore.getViewText ? '围观模式(只读):正在查看' : '尚未选择工位:请用右上角「工位」下拉选择要查看 / 要入座的工位'
)
const props = withDefaults(
  defineProps<{
    /** 右侧栏宽度 */
    /** 右侧栏**初始/默认**宽度(拖动之后以实际拖动值为准;双击分界线恢复该值) */
    asideWidth?: string
    /** 右侧栏最小宽度(px) */
    minAsideWidth?: number
    /** 左侧内容区最小宽度(px):左侧是四列患者信息表单,太窄会折行 */
    minContentWidth?: number
    /** 记忆拖动结果的 localStorage key;传空字符串表示不记忆 */
    storageKey?: string
    /**
     * 【本次需求】是否允许在标题栏切换工位。
     * 设备领用 / 装机页传 `false`:工位固定,不提供切换(未入座者即围观者,只读)。
     */
    bedSwitchEnabled?: boolean
    /**
     * 【本次需求】可切换工位的**作业类型**过滤(0检查 1领用 2装机)。
     * 叫号检查页传 `[0]` → 下拉只列检查类工位;不传 = 不过滤。
     */
    bedSwitchOpTypes?: number[] | null
  }>(),
  {
    asideWidth: '400px'
    asideWidth: '400px',
    minAsideWidth: 280,
    minContentWidth: 520,
    storageKey: 'ecg.doctor.asideWidth',
    bedSwitchEnabled: true,
    bedSwitchOpTypes: null
  }
)
/** 默认宽度(px):由 asideWidth 解析而来,用作初始值与"双击复位"值 */
const defaultAsidePx = (() => {
  const n = parseInt(props.asideWidth, 10)
  return Number.isFinite(n) && n > 0 ? n : 400
})()
/**
 * 【关键】不依赖 CSS `height: 100%` 的解析,改为**实测父容器可用高度**。
@@ -71,6 +120,111 @@
const rootRef = ref<HTMLElement>()
const measuredHeight = ref<string | undefined>(undefined)
// ==================== 右侧栏宽度(可拖动分界线) ====================
/** 主体行容器:拖动时用它算"右侧栏最多能有多宽" */
const bodyRef = ref<HTMLElement>()
/** 右侧栏当前宽度(px) */
const asidePx = ref<number>(defaultAsidePx)
/** 是否正在拖动(用于换光标、禁选中) */
const dragging = ref(false)
/**
 * 把宽度夹到合法区间。
 *
 * 上限 = 容器宽度 − 左侧最小宽度(保证左侧患者信息/记录列表不被挤没);
 * 下限 = minAsideWidth。窗口极窄导致两者冲突时,**优先保证下限**,
 * 左侧靠自身的 `min-width:0 + overflow` 优雅降级。
 */
const clampAsidePx = (px: number) => {
  const bodyPx = bodyRef.value?.clientWidth ?? 0
  const lower = props.minAsideWidth
  const upper = bodyPx > 0
    ? Math.max(lower, bodyPx - props.minContentWidth)
    : Number.MAX_SAFE_INTEGER
  return Math.round(Math.min(Math.max(px, lower), upper))
}
/** 记住拖动结果(隐私模式/禁用存储时静默忽略) */
const saveAsidePx = () => {
  if (!props.storageKey) return
  try {
    localStorage.setItem(props.storageKey, String(asidePx.value))
  } catch {
    /* ignore */
  }
}
/** 读取上次拖动结果;必须等 bodyRef 挂载后才能 clamp */
const loadAsidePx = () => {
  if (!props.storageKey) return
  try {
    const v = parseInt(localStorage.getItem(props.storageKey) || '', 10)
    if (Number.isFinite(v) && v > 0) asidePx.value = clampAsidePx(v)
  } catch {
    /* ignore */
  }
}
/** 双击分界线:恢复默认宽度并清除记忆 */
const resetAsideWidth = () => {
  asidePx.value = clampAsidePx(defaultAsidePx)
  if (props.storageKey) {
    try {
      localStorage.removeItem(props.storageKey)
    } catch {
      /* ignore */
    }
  }
}
let startX = 0
let startPx = 0
/**
 * 用 Pointer Events + setPointerCapture:
 * 捕获后 pointermove/up 都只在分界线上触发,**不需要**再往 document 上挂/摘监听,
 * 也就不会出现"拖到 iframe 或窗口外松手后监听残留"的老问题。
 */
const onSplitterDown = (e: PointerEvent) => {
  const el = e.currentTarget as HTMLElement | null
  dragging.value = true
  startX = e.clientX
  startPx = asidePx.value
  el?.setPointerCapture?.(e.pointerId)
  e.preventDefault()
}
const onSplitterMove = (e: PointerEvent) => {
  if (!dragging.value) return
  // 分界线向右拖(Δx>0)→ 右侧栏变窄,故用减法
  asidePx.value = clampAsidePx(startPx - (e.clientX - startX))
}
const onSplitterUp = (e: PointerEvent) => {
  if (!dragging.value) return
  dragging.value = false
  const el = e.currentTarget as HTMLElement | null
  el?.releasePointerCapture?.(e.pointerId)
  saveAsidePx()
}
/** 键盘微调(←/→ 每次 20px),无鼠标也能调整 */
const onSplitterKey = (e: KeyboardEvent) => {
  const step = 20
  if (e.key === 'ArrowLeft') {
    asidePx.value = clampAsidePx(asidePx.value + step) // 分界线左移 → 右侧变宽
  } else if (e.key === 'ArrowRight') {
    asidePx.value = clampAsidePx(asidePx.value - step)
  } else {
    return
  }
  saveAsidePx()
  e.preventDefault()
}
let ro: ResizeObserver | undefined
let rafId = 0
@@ -87,6 +241,10 @@
  // 父级高度不可测(auto/0)时保留 CSS 回退,不设内联高度
  measuredHeight.value = inner > 0 ? `${inner}px` : undefined
  // 宽度变化(含窗口缩放)时重新夹一次右栏宽度,
  // 否则拖宽后把窗口缩小,右侧栏可能把左侧内容挤没。
  asidePx.value = clampAsidePx(asidePx.value)
}
/** 用 rAF 合并连续触发,避免 ResizeObserver 循环告警 */
@@ -100,6 +258,7 @@
onMounted(() => {
  measure()
  loadAsidePx()
  const parent = rootRef.value?.parentElement
  if (parent && typeof ResizeObserver !== 'undefined') {
    ro = new ResizeObserver(scheduleMeasure)
@@ -118,22 +277,65 @@
</script>
<template>
  <div ref="rootRef" class="doctor-page" :style="{ height: measuredHeight }">
    <!-- 标题栏:固定高度,不滚动 -->
  <div
    ref="rootRef"
    class="doctor-page"
    :class="{ 'is-resizing': dragging }"
    :style="{ height: measuredHeight }"
  >
    <!--
      标题栏:固定高度,不滚动。
      右侧固定放「工位切换 / 围观」下拉(主/围观模型的入口)——放在布局层,
      叫号检查 / 设备领用 / 装机 三页自动都有,不用各页重复加。
    -->
    <div v-if="$slots.header" class="doctor-page__header">
      <div class="doctor-page__header-left">
      <slot name="header"></slot>
      </div>
      <BedSwitchSelect :enabled="bedSwitchEnabled" :op-types="bedSwitchOpTypes" />
    </div>
    <!-- 围观模式(只读)/ 未选工位 提示条:让医生一眼知道"现在只能看,不能操作" -->
    <div v-if="showReadonlyTip" class="doctor-page__readonly-tip">
      <Icon icon="ep:view" class="mr-4px" />
      {{ readonlyTipPrefix }}{{ roomStore.getViewText ? readonlyTipText : '' }}<template v-if="roomStore.getViewText && roomStore.viewOwnerName">,主人 {{ roomStore.viewOwnerName }}</template><template v-if="roomStore.getViewText">;如需操作,请在右上角「工位」里入座到该工位。</template>
    </div>
    <!-- 主体:占据剩余全部高度;内部只让"内容区"和"右栏"各自滚动 -->
    <div class="doctor-page__body">
    <div ref="bodyRef" class="doctor-page__body" :class="{ 'is-readonly': !roomStore.getCanOperate }">
      <div class="doctor-page__content">
        <slot></slot>
      </div>
      <!--
        可拖动分界线(本次需求):
        · 左右拖动 = 调整右侧患者清单的宽度;
        · 双击 = 恢复默认宽度(并清除记忆);
        · 聚焦后 ←/→ = 每次 20px 微调;
        · 宽度记忆在 localStorage,三个工作台页面共用同一个 key。
      -->
      <div
        v-if="$slots.aside"
        class="doctor-page__splitter"
        :class="{ 'is-dragging': dragging }"
        role="separator"
        aria-orientation="vertical"
        :aria-valuenow="asidePx"
        :aria-valuemin="minAsideWidth"
        tabindex="0"
        title="拖动调整右侧宽度(双击复位)"
        @pointerdown="onSplitterDown"
        @pointermove="onSplitterMove"
        @pointerup="onSplitterUp"
        @pointercancel="onSplitterUp"
        @dblclick="resetAsideWidth"
        @keydown="onSplitterKey"
      ></div>
      <div
        v-if="$slots.aside"
        class="doctor-page__aside"
        :style="{ width: asideWidth, flex: `0 0 ${asideWidth}` }"
        :style="{ width: `${asidePx}px`, flex: '0 0 auto' }"
      >
        <slot name="aside"></slot>
      </div>
@@ -144,7 +346,7 @@
      需求:`自动`、`停止候诊` 与 `叫号/过号/重叫/暂停/撤销叫号` **合成一组**,
            一起**靠右**(对着右侧候诊表),不要被拆散到左边/中间。
    -->
    <div v-if="$slots.footer" class="doctor-page__footer">
    <div v-if="$slots.footer" class="doctor-page__footer" :class="{ 'is-readonly': !roomStore.getCanOperate }">
      <div class="doctor-page__footer-right">
        <slot name="footer"></slot>
      </div>
@@ -168,6 +370,12 @@
  background-color: var(--el-bg-color);
}
/* 拖动分界线过程中:全局换成左右箭头光标,并禁止选中文字(否则会拖出一片蓝色选区) */
.doctor-page.is-resizing {
  cursor: col-resize;
  user-select: none;
}
/* ---------- 标题栏 ---------- */
.doctor-page__header {
  flex: none;
@@ -175,6 +383,59 @@
  padding: 0 12px;
  overflow: hidden;
  background-color: var(--el-color-primary-light-7);
  /* 左侧标题(各页传入的 TitlePanel)+ 右侧「工位切换 / 围观」下拉 */
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}
/* 标题区可压缩(工位下拉固定靠右) */
.doctor-page__header-left {
  flex: 1;
  min-width: 0;
  overflow: hidden;
}
/* ==========================================================================
   【本次需求】工作台按钮统一尺寸 / 字号(方便鼠标击中)
   原来混用 `size="small"`(24px / 12px 字)与默认(32px / 14px 字),
   同一页里大小不一。这里做统一归一化,**不逐个改 size 属性**:
   归一化后 `--small` 已无视觉效果,后续新增按钮也会自动一致。
   ========================================================================== */
.doctor-page :deep(.el-button),
.doctor-page :deep(.el-button--small),
.doctor-page :deep(.el-button--default) {
  height: 32px;
  padding: 0 14px;
  font-size: 14px;
}
/* 表格 / 队列列表内的行内按钮:小一档,避免把表格行撑高 */
.doctor-page :deep(.el-table .el-button:not(.is-link)),
.doctor-page :deep(.queue-list .el-button:not(.is-link)) {
  height: 28px;
  padding: 0 10px;
  font-size: 13px;
}
/* 链接式按钮(表头「撤销」、候选区「立即补人」等):只放大字号,不加高度 */
.doctor-page :deep(.el-button.is-link) {
  height: auto;
  padding: 0 4px;
  font-size: 14px;
}
/* ---------- 围观模式(只读)提示条 ---------- */
.doctor-page__readonly-tip {
  flex: none;
  padding: 4px 12px;
  font-size: 13px;
  line-height: 20px;
  color: var(--el-color-warning);
  background-color: var(--el-color-warning-light-9);
  border-bottom: 1px solid var(--el-color-warning-light-7);
}
/* ---------- 主体 ---------- */
@@ -183,6 +444,20 @@
  flex: 1;
  /* ⚠️ 关键:不加 min-height:0,flex 子项不会收缩,overflow 不生效 */
  min-height: 0;
}
/*
  围观模式(只读):内容区里的**按钮 / 开关 / 勾选框**一律不可点。
  覆盖各面板里的「检查完成 / 放弃检查 / 确费 / 领用完成 / 装机完成 / 数据录入 / 撤销 …」。
  说明:
    · 弹窗(患者信息、打印预览等)是 teleport 出去的,不受这里影响,仍可正常查看;
    · 服务端还有一道硬校验(非工位主人调用写接口会被拒绝),这里只是体验层。
*/
.doctor-page__body.is-readonly :deep(.el-button),
.doctor-page__body.is-readonly :deep(.el-switch),
.doctor-page__body.is-readonly :deep(.el-checkbox) {
  pointer-events: none;
  opacity: 0.5;
}
/*
@@ -196,6 +471,9 @@
  这里的 `overflow-y: auto` 只在**空间实在不足**(患者信息 + 按钮把高度吃光)时生效,
  作为兜底;正常情况不会出现滚动条。
  注意必须保留 min-height:0,否则 flex 子项不会收缩,记录列表拿不到受限高度。
  ⚠️ 右边框已去掉:分界线改由 `.doctor-page__splitter` 提供,
     否则「3px 右边框 + 分界线」会并排成两条线。
*/
.doctor-page__content {
  display: flex;
@@ -207,6 +485,42 @@
  overflow-y: auto;
  overflow-x: hidden;
  border: solid var(--el-color-primary-light-7);
  border-right: none;
}
/* ---------- 可拖动分界线 ---------- */
/*
  命中区 6px(好点中),视觉线 1px 居中;hover / 拖动中加粗变主色。
  注意:宽度由它自己占位,因此拖动时"内容区宽度"由 flex 自动让出,无需 JS 改左侧。
*/
.doctor-page__splitter {
  position: relative;
  flex: none;
  width: 6px;
  cursor: col-resize;
  background-color: transparent;
  /* 防止拖动时浏览器做原生拖拽/滚动 */
  touch-action: none;
  outline: none;
  &::before {
    content: '';
    position: absolute;
    top: 0;
    bottom: 0;
    left: 50%;
    width: 1px;
    transform: translateX(-50%);
    background-color: var(--el-border-color);
    transition: background-color 0.15s, width 0.15s;
  }
  &:hover::before,
  &.is-dragging::before,
  &:focus-visible::before {
    width: 2px;
    background-color: var(--el-color-primary);
  }
}
/* ---------- 右侧栏 ---------- */
@@ -238,6 +552,16 @@
}
/*
  围观模式(只读):底部功能按钮整组禁用 ——
  pointer-events:none 同时挡住点击与悬停,再加透明度让"不能点"一眼可见。
  (服务端另有硬校验:非工位主人调用写接口会被拒绝,前端禁用只是体验层。)
*/
.doctor-page__footer.is-readonly {
  pointer-events: none;
  opacity: 0.55;
}
/*
  右侧控件组:`自动` 复选框 + 功能按钮 + `停止候诊` 开关 **合成一组**,
  一起靠右。用 gap 统一间距(并清掉 Element Plus 给相邻 el-button 的
  默认 margin-left:12px,否则会与 gap 叠加导致间距不均)。