yxh
yxh
5 天以前 09dc74e264b6262cbe41c9c8476e24705871d4a6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
<!--
  医生工作台通用布局(叫号检查 / 设备领用 / 装机 三页共用)
 
  【解决什么问题】
  原先三个页面的功能按钮放在 `.right-bottom { position: absolute; bottom: 50px }`,
  **脱离文档流**,因此"已完成患者列表"变长后会直接铺到按钮底下、把
  「叫号 / 过号 / 重叫 / 暂停 / 撤销叫号」挡住;
  且外层容器没有确定高度,中间区无法产生滚动,内容只能一路把页面撑长。
 
  【布局契约】——纵向三段 + 右侧栏(中间有**可拖动分界线**)
  ┌────────────────────────────────────────────┐
  │ #header  标题栏(固定高度,不滚动)          │  flex: none
  ├──────────────────────────────┬─────────────┤
  │ #default 主体(滚动区)       │ #aside      │  仅中间区与右栏各自滚动
  │  · 患者信息(由各面板内部    │ (独立滚动)│  两者之间的竖线**可左右拖动**
  │    设为 sticky,钉在顶部)   │             │  以调整右栏宽度
  │  · 已完成记录列表(滚动)    │             │
  ├──────────────────────────────┴─────────────┤
  │ #footer  功能按钮(固定底部,永不被遮挡)    │  flex: none
  └────────────────────────────────────────────┘
 
  【关键实现点】
  1. 根元素 `height:100%; display:flex; flex-direction:column; overflow:hidden`
     —— 让"整页"有确定高度,中间区才可能滚动(而不是把页面撑长)。
  2. 中间区必须有 `min-height: 0`。flex 子项默认 `min-height: auto`
     会**拒绝收缩到内容高度以下**,从而 `overflow-y:auto` 永不生效 ——
     这是本类布局最常见的坑,别删。
  3. `#footer` 在**正常文档流**中(不再用 position:absolute),
     带不透明背景与上边框,内容永远不会透过来或盖住它。
  4. 根元素**自带不透明背景**:各面板内 sticky 的患者信息若不透明,
     滚动内容会从它与边缘的缝隙透出,故整页统一底色。
  5. 【本次新增】分界线可拖动(见下),宽度记在 localStorage 里,切页/刷新后保持。
 
  【插槽】
    header   标题栏(可选)
    default  主体内容(滚动区)
    aside    右侧栏(可选;独立滚动,如患者队列)
    footer   底部功能按钮(可选;固定在页面底部)
-->
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import BedSwitchSelect from '@/views/ecg/doctor/components/BedSwitchSelect.vue'
import { useRoomStore } from '@/store/modules/room'
 
defineOptions({ name: 'DoctorPageLayout' })
 
/* ==================== 主/围观(只读模式) ==================== */
 
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()
 
/**
 * 是否显示顶部提示条。
 *
 * 判定:**只要当前不可操作**就提示 —— 含两种情况:
 *   ① 还没选定要看哪个工位(未入座也没围观);
 *   ② 正在围观别人的工位(我入座的 ≠ 我正在看的)。
 */
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',
    minAsideWidth: 280,
    minContentWidth: 520,
    storageKey: 'ecg.doctor.asideWidth',
    bedSwitchEnabled: true,
    bedSwitchOpTypes: null
  }
)
 
/**
 * 「默认」右栏宽度(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)
  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%` 的解析,改为**实测父容器可用高度**。
 *
 * 背景(真实故障):原先靠 `.doctor-page { height: 100% }` 撑起高度。
 * 该写法要求**父级有确定高度**,而本组件挂在 `AppView` 的 `<section>` 下,
 * 那条链上任何一环(`el-scrollbar__view` 的 99%、`AppView` 的 `min-height` 等)
 * 一旦不成立,`height:100%` 就会退化成 `auto` ——
 * 于是 `.doctor-page` **随内容长高**,flex 的 `flex:none` footer 被推到视口下方,
 * 用户必须滚动页面才能看到 footer。这正是用户反馈的现象。
 *
 * 解决:用 `ResizeObserver` 量出**父元素的实际内容高度**,以内联 style 直接给根元素
 * 设定像素高度。这样无论外层是 flex、block 还是百分比链,
 * 我们的三段式布局都拿到一个**确定的**高度,footer 必然贴在容器底部、无需滚动。
 *
 * 若父级高度不可测(罕见),`height` 保持 undefined,
 * 此时仍回退到 CSS 的 `height: 100%`,不会比原来更差。
 */
