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>