| | |
| | | 「叫号 / 过号 / 重叫 / 暂停 / 撤销叫号」挡住; |
| | | 且外层容器没有确定高度,中间区无法产生滚动,内容只能一路把页面撑长。 |
| | | |
| | | 【布局契约】——纵向三段 + 右侧栏 |
| | | 【布局契约】——纵向三段 + 右侧栏(中间有**可拖动分界线**) |
| | | ┌────────────────────────────────────────────┐ |
| | | │ #header 标题栏(固定高度,不滚动) │ flex: none |
| | | ├──────────────────────────────┬─────────────┤ |
| | | │ #default 主体(滚动区) │ #aside │ 仅中间区与右栏各自滚动 |
| | | │ · 患者信息(由各面板内部 │ (独立滚动)│ |
| | | │ 设为 sticky,钉在顶部) │ │ |
| | | │ · 患者信息(由各面板内部 │ (独立滚动)│ 两者之间的竖线**可左右拖动** |
| | | │ 设为 sticky,钉在顶部) │ │ 以调整右栏宽度 |
| | | │ · 已完成记录列表(滚动) │ │ |
| | | ├──────────────────────────────┴─────────────┤ |
| | | │ #footer 功能按钮(固定底部,永不被遮挡) │ flex: none |
| | |
| | | 带不透明背景与上边框,内容永远不会透过来或盖住它。 |
| | | 4. 根元素**自带不透明背景**:各面板内 sticky 的患者信息若不透明, |
| | | 滚动内容会从它与边缘的缝隙透出,故整页统一底色。 |
| | | 5. 【本次新增】分界线可拖动(见下),宽度记在 localStorage 里,切页/刷新后保持。 |
| | | |
| | | 【插槽】 |
| | | header 标题栏(可选) |
| | |
| | | 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%` 的解析,改为**实测父容器可用高度**。 |
| | |
| | | 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 |
| | | |
| | |
| | | |
| | | // 父级高度不可测(auto/0)时保留 CSS 回退,不设内联高度 |
| | | measuredHeight.value = inner > 0 ? `${inner}px` : undefined |
| | | |
| | | // 宽度变化(含窗口缩放)时重新夹一次右栏宽度, |
| | | // 否则拖宽后把窗口缩小,右侧栏可能把左侧内容挤没。 |
| | | asidePx.value = clampAsidePx(asidePx.value) |
| | | } |
| | | |
| | | /** 用 rAF 合并连续触发,避免 ResizeObserver 循环告警 */ |
| | |
| | | |
| | | onMounted(() => { |
| | | measure() |
| | | loadAsidePx() |
| | | const parent = rootRef.value?.parentElement |
| | | if (parent && typeof ResizeObserver !== 'undefined') { |
| | | ro = new ResizeObserver(scheduleMeasure) |
| | |
| | | </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"> |
| | | <slot name="header"></slot> |
| | | <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> |
| | |
| | | 需求:`自动`、`停止候诊` 与 `叫号/过号/重叫/暂停/撤销叫号` **合成一组**, |
| | | 一起**靠右**(对着右侧候诊表),不要被拆散到左边/中间。 |
| | | --> |
| | | <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> |
| | |
| | | background-color: var(--el-bg-color); |
| | | } |
| | | |
| | | /* 拖动分界线过程中:全局换成左右箭头光标,并禁止选中文字(否则会拖出一片蓝色选区) */ |
| | | .doctor-page.is-resizing { |
| | | cursor: col-resize; |
| | | user-select: none; |
| | | } |
| | | |
| | | /* ---------- 标题栏 ---------- */ |
| | | .doctor-page__header { |
| | | flex: none; |
| | |
| | | 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); |
| | | } |
| | | |
| | | /* ---------- 主体 ---------- */ |
| | |
| | | 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; |
| | | } |
| | | |
| | | /* |
| | |
| | | 这里的 `overflow-y: auto` 只在**空间实在不足**(患者信息 + 按钮把高度吃光)时生效, |
| | | 作为兜底;正常情况不会出现滚动条。 |
| | | 注意必须保留 min-height:0,否则 flex 子项不会收缩,记录列表拿不到受限高度。 |
| | | |
| | | ⚠️ 右边框已去掉:分界线改由 `.doctor-page__splitter` 提供, |
| | | 否则「3px 右边框 + 分界线」会并排成两条线。 |
| | | */ |
| | | .doctor-page__content { |
| | | display: flex; |
| | |
| | | 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); |
| | | } |
| | | } |
| | | |
| | | /* ---------- 右侧栏 ---------- */ |
| | |
| | | } |
| | | |
| | | /* |
| | | 围观模式(只读):底部功能按钮整组禁用 —— |
| | | pointer-events:none 同时挡住点击与悬停,再加透明度让"不能点"一眼可见。 |
| | | (服务端另有硬校验:非工位主人调用写接口会被拒绝,前端禁用只是体验层。) |
| | | */ |
| | | .doctor-page__footer.is-readonly { |
| | | pointer-events: none; |
| | | opacity: 0.55; |
| | | } |
| | | |
| | | /* |
| | | 右侧控件组:`自动` 复选框 + 功能按钮 + `停止候诊` 开关 **合成一组**, |
| | | 一起靠右。用 gap 统一间距(并清掉 Element Plus 给相邻 el-button 的 |
| | | 默认 margin-left:12px,否则会与 gap 叠加导致间距不均)。 |