const rootRef = ref<HTMLElement>()
const measuredHeight = ref<string | undefined>(undefined)
 
// ==================== 右侧栏宽度(可拖动分界线) ====================
 
/** 主体行容器:拖动时用它算"右侧栏最多能有多宽" */
const bodyRef = ref<HTMLElement>()
 
/** 右侧栏当前宽度(px):初始值也走自适应,窄屏不会一上来就占掉 400px */
const asidePx = ref<number>(calcDefaultAsidePx())
 
/** 是否正在拖动(用于换光标、禁选中) */
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 = () => {
  asideUserSet.value = true
  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)
      asideUserSet.value = true // 有记忆值 = 医生手动调过,后续不再跟随窗口自适应
    }
  } catch {
    /* ignore */
  }
}
 
/** 双击分界线:恢复默认宽度(重新按当前窗口自适应)并清除记忆 */
const resetAsideWidth = () => {
  asideUserSet.value = false
  asidePx.value = clampAsidePx(calcDefaultAsidePx())
  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
 
const measure = () => {
  const el = rootRef.value
  const parent = el?.parentElement
  if (!el || !parent) return
 
  const cs = getComputedStyle(parent)
  const inner =
    parent.clientHeight -
    parseFloat(cs.paddingTop || '0') -
    parseFloat(cs.paddingBottom || '0')
 
  // 父级高度不可测(auto/0)时保留 CSS 回退,不设内联高度
  measuredHeight.value = inner > 0 ? `${inner}px` : undefined
 
  // 宽度变化(含窗口缩放)时重新夹一次右栏宽度,
  // 否则拖宽后把窗口缩小,右侧栏可能把左侧内容挤没。
  //
  // 【分辨率自适应】没手动调过的(首次使用 / 双击复位过)→ 跟随窗口重新算默认宽度,
  // 这样同一位医生换到小屏笔记本上自动收窄;手动调过的只做夹紧,尊重他的选择。
  asidePx.value = asideUserSet.value
    ? clampAsidePx(asidePx.value)
    : clampAsidePx(calcDefaultAsidePx())
}
 
/** 用 rAF 合并连续触发,避免 ResizeObserver 循环告警 */
const scheduleMeasure = () => {
  if (rafId) cancelAnimationFrame(rafId)
  rafId = requestAnimationFrame(() => {
    rafId = 0
    measure()
  })
}
 
onMounted(() => {
  measure()
  loadAsidePx()
  const parent = rootRef.value?.parentElement
  if (parent && typeof ResizeObserver !== 'undefined') {
    ro = new ResizeObserver(scheduleMeasure)
    ro.observe(parent)
  } else {
    window.addEventListener('resize', scheduleMeasure)
  }
})
 
onBeforeUnmount(() => {
  if (rafId) cancelAnimationFrame(rafId)
  ro?.disconnect()
  ro = undefined
  window.removeEventListener('resize', scheduleMeasure)
})
</script>
 
<template>
  <div
    ref="rootRef"
    class="doctor-page"
    :class="{ 'is-resizing': dragging, 'is-compact': isCompact }"
    :style="{ height: measuredHeight }"
  >
    <!--
      标题栏:固定高度,不滚动。
      右侧固定放「工位切换 / 围观」下拉(主/围观模型的入口)——放在布局层,
      叫号检查 / 设备领用 / 装机 三页自动都有,不用各页重复加。
    -->
    <div v-if="$slots.header" class="doctor-page__header">
      <div class="doctor-page__header-left">
        <slot name="header"></slot>
      </div>
      <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>
 
    <!-- 围观模式(只读)/ 未选工位 提示条:让医生一眼知道"现在只能看,不能操作" -->
    <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 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: `${asidePx}px`, flex: '0 0 auto' }"
      >
        <slot name="aside"></slot>
      </div>
    </div>
 
    <!--
      功能按钮:正常文档流中的固定底部栏,永不被内容遮挡。
      需求:`自动`、`停止候诊` 与 `叫号/过号/重叫/暂停/撤销叫号` **合成一组**,
            一起**靠右**(对着右侧候诊表),不要被拆散到左边/中间。
    -->
    <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>
    </div>
  </div>
</template>
 
<style scoped lang="scss">
.doctor-page {
  display: flex;
  flex-direction: column;
  /*
    兜底高度:仅在 `measuredHeight` 未测到时生效(见 script 中的说明)。
    真正的确定性高度来自 ResizeObserver 实测后写入的行内 style ——
    因为 `height: 100%` 依赖父级有确定高度,这条链在外层布局变化时会失效,
    导致根元素随内容长高、把 footer 推到视口下方(用户反馈的现象)。
  */
  height: 100%;
  overflow: hidden;
  /* 不透明底色:面板内 sticky 的患者信息与边缘缝隙不会透出滚动内容 */
  background-color: var(--el-bg-color);
}
 
/* 拖动分界线过程中:全局换成左右箭头光标,并禁止选中文字(否则会拖出一片蓝色选区) */
.doctor-page.is-resizing {
  cursor: col-resize;
  user-select: none;
}
 
/* ---------- 标题栏 ---------- */
.doctor-page__header {
  flex: none;
  height: 40px;
  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;
}
 
/* 右上角工具区:显示密度 + 工位切换(都固定靠右,不参与压缩) */
.doctor-page__header-tools {
  display: flex;
  flex: none;
  align-items: center;
  gap: 8px;
}
 
.doctor-page__density {
  width: 104px;
}
 
/* ==========================================================================
   【本次需求】工作台按钮统一尺寸 / 字号(方便鼠标击中)
 
   原来混用 `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);
}
 
/* ---------- 主体 ---------- */
.doctor-page__body {
  display: flex;
  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;
}
 
/*
  内容区:**自身不滚动**(需求:滚动条要长在「已完成记录列表」上,而不是中间区整体)。
  因此这里 flex 纵向排列 + overflow-y:auto:
    · 患者信息(面板里的 el-form)flex:none,固定在上方;
    · 记录列表(.records-host)flex:1;记录列表**自己滚动**
      (各面板内 `.records-wrap :deep(.el-card__body) { max-height: calc(100% - 545px); overflow-y:auto }`
       配合 `.el-table__header-wrapper { position: sticky }` 固定表头)。
 
  这里的 `overflow-y: auto` 只在**空间实在不足**(患者信息 + 按钮把高度吃光)时生效,
  作为兜底;正常情况不会出现滚动条。
  注意必须保留 min-height:0,否则 flex 子项不会收缩,记录列表拿不到受限高度。
 
  ⚠️ 右边框已去掉:分界线改由 `.doctor-page__splitter` 提供,
     否则「3px 右边框 + 分界线」会并排成两条线。
*/
.doctor-page__content {
  display: flex;
  flex: 1;
  flex-direction: column;
  min-width: 0;
  min-height: 0;
  padding: 6px 10px;
  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);
  }
}
 
/* ---------- 右侧栏 ---------- */
.doctor-page__aside {
  flex: none;
  /* 允许右栏自身收缩,配合 overflow 独立滚动 */
  min-width: 0;
  min-height: 0;
  /* 右栏独立滚动,长列表不会把底部按钮顶走 */
  overflow-y: auto;
  border: solid var(--el-color-primary-light-7);
  border-left: none;
}
 
/* ---------- 底部功能按钮 ---------- */
.doctor-page__footer {
  display: flex;
  flex: none;
  /* 需求:所有控件「一行显示」且整组靠右 —— 不允许换行,否则 footer 变两行、
     会占掉记录区高度(原先 flex-wrap:wrap 在 1366 宽下就会折行)。 */
  flex-wrap: nowrap;
  align-items: center;
  /* 整组靠右:右侧不留 padding,使按钮组与右侧 #aside(候诊表)右边线对齐 */
  justify-content: flex-end;
  padding: 4px 0 4px 8px;
  /* 固定底部:有背景/上边框,内容不会透过来 */
  background-color: var(--el-bg-color);
  border-top: 1px solid var(--el-border-color);
}
 
/*
  围观模式(只读):底部功能按钮整组禁用 ——
  pointer-events:none 同时挡住点击与悬停,再加透明度让"不能点"一眼可见。
  (服务端另有硬校验:非工位主人调用写接口会被拒绝,前端禁用只是体验层。)
*/
.doctor-page__footer.is-readonly {
  pointer-events: none;
  opacity: 0.55;
}
 
/*
  右侧控件组:`自动` 复选框 + 功能按钮 + `停止候诊` 开关 **合成一组**,
  一起靠右。用 gap 统一间距(并清掉 Element Plus 给相邻 el-button 的
  默认 margin-left:12px,否则会与 gap 叠加导致间距不均)。
*/
.doctor-page__footer-right {
  display: flex;
  flex: none;
  gap: 8px;
  align-items: center;
  white-space: nowrap;
 
  :deep(.el-button + .el-button) {
    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>