yxh
2026-10-18 01ee694a3bb8048372fc793a8de314d70b688b35
修改了工位
已修改10个文件
已添加1个文件
682 ■■■■■ 文件已修改
src/api/ecg/room/index.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/config/axios/service.ts 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/hooks/web/useIdleLogout.ts 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/permission.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/releaseBed.ts 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/seatGuard.ts 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/checktype/QueueSequenceForm.vue 320 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/doctor/DevInstall.vue 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/doctor/Routine.vue 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/doctor/index.vue 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/room/index.vue 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/ecg/room/index.ts
@@ -167,6 +167,16 @@
    return await request.get({ url: `/clinic/room/close-biz` })
  },
  /**
   * ç®¡ç†å‘˜å¼ºåˆ¶æ¸…空工位(医生下班忘离座 / æ¢äººä½¿ç”¨åŒä¸€å·¥ä½ï¼‰ã€‚
   *
   * ä¸ŽåŒ»ç”Ÿè‡ªå·±çš„「离座」不同:这里可以清**任何人**占用的工位。
   * åŽç«¯åªåœ¨è¯¥å·¥ä½å¤„于「医生在岗 / æš‚停」且已被占用时才生效,空闲工位会报"状态不允许"。
   */
  forceClearBed: async (params: { roomId: number | null; bedNo: string | null }) => {
    return await request.post({ url: `/clinic/room/force-clear-bed`, params })
  },
  /** ã€Œé€‰æ‹©å·¥ä½æ¨¡å¼ã€æŒ‰æœ¬æœºIP匹配工位(模式 1/3):返回后端识别到的IP + å€™é€‰å·¥ä½ï¼ˆå·²æŽ’序) */
  getIpBed: async () => {
    return await request.get({ url: `/clinic/room/get-ip-bed` })
src/config/axios/service.ts
@@ -14,7 +14,6 @@
import { resetRouter } from '@/router'
import { deleteUserCache } from '@/hooks/web/useCache'
import { releaseBedOnExit } from '@/utils/releaseBed'
const tenantEnable = import.meta.env.VITE_APP_TENANT_ENABLE
const { result_code, base_url, request_timeout } = config
@@ -24,6 +23,17 @@
  '无效的刷新令牌', // åˆ·æ–°ä»¤ç‰Œè¢«åˆ é™¤æ—¶ï¼Œä¸ç”¨æç¤º
  '刷新令牌已过期' // ä½¿ç”¨åˆ·æ–°ä»¤ç‰Œï¼Œåˆ·æ–°èŽ·å–æ–°çš„è®¿é—®ä»¤ç‰Œæ—¶ï¼Œç»“æžœå› ä¸ºè¿‡æœŸå¤±è´¥ï¼Œæ­¤æ—¶éœ€è¦å¿½ç•¥ã€‚å¦åˆ™ï¼Œä¼šå¯¼è‡´ç»§ç»­ 401,无法跳转到登出界面
]
/**
 * ã€æœ¬éœ€æ±‚】"工位失效"类业务码:**不在这里弹全局提示**。
 *
 * å·¥ä½œå°æ¯ 5 ç§’轮询一次,若这里每次都 `ElNotification.error`,现场就会看到
 * ã€Œè¯·é‡æ–°å°±åº§ã€ä¸€ç›´åœ¨è·³ã€‚改由工作台页面用 {@link utils/seatGuard} ä¸€æ¬¡æ€§æç¤ºï¼Œ
 * å¹¶åœæŽ‰è‡ªå·±çš„轮询。
 *
 * 1_010_002_001 = è¯·é‡æ–°å°±åº§ï¼ˆROOM_NOT_SIT)
 */
const SILENT_BIZ_CODES: number[] = [1010002001]
// æ˜¯å¦æ˜¾ç¤ºé‡æ–°ç™»å½•
export const isRelogin = { show: false }
// Axios æ— æ„ŸçŸ¥åˆ·æ–°ä»¤ç‰Œï¼Œå‚考 https://www.dashingdog.cn/article/11 ä¸Ž https://segmentfault.com/a/1190000020210980 å®žçް
@@ -177,9 +187,16 @@
        console.log(msg)
        return handleAuthorized()
      } else {
        ElNotification.error({ title: msg })
        // ã€æœ¬éœ€æ±‚】工位失效类业务码不弹全局提示(由工作台一次性提示 + åœè½®è¯¢ï¼‰ï¼Œé¿å…æ¯ 5 ç§’刷屏
        if (SILENT_BIZ_CODES.indexOf(Number(code)) === -1) {
          ElNotification.error({ title: msg })
        }
      }
      return Promise.reject('error')
      // ã€æœ¬éœ€æ±‚】把业务码带进异常对象:页面可据此识别『已不是该工位主人』并停止轮询。
      // ä»ç”¨ Error å®žä¾‹ï¼Œä¿è¯æ—¢æœ‰çš„ e?.message ç”¨æ³•不受影响。
      const bizErr: any = new Error(msg)
      bizErr.code = Number(code)
      return Promise.reject(bizErr)
    } else {
      return data
    }
@@ -207,13 +224,16 @@
const handleAuthorized = () => {
  const { t } = useI18n()
  // ã€é‡Šæ”¾è¯Šç–—床位】鉴权失败 â‡’ æœ¬è½®ç™»å½•即将结束,必须先释放医生占用的床位,
  // å¦åˆ™è¯¥åºŠä½ä¼šä¸€ç›´æ˜¾ç¤ºè¢«å ç”¨ï¼ˆ`clinic_room.doc_id` ä¸ä¸ºç©ºï¼‰ï¼Œåˆ«çš„医生选不到。
  // ã€æœ¬æ¬¡å˜æ›´ã€‘鉴权失败**不再释放**诊室工位(`clinic_room.doc_id`)。
  //
  // âš ï¸ é¡ºåºè¦æ±‚:releaseBedOnExit å†…部要读 localStorage é‡Œçš„访问令牌来带
  // Authorization å¤´ï¼Œæ‰€ä»¥**必须在 removeToken() ä¹‹å‰**调用(不 await,
  // ä»¥å…é˜»å¡žä¸‹é¢çš„æ¸…理与跳转;失败也不影响流程)。
  void releaseBedOnExit('auth-failed')
  // åŽŸå®žçŽ°ï¼š401 / åˆ·æ–°ä»¤ç‰Œå¤±è´¥æ—¶ä¼šè°ƒ `releaseBedOnExit('auth-failed')`,
  // æŠŠåŒ»ç”Ÿå ç”¨çš„工位清空。但这会把"令牌到期"误当成"医生离开":
  // åŒ»ç”Ÿåªæ˜¯ç¦»å¼€ç”µè„‘超过 token æœ‰æ•ˆæœŸï¼ˆæˆ–网络抖动导致刷新失败),
  // å›žæ¥å°±è¢«è¿«é‡æ–°å…¥åº§ï¼Œå·¥ä½œå°è¿˜ä¼šæç¤º"请重新就坐"。
  //
  // çŽ°åœ¨çš„å£å¾„ï¼šå·¥ä½é‡Šæ”¾åªå‘ç”Ÿåœ¨**用户主动操作**(手动「离座」/「手动退出登录」),
  // ä»¤ç‰Œå¤±æ•ˆåªæ¸…本地凭证并跳登录页,重新登录后工位仍在,可直接继续使用。
  // åŒ»ç”ŸçœŸçš„下班忘离座的兜底:闭诊统一清空 + è¯Šå®¤ç®¡ç†é¡µã€Œæ¸…空工位」。
  // ã€ä¿®å¤ã€‘鉴权失败时**立即**清理本地凭证与用户缓存,不再等用户点击弹窗。
  //
src/hooks/web/useIdleLogout.ts
@@ -1,7 +1,6 @@
import { watch } from 'vue'
import { useIdle } from '@vueuse/core'
import { ElMessage } from 'element-plus'
import { releaseBedOnExit } from '@/utils/releaseBed'
/**
 * ç©ºé—²è¶…时自动退出(需求:30 åˆ†é’Ÿä¸æ“ä½œæ‰è¦æ±‚重新输入密码)。
@@ -18,6 +17,7 @@
 *   Â· æœ‰ä»»ä½•é¼ æ ‡/键盘/触摸/滚动操作 â†’ è®¡æ—¶å™¨é‡ç½®ï¼›
 *   Â· è¿žç»­ 30 åˆ†é’Ÿ**无任何操作** â†’ ä¸»åŠ¨æ¸…ç†ç™»å½•æ€å¹¶è·³è½¬ç™»å½•é¡µï¼ˆè¦æ±‚é‡æ–°è¾“å¯†ç ï¼‰ï¼›
 *   Â· æœ‰æ“ä½œæ—¶ä¸ä¸»åŠ¨ç™»å‡ºï¼ˆtoken è‡ªèº«çš„续期由 axios æ‹¦æˆªå™¨çš„ refresh-token è´Ÿè´£ï¼‰ã€‚
 *   Â· ã€æœ¬æ¬¡ã€‘空闲登出**不再释放诊室工位**(工位保留,重新登录即可继续使用)。
 *
 * ç”¨æ³•(在 permission.ts ä¸­è°ƒç”¨ä¸€æ¬¡å³å¯ï¼Œå†…部自带幂等保护):
 *   setupIdleLogout()
@@ -99,26 +99,28 @@
}
/**
 * å®žé™…执行空闲登出:释放床位 + æ¸…理本地凭证并跳转登录页。
 * å®žé™…执行空闲登出:**只清本地凭证并跳转登录页,不释放工位**。
 *
 * ä¹‹æ‰€ä»¥ä¸è°ƒç”¨ userStore.loginOut()(那是"主动退出"):
 *   Â· é‚£ä¸ªæŽ¥å£ä¼šè¯·æ±‚后端注销 token â€” å¯¹æœ¬åœºæ™¯æ²¡å¿…要,且可能因网络问题卡住;
 *   Â· ç©ºé—²ç™»å‡ºåªéœ€ä¿è¯æœ¬åœ°å‡­è¯æ¸…干净 + å›žåˆ°ç™»å½•页。
 *
 * âš ï¸ ä½†**必须**单独调一次「释放床位」:
 *   åŒ»ç”Ÿåœ¨ã€Œè¯Šå®¤é€‰æ‹©ã€é¡µé€‰å¥½åºŠä½åŽå°±å ç”¨äº†è¯¥åºŠä½ï¼ˆ`clinic_room.doc_id`)。
 *   åŽŸå…ˆæœ¬å‡½æ•°åªæ¸…æœ¬åœ°å‡­è¯ã€ä¸é€šçŸ¥åŽç«¯ â‡’ ç©ºé—²è¶…时后床位**一直显示被占用**,
 *   å…¶ä»–医生选不到这个床位。这正是"医生忘了离座就退出系统"的主要场景。
 * ã€é‡è¦å˜æ›´ï¼ˆæœ¬æ¬¡ï¼‰ã€‘空闲超时**不再释放诊室工位**。
 * ---------------------------------------------------------------
 * åŽŸå…ˆæœ¬å‡½æ•°ä¼šå…ˆè°ƒ `releaseBedOnExit('idle-timeout')`,把 `clinic_room.doc_id` æ¸…空。
 * å®žæµ‹é—®é¢˜ï¼šåŒ»ç”Ÿç¦»å¼€ç”µè„‘ 30 åˆ†é’Ÿä»¥ä¸Šï¼ˆä¾‹å¦‚去做检查、离开 1 å°æ—¶å›žæ¥ï¼‰ï¼Œ
 * å›žæ¥æ—¶å·¥ä½å·²ç»è¢«é‡Šæ”¾ â†’ å·¥ä½œå°åªèƒ½åªè¯»ã€å¹¶æç¤º"请重新就坐",
 * è€ŒåŒ»ç”Ÿæœ¬äººå¹¶æ²¡æœ‰ç¦»åº§ã€‚这不符合实际使用场景。
 *
 * çŽ°åœ¨çš„å£å¾„ï¼š
 *   Â· ç©ºé—²è¶…æ—¶ â†’ **只要求重新输入密码**(token æ¸…掉、回登录页),**工位保留**;
 *   Â· é‡æ–°ç™»å½•后 `roomStore.fetchMySeat()` ä¼šè‡ªåŠ¨æ¢å¤ã€Œæˆ‘çš„å·¥ä½ã€ï¼Œå¯ç›´æŽ¥ç»§ç»­ä½¿ç”¨ï¼›
 *   Â· å·¥ä½çš„释放只发生在**用户主动操作**:手动「离座」、手动「退出登录」。
 *   Â· åŒ»ç”ŸçœŸçš„下班忘离座的兜底:① é—­è¯Šæ—¶ç³»ç»Ÿç»Ÿä¸€æ¸…空当班工位;② ç®¡ç†å‘˜å¯åœ¨
 *     è¯Šå®¤ç®¡ç†é¡µã€Œæ¸…空工位」。
 */
async function doIdleLogout(): Promise<void> {
  ElMessage.warning(`已超过 ${IDLE_TIMEOUT_MINUTES} åˆ†é’Ÿæœªæ“ä½œï¼Œè¯·é‡æ–°ç™»å½•`)
  // å…ˆé‡Šæ”¾åºŠä½ï¼Œå†æ¸…凭证 â€”— é¡ºåºä¸èƒ½åï¼šé‡Šæ”¾æŽ¥å£éœ€è¦æºå¸¦å½“前令牌
  try {
    await releaseBedOnExit('idle-timeout')
  } catch {
    /* releaseBedOnExit å†…部已兜底,不会抛错;此处仅为防御性写法 */
  }
  cleanupCredentials()
src/permission.ts
@@ -13,7 +13,6 @@
import { useRoomStoreWithOut } from "@/store/modules/room";
// éœ€æ±‚:30 åˆ†é’Ÿæ— æ“ä½œæ‰è¦æ±‚重新登录(前端空闲检测)
import { setupIdleLogout } from '@/hooks/web/useIdleLogout'
import { setupBedReleaseOnUnload } from '@/utils/releaseBed'
const { start, done } = useNProgress()
@@ -222,10 +221,12 @@
  // ==========================================================================
  if (getAccessToken() && to.path !== '/login') {
    setupIdleLogout()
    // éœ€æ±‚:退出系统时自动退出占用的诊疗床位。
    // æ³¨å†Œ `pagehide` å…œåº•(关闭标签页/刷新/跳走)—— å†…部自带幂等保护,
    // åªæ˜¯"尽力而为"的最后一层;主要保障在 useIdleLogout / userStore.loginOut /
    // axios çš„ handleAuthorized ä¸‰å¤„明确的退出路径上。
    setupBedReleaseOnUnload()
    // ã€æœ¬æ¬¡å˜æ›´ã€‘**已移除** `setupBedReleaseOnUnload()`(pagehide + sendBeacon)。
    //
    // åŽŸè¡Œä¸ºï¼šå…³é—­æ ‡ç­¾é¡µ / åˆ·æ–° / è·³èµ°æ—¶éƒ½ä¼šé‡Šæ”¾å ç”¨çš„工位,导致
    //   Â· æŒ‰ F5 åˆ·æ–°ä¸€æ¬¡å°±ä¸¢å·¥ä½ï¼›
    //   Â· æµè§ˆå™¨å´©æºƒæ¢å¤ã€åˆ‡èµ°ä¸€ä¼šå„¿å›žæ¥ï¼Œä¹Ÿå¯èƒ½å›  pagehide ä¸¢å·¥ä½ã€‚
    // çŽ°åœ¨å·¥ä½åªåœ¨**用户主动操作**时释放:手动「离座」、手动「退出登录」。
    // åŒ»ç”Ÿä¸‹ç­å¿˜ç¦»åº§çš„兜底:① é—­è¯Šæ—¶ç»Ÿä¸€æ¸…空当班工位;② ç®¡ç†å‘˜ã€Œæ¸…空工位」。
  }
})
src/utils/releaseBed.ts
@@ -2,7 +2,7 @@
import { getAccessToken } from '@/utils/auth'
/**
 * é€€å‡ºç³»ç»Ÿæ—¶è‡ªåŠ¨é‡Šæ”¾è¯Šå®¤å·¥ä½ï¼ˆéœ€æ±‚ï¼šåŒ»ç”Ÿå¸¸å¿˜è®°ã€Œç¦»åº§ã€å°±é€€å‡ºç³»ç»Ÿï¼‰ã€‚
 * æ‰‹åŠ¨ã€Œé€€å‡ºç™»å½•ã€æ—¶é‡Šæ”¾è¯Šå®¤å·¥ä½ã€‚
 *
 * èƒŒæ™¯
 * ----
@@ -10,20 +10,23 @@
 * ï¼ˆåŽç«¯å†™åœ¨ `clinic_room.doc_id` / `status`)。
 * è‹¥åŒ»ç”Ÿä¸ç‚¹ã€Œç¦»åº§ã€è€Œç›´æŽ¥é€€å‡ºç³»ç»Ÿï¼ŒåºŠä½ä¼šä¸€ç›´æ˜¾ç¤ºè¢«å ç”¨ï¼Œå…¶ä»–医生无法使用。
 *
 * è¦†ç›–的退出路径
 * --------------
 *  1. æ‰‹åŠ¨ç‚¹ã€Œé€€å‡ºç™»å½•ã€ â†’ åŽç«¯ `/system/auth/logout` å·²ä¼šç¦»åº§ï¼ˆAuthController),
 *     æœ¬å‡½æ•°åœ¨å…¶ä¹‹å‰å†å…œä¸€æ¬¡ï¼Œä¿è¯å³ä½¿ logout è¯·æ±‚失败也已释放;
 *  2. **空闲 30 åˆ†é’Ÿè‡ªåŠ¨ç™»å‡º**(`useIdleLogout`)→ åŽŸå…ˆåªæ¸…æœ¬åœ°å‡­è¯ã€ä¸è°ƒåŽç«¯ â‡’ åºŠä½ä¸é‡Šæ”¾ï¼Œ
 *     è¿™æ˜¯æœ¬æ¬¡ä¿®å¤çš„æ ¸å¿ƒåœºæ™¯ï¼›
 *  3. **令牌失效/刷新失败被强制登出**(`config/axios/service.ts` çš„ `handleAuthorized`);
 *  4. å…³é—­/刷新页面前的 `pagehide` å…œåº•。
 * ã€é‡è¦å˜æ›´ï¼ˆæœ¬æ¬¡ï¼‰ã€‘只保留「手动退出登录」这一条路径
 * ---------------------------------------------------------------
 * åŽŸå…ˆè¦†ç›– 4 æ¡è·¯å¾„:手动退出、空闲 30 åˆ†é’Ÿè‡ªåŠ¨ç™»å‡ºã€ä»¤ç‰Œå¤±æ•ˆ(401)、`pagehide`(刷新/关页)。
 * å®žæµ‹é—®é¢˜ï¼šåŽä¸‰æ¡ä¼šæŠŠã€Œä»¤ç‰Œåˆ°æœŸ / åˆ·æ–°é¡µé¢ / ç¦»å¼€ä¸€ä¼šå„¿ã€è¯¯å½“成「医生离座」,
 * å¯¼è‡´å·¥ä½è¢«æ¸…空 â€”— åŒ»ç”Ÿå›žæ¥çœ‹åˆ°å·¥ä½œå°åªå‰©åªè¯»ã€å¹¶æç¤º"请重新就坐",而工位已变空闲。
 *
 * çŽ°åœ¨çš„å£å¾„ï¼š
 *   Â· **只有手动「退出登录」**(以及诊室选择页的手动「离座」)才释放工位;
 *   Â· ç©ºé—²è¶…时(`useIdleLogout`)只要求重新输密码,工位保留;
 *   Â· 401 / åˆ·æ–°ä»¤ç‰Œå¤±è´¥åªæ¸…本地凭证,工位保留;
 *   Â· ä¸å†æ³¨å†Œ `pagehide` å…œåº•(刷新页面不应该丢工位);
 *   Â· åŒ»ç”ŸçœŸçš„下班忘离座的兜底:① é—­è¯Šæ—¶ç³»ç»Ÿç»Ÿä¸€æ¸…空当班工位;② ç®¡ç†å‘˜ã€Œæ¸…空工位」。
 *
 * ä¸ºä»€ä¹ˆç”¨**裸 axios** è€Œä¸æ˜¯é¡¹ç›®çš„ request
 * ----------------------------------------
 * é¡¹ç›®çš„ `request` æŒ‚了 401 æ‹¦æˆªå™¨ï¼Œè€Œæœ¬å‡½æ•°**恰恰是在 401 ä¹‹åŽ**被调用的。
 * è‹¥èµ°æ‹¦æˆªå™¨ï¼Œä¼šå†æ¬¡è§¦å‘"刷新令牌失败 â†’ å¼ºåˆ¶ç™»å‡º â†’ å†è°ƒæœ¬å‡½æ•°"的递归。
 * è£¸ axios ä¸æŒ‚任何拦截器,天然断开这条链。
 * æœ¬å‡½æ•°å¯èƒ½åœ¨é‰´æƒçŠ¶æ€ä¸ç¨³æ—¶è¢«è°ƒç”¨ï¼Œè‹¥èµ°é¡¹ç›® request çš„ 401 æ‹¦æˆªå™¨ï¼Œ
 * ä¼šå†æ¬¡è§¦å‘"刷新令牌失败 â†’ å¼ºåˆ¶ç™»å‡º"的递归。裸 axios ä¸æŒ‚任何拦截器,天然断开这条链。
 *
 * ä¸ºä»€ä¹ˆç”¨**服务端按 userId åæŸ¥**而不是前端传床位号
 * ------------------------------------------------
@@ -31,9 +34,7 @@
 * å°±ä¼š"释放不掉"。故后端提供无参接口 `/ecg/doctor/release-my-beds`,按当前登录用户
 * åæŸ¥å…¶å ç”¨çš„全部床位。前端只需带上令牌。
 *
 * âš ï¸ æœ¬æ–‡ä»¶**不要 import `@/config/axios/config`**:
 *   `config/axios/service.ts` éœ€è¦ import æœ¬æ–‡ä»¶ï¼ˆåœ¨ 401 å¤„理里释放床位),
 *   è‹¥æœ¬æ–‡ä»¶åå‘ import å®ƒçš„ config,会形成循环依赖。
 * âš ï¸ æœ¬æ–‡ä»¶**不要 import `@/config/axios/config`**:避免与 service.ts å½¢æˆå¾ªçŽ¯ä¾èµ–ã€‚
 *   è¿™é‡Œç›´æŽ¥ç”¨åŒä¸€ä¸ªçŽ¯å¢ƒå˜é‡æ‹¼æŽ¥ï¼Œæ•ˆæžœç­‰ä»·ä¸”æ— çŽ¯ã€‚
 *
 * å¤±è´¥å¤„理:本函数**永不抛异常**(退出流程不应因它而中断)。
@@ -68,39 +69,4 @@
  } finally {
    releasing = false
  }
}
/**
 * æ³¨å†Œ `pagehide` å…œåº•:关闭标签页/刷新/跳走时尝试释放床位。
 *
 * ç”¨ `sendBeacon` è€Œéž ajax â€”— é¡µé¢å¸è½½æ—¶æ™®é€šè¯·æ±‚会被浏览器取消,beacon ä¸ä¼šã€‚
 * ä½† beacon **无法自定义请求头**(不能带 Authorization),故改用 URL ä¼ ä»¤ç‰Œï¼š
 * åŽç«¯ `SecurityProperties.tokenParameter` é»˜è®¤å°±æ˜¯ `"token"`,
 * ä¸” `TokenAuthenticationFilter` ä¼šè¯»å–它(见 `obtainAuthorization`)。
 *
 * æ³¨æ„ï¼šæœ¬é¡¹æ˜¯**尽力而为**的兜底,不作为主要手段 â€”—
 * ä¸»è¦æ‰‹æ®µæ˜¯ä¸Šé¢ 1/2/3 ä¸‰æ¡æ˜Žç¡®è·¯å¾„。
 */
export function setupBedReleaseOnUnload(): void {
  if (typeof window === 'undefined') return
  // å¹‚等:避免 HMR / é‡å¤è°ƒç”¨å¯¼è‡´é‡å¤æ³¨å†Œ
  if ((window as any).__bedReleaseUnloadBound) return
  ;(window as any).__bedReleaseUnloadBound = true
  window.addEventListener('pagehide', () => {
    const token = getAccessToken()
    if (!token) return
    try {
      const url = `${BASE_URL}/ecg/doctor/release-my-beds?token=${encodeURIComponent(token)}`
      const ok = navigator.sendBeacon?.(url)
      if (!ok) {
        // sendBeacon ä¸å¯ç”¨/被拒绝时,退回同步 XHR æœ€åŽä¸€æ
        const xhr = new XMLHttpRequest()
        xhr.open('POST', url, false) // åŒæ­¥ï¼Œç¡®ä¿å¸è½½å‰å‘出
        xhr.send()
      }
    } catch {
      /* å¿½ç•¥ï¼šå¸è½½é˜¶æ®µä¸åº”报错 */
    }
  })
}
src/utils/seatGuard.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,73 @@
import { ElMessageBox } from 'element-plus'
/**
 * å·¥ä½å®ˆå«ï¼šã€Œè¯·é‡æ–°å°±åº§ã€ç±»é”™è¯¯çš„一次性提示 + è½®è¯¢åœæ­¢è¾…助。
 *
 * èƒŒæ™¯
 * ----
 * å·¥ä½œå°ï¼ˆå«å·æ£€æŸ¥ / è®¾å¤‡é¢†ç”¨ / è®¾å¤‡è£…机)每 **5 ç§’** è½®è¯¢ä¸€æ¬¡ `bed-doctor-get`。
 * å½“当前账号已经不是该工位的主人(工位被释放 / åœ¨åˆ«çš„电脑入座 / è¢«ç®¡ç†å‘˜æ¸…空)时,
 * åŽç«¯è¿”回业务码 `1_010_002_001`「请重新就座」,前端原先**每轮都弹一次**,
 * çŽ°åœºçœ‹åˆ°çš„å°±æ˜¯"系统一直跳出请重新就座!"。
 *
 * è¿™é‡Œæä¾›ä¸¤ä¸ªèƒ½åŠ›ï¼š
 *   Â· åªæç¤ºä¸€æ¬¡ï¼ˆä¸€æ¬¡æ€§å¼¹çª—,带「去选工位」),不再刷屏;
 *   Â· é¡µé¢æ®æ­¤**停掉轮询定时器**,等重新选到工位后再恢复。
 *
 * åˆ¤å®šä¾æ®ï¼šåŽç«¯ {@code ErrorCodeConstants.ROOM_NOT_SIT = 1_010_002_001}。
 */
/** ã€Œè¯·é‡æ–°å°±åº§ã€ä¸šåŠ¡ç ï¼ˆåŽç«¯ ROOM_NOT_SIT) */
export const SEAT_INVALID_CODE = 1010002001
/** æŸä¸ªè¯·æ±‚异常是否属于"已不是该工位主人" */
export function isSeatInvalidError(e: any): boolean {
  if (!e) return false
  const code = Number(e.code ?? e.bizCode ?? NaN)
  if (Number.isFinite(code) && code === SEAT_INVALID_CODE) return true
  // å…œåº•:个别链路只把 msg ä¼ ä¸Šæ¥
  const msg = String(e.message ?? e.msg ?? e)
  return msg.indexOf('请重新就座') !== -1
}
let notified = false
/** æ˜¯å¦å·²ç»æç¤ºè¿‡ï¼ˆä¾¿äºŽé¡µé¢åˆ¤æ–­ï¼‰ */
export function isSeatInvalidNotified(): boolean {
  return notified
}
/**
 * å·¥ä½å¤±æ•ˆæç¤ºï¼š**整个会话只弹一次**,避免 5 ç§’轮询把提示刷成"一直跳"。
 *
 * ç‚¹ã€ŒåŽ»é€‰å·¥ä½ã€â†’ æ•´é¡µè·³è½¬åˆ°é€‰å·¥ä½é¡µï¼ˆç”¨ location è€Œéž router.push:
 * é€‰å·¥ä½é¡µæ˜¯é¡¶å±‚路由,整页跳转能顺带重置各工作台的定时器与本地状态)。
 */
export function notifySeatInvalidOnce(): void {
  if (notified) return
  notified = true
  ElMessageBox.confirm(
    '当前账号已不是这个工位的主人(可能工位已被释放、在其它电脑上入座,或被管理员清空)。\n' +
      '请重新选择工位后再操作。',
    '请重新就座',
    {
      confirmButtonText: '去选工位',
      cancelButtonText: '稍后',
      type: 'warning',
      closeOnClickModal: false
    }
  )
    .then(() => {
      window.location.href = '/roomselect'
    })
    .catch(() => {
      /* ç”¨æˆ·é€‰æ‹©ã€Œç¨åŽã€ï¼šä¿æŒé¡µé¢åªè¯»ï¼Œä¸å†é‡å¤å¼¹ */
    })
}
/**
 * é‡æ–°èŽ·å¾—å·¥ä½ï¼ˆé‡æ–°å…¥åº§ / åˆ‡æ¢å·¥ä½ / è‡ªåŠ¨æ¢å¤ï¼‰åŽå¤ä½ï¼Œå…è®¸ä¸‹æ¬¡å†æç¤ºä¸€æ¬¡ã€‚
 */
export function resetSeatInvalidNotify(): void {
  notified = false
}
src/views/ecg/checktype/QueueSequenceForm.vue
@@ -7,7 +7,7 @@
    èƒŒæ™¯ï¼šè¯¥è¡¨åŽŸå…ˆæ¯å¤©è‡ªåŠ¨ç”Ÿæˆï¼ŒçŽ°å·²æ”¹ä¸ºæ‰‹å·¥ç»´æŠ¤ï¼Œæ­¤å‰æ²¡æœ‰ç»´æŠ¤ç•Œé¢ã€‚
  -->
  <Dialog :title="dialogTitle" v-model="dialogVisible" width="1000px">
  <Dialog :title="dialogTitle" v-model="dialogVisible" width="65%" >
    <!-- é¡¶éƒ¨è¯´æ˜Žï¼šè¿™äº›å­—段的含义与"每日重置"的关系 -->
    <el-alert type="info" :closable="false" class="mb-10px">
      <template #title>
@@ -19,7 +19,8 @@
      <div class="text-12px leading-5">
        Â· <b>起始序号</b>:每天自动重置时回到该值;<b>上限</b>:到达后该时段视为「已满」,患者签到会顺延到后续时段。<br />
        Â· <b>当前序号</b>:已发到几号,一般无需手工改(每日重置会自动回到"起始序号")。<br />
        Â· <b>序号上限必须大于起始序号</b>,否则该时段永远无法取号。<br />
        Â· <b>预定额度</b> = ä¸Šé™ âˆ’ èµ·å§‹åºå·ï¼ˆåªè¯»ï¼‰ã€‚<b>上限 = èµ·å§‹åºå· è¡¨ç¤ºè¯¥æ—¶æ®µæ²¡æœ‰åé¢</b>(允许保存,预定额度显示 0 å¹¶æ ‡çº¢ï¼‰ã€‚<br />
        Â· ä¸Šé™ <b>不能小于</b>起始序号,否则该时段永远无法取号。<br />
        Â· åŒä¸€æ£€æŸ¥é¡¹ç›®ä¸‹<b>同一时段只能有一条规则</b>,重复添加会被拒绝。
      </div>
    </el-alert>
@@ -36,107 +37,155 @@
      </span>
    </div>
    <el-table v-loading="loading" :data="list" :stripe="true" border height="420px">
      <el-table-column label="时段" align="center" min-width="130px">
    <el-table
      v-loading="loading"
      :data="list"
      :stripe="true"
      border
      class="qs-table"
      height="calc(100vh - 300px)"
    >
      <!-- ============ â‘  æ—¶æ®µ ============ -->
      <el-table-column label="时段" align="center" width="96">
        <template #default="scope">
          <b>{{ formatTimeSlot(scope.row.timeSlot) }}</b>
          <div class="text-11px text-gray-400">{{ scope.row.timeSlot }}</div>
          <b class="text-12px" :title="String(scope.row.timeSlot ?? '')">
            {{ formatTimeSlot(scope.row.timeSlot) }}
          </b>
        </template>
      </el-table-column>
      <el-table-column label="普通-起始序号" align="center" min-width="120px">
        <template #default="scope">
          <el-input-number
            v-model="scope.row.queueSet"
            :min="0"
            :controls="false"
            size="small"
            style="width: 100%"
            @change="markChanged(scope.row)"
          />
        </template>
      </el-table-column>
      <el-table-column label="普通-上限" align="center" min-width="110px">
        <template #default="scope">
          <el-input-number
            v-model="scope.row.queueFull"
            :min="1"
            :controls="false"
            size="small"
            style="width: 100%"
            @change="markChanged(scope.row)"
          />
        </template>
      </el-table-column>
      <el-table-column label="普通-当前序号" align="center" min-width="115px">
        <template #default="scope">
          <el-input-number
            v-model="scope.row.queueNo"
            :min="0"
            :controls="false"
            size="small"
            style="width: 100%"
            @change="markChanged(scope.row)"
          />
        </template>
      <!-- ============ â‘¡ æ­£å¸¸é¢„约 ============ -->
      <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)"
            />
          </template>
        </el-table-column>
        <el-table-column label="上限" align="center" width="80">
          <template #default="scope">
            <el-input-number
              v-model="scope.row.queueFull"
              :min="0"
              :controls="false"
              size="small"
              style="width: 100%"
              @change="markChanged(scope.row)"
            />
          </template>
        </el-table-column>
        <el-table-column label="预定额度" align="center" width="90">
          <template #default="scope">
            <span
              class="qs-quota"
              :class="{ 'qs-quota--zero': quotaOf(scope.row.queueSet, scope.row.queueFull) <= 0 }"
              :title="`上限 ${scope.row.queueFull ?? 0} âˆ’ èµ·å§‹ ${scope.row.queueSet ?? 0}`"
            >
              {{ quotaOf(scope.row.queueSet, scope.row.queueFull) }}
            </span>
          </template>
        </el-table-column>
        <el-table-column label="当前序号" align="center" width="90">
          <template #default="scope">
            <el-input-number
              v-model="scope.row.queueNo"
              :min="0"
              :controls="false"
              size="small"
              style="width: 100%"
              @change="markChanged(scope.row)"
            />
          </template>
        </el-table-column>
        <el-table-column label="状态" align="center" width="90">
          <template #default="scope">
            <el-tag v-if="isFull(scope.row)" type="danger" size="small">已满</el-tag>
            <el-tag v-else type="success" size="small">可取号</el-tag>
          </template>
        </el-table-column>
      </el-table-column>
      <el-table-column label="VIP-起始序号" align="center" min-width="115px">
        <template #default="scope">
          <el-input-number
            v-model="scope.row.queueVipSet"
            :min="0"
            :controls="false"
            size="small"
            style="width: 100%"
            @change="markChanged(scope.row)"
          />
        </template>
      </el-table-column>
      <el-table-column label="VIP-上限" align="center" min-width="105px">
        <template #default="scope">
          <el-input-number
            v-model="scope.row.queueVipFull"
            :min="1"
            :controls="false"
            size="small"
            style="width: 100%"
            @change="markChanged(scope.row)"
          />
        </template>
      </el-table-column>
      <el-table-column label="VIP-当前序号" align="center" min-width="110px">
        <template #default="scope">
          <el-input-number
            v-model="scope.row.queueVipNo"
            :min="0"
            :controls="false"
            size="small"
            style="width: 100%"
            @change="markChanged(scope.row)"
          />
        </template>
      <!-- ============ â‘¢ é¢„ç•™ ============ -->
      <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)"
            />
          </template>
        </el-table-column>
        <el-table-column label="上限" align="center" width="80">
          <template #default="scope">
            <el-input-number
              v-model="scope.row.queueVipFull"
              :min="0"
              :controls="false"
              size="small"
              style="width: 100%"
              @change="markChanged(scope.row)"
            />
          </template>
        </el-table-column>
        <el-table-column label="预定额度" align="center" width="90">
          <template #default="scope">
            <span
              class="qs-quota"
              :class="{ 'qs-quota--zero': quotaOf(scope.row.queueVipSet, scope.row.queueVipFull) <= 0 }"
              :title="`上限 ${scope.row.queueVipFull ?? 0} âˆ’ èµ·å§‹ ${scope.row.queueVipSet ?? 0}`"
            >
              {{ quotaOf(scope.row.queueVipSet, scope.row.queueVipFull) }}
            </span>
          </template>
        </el-table-column>
        <el-table-column label="当前序号" align="center" width="90">
          <template #default="scope">
            <el-input-number
              v-model="scope.row.queueVipNo"
              :min="0"
              :controls="false"
              size="small"
              style="width: 100%"
              @change="markChanged(scope.row)"
            />
          </template>
        </el-table-column>
        <el-table-column label="状态" align="center" width="90">
          <template #default="scope">
            <el-tag v-if="isFullVip(scope.row)" type="danger" size="small">已满</el-tag>
            <el-tag v-else type="success" size="small">可取号</el-tag>
          </template>
        </el-table-column>
      </el-table-column>
      <el-table-column label="状态" align="center" width="90px">
        <template #default="scope">
          <el-tag v-if="isFull(scope.row)" type="danger" size="small">已满</el-tag>
          <el-tag v-else type="success" size="small">可取号</el-tag>
        </template>
      </el-table-column>
      <el-table-column label="操作" align="center" width="160px" fixed="right">
      <!-- ============ â‘£ æ“ä½œ ============ -->
      <el-table-column label="操作" align="center" width="160" fixed="right">
        <template #default="scope">
          <el-button
            link
            type="primary"
            :loading="savingId === scope.row.id"
            @click="handleRowSave(scope.row)"
          >
            ä¿å­˜
          </el-button>
          <el-button link type="primary" @click="openForm('update', scope.row)">编辑</el-button>
          <el-button link type="danger" @click="handleDelete(scope.row)">删除</el-button>
          <el-button link type="primary" @click="openForm('update', scope.row)">
            ç¼–辑
          </el-button>
          <el-button link type="danger" @click="handleDelete(scope.row)">
            åˆ é™¤
          </el-button>
        </template>
      </el-table-column>
    </el-table>
@@ -174,13 +223,15 @@
          <el-input-number v-model="formData.queueSet" :min="0" class="w-full" />
        </el-form-item>
        <el-form-item label="普通-上限" prop="queueFull">
          <el-input-number v-model="formData.queueFull" :min="1" class="w-full" />
          <!-- ã€æœ¬æ¬¡éœ€æ±‚】上限允许等于起始序号(=该时段无名额),故 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>
        <el-form-item label="VIP-上限" prop="queueVipFull">
          <el-input-number v-model="formData.queueVipFull" :min="1" class="w-full" />
          <!-- ã€æœ¬æ¬¡éœ€æ±‚】同普通上限:允许等于起始序号 -->
          <el-input-number v-model="formData.queueVipFull" :min="0" class="w-full" />
        </el-form-item>
        <el-form-item label="普通-当前序号">
          <el-input-number v-model="formData.queueNo" :min="0" class="w-full" />
@@ -256,8 +307,23 @@
    {
      validator: (_rule: any, value: any, callback: any) => {
        const set = formData.value.queueSet
        if (value !== undefined && value !== null && set !== undefined && set !== null && value <= set) {
          callback(new Error('上限必须大于起始序号,否则该时段无法取号'))
        // ã€æœ¬æ¬¡éœ€æ±‚】上限 = èµ·å§‹åºå· æ˜¯**合法**的:表示该时段没有名额(预定额度 0),允许保存。
        // åªæœ‰ã€Œä¸Šé™ < èµ·å§‹åºå·ã€æ‰æ˜¯é…ç½®é”™è¯¯ï¼ˆè¯¥æ—¶æ®µæ°¸è¿œæ— æ³•取号)。
        if (value !== undefined && value !== null && set !== undefined && set !== null && value < set) {
          callback(new Error('上限不能小于起始序号(等于表示该时段没有名额)'))
        } else {
          callback()
        }
      },
      trigger: 'blur'
    }
  ],
  queueVipFull: [
    {
      validator: (_rule: any, value: any, callback: any) => {
        const set = formData.value.queueVipSet
        if (value !== undefined && value !== null && set !== undefined && set !== null && value < set) {
          callback(new Error('预留上限不能小于预留起始序号(等于表示预留没有名额)'))
        } else {
          callback()
        }
@@ -306,15 +372,47 @@
  }
}
/** è¯¥æ—¶æ®µæ˜¯å¦å·²æ»¡ï¼ˆå½“前序号 >= ä¸Šé™ï¼‰ */
const isFull = (row: QueueSequenceVO) =>
  (row.queueNo ?? 0) >= (row.queueFull ?? 0) && (row.queueFull ?? 0) > 0
/**
 * é¢„定额度 = ä¸Šé™ âˆ’ èµ·å§‹åºå·ï¼ˆå°äºŽ 0 æŒ‰ 0 æ˜¾ç¤ºï¼‰ã€‚
 *
 * ã€æœ¬æ¬¡éœ€æ±‚】该列只读,值为 0 æ—¶å•元格浅红底 â€”— è¡¨ç¤ºè¿™ä¸ªæ—¶æ®µ**没有名额**。
 * ã€Œä¸Šé™ = èµ·å§‹åºå·ã€æ˜¯åˆæ³•配置(无名额时段),不再当作错误。
 */
const quotaOf = (set?: number | null, full?: number | null) =>
  Math.max(0, (full ?? 0) - (set ?? 0))
/**
 * è¯¥æ—¶æ®µæ˜¯å¦å·²æ»¡ï¼ˆæ­£å¸¸é¢„约)。
 *
 * æ»¡çš„两种情形:
 *  â‘  é¢„定额度 <= 0(上限 <= èµ·å§‹åºå·ï¼‰â†’ æœ¬æ¥å°±æ²¡æœ‰åé¢ï¼›
 *  â‘¡ å½“前序号已达到上限。
 */
const isFull = (row: QueueSequenceVO) => {
  if (quotaOf(row.queueSet, row.queueFull) <= 0) return true
  return (row.queueNo ?? 0) >= (row.queueFull ?? 0)
}
/** è¯¥æ—¶æ®µæ˜¯å¦å·²æ»¡ï¼ˆé¢„留组),口径同上 */
const isFullVip = (row: QueueSequenceVO) => {
  if (quotaOf(row.queueVipSet, row.queueVipFull) <= 0) return true
  return (row.queueVipNo ?? 0) >= (row.queueVipFull ?? 0)
}
/** è¡Œå†…保存(只改数值,不动时段) */
const handleRowSave = async (row: QueueSequenceVO) => {
  if (!row.id) return
  if ((row.queueFull ?? 0) <= (row.queueSet ?? 0)) {
    message.error(`时段 ${formatTimeSlot(row.timeSlot)}:上限必须大于起始序号`)
  // ã€æœ¬æ¬¡éœ€æ±‚】只有「上限 < èµ·å§‹åºå·ã€æ‰æ˜¯é”™è¯¯ï¼›ç›¸ç­‰è¡¨ç¤ºè¯¥æ—¶æ®µæ²¡æœ‰åé¢ï¼Œå…è®¸ä¿å­˜ã€‚
  if ((row.queueFull ?? 0) < (row.queueSet ?? 0)) {
    message.error(
      `时段 ${formatTimeSlot(row.timeSlot)}:上限不能小于起始序号(等于表示该时段没有名额)`
    )
    return
  }
  if ((row.queueVipFull ?? 0) < (row.queueVipSet ?? 0)) {
    message.error(
      `时段 ${formatTimeSlot(row.timeSlot)}:预留上限不能小于预留起始序号(等于表示预留没有名额)`
    )
    return
  }
  savingId.value = row.id
@@ -388,3 +486,35 @@
  await getList()
}
</script>
<style scoped lang="scss">
/*
  ã€æœ¬æ¬¡è°ƒæ•´ã€‘字号恢复为系统默认,**允许横向滚动**:
    Â· ä¸å†åŽ‹ç¼©å­—å·ä¸Žå†…è¾¹è· â€”— è¡¨ä½“恢复默认字号(14px)、输入框默认字号、状态标签用 small é»˜è®¤æ ·å¼ï¼›
    Â· åˆ—宽由模板固定(时段 96 + æ­£å¸¸é¢„约 5 åˆ— + é¢„ç•™ 5 åˆ— + æ“ä½œ 160);
      æ€»å®½è¶…过弹窗宽度时表格出现**横向滚动条**,操作列 fixed="right" å§‹ç»ˆå¯è§ï¼›
    Â· è¡¨æ ¼é«˜åº¦ calc(100vh - 300px):竖向多显示几行,页面本身不出现滚动条。
*/
.qs-table {
  /* è¡Œå†…数字输入占满单元格(不缩小字号,只控制宽度) */
  :deep(.el-input-number) {
    width: 100%;
  }
}
/* é¢„定额度(只读):值为 0 æ—¶æµ…红底,表示该时段没有名额 */
.qs-quota {
  display: inline-block;
  padding: 1px 6px;
  border-radius: 3px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--el-text-color-primary);
}
.qs-quota--zero {
  color: var(--el-color-danger);
  background-color: var(--el-color-danger-light-9);
  box-shadow: inset 0 0 0 1px var(--el-color-danger-light-5);
}
</style>
src/views/ecg/doctor/DevInstall.vue
@@ -9,8 +9,9 @@
import QueuePanel from "@/views/ecg/doctor/components/QueuePanel.vue";
import DoctorPageLayout from "@/components/DoctorPageLayout/src/DoctorPageLayout.vue";
import {DoctorApi, PatientStatisticVO, RoomBedVO} from '@/api/ecg/doctor';
import { notifySeatInvalidOnce, isSeatInvalidError, resetSeatInvalidNotify } from '@/utils/seatGuard'
import {QueueVO} from "@/api/ecg/queue";
import {ElMessage, ElMessageBox, ElNotification} from "element-plus";
import {ElMessage, ElMessageBox} from "element-plus";
import {useRoomStore} from "@/store/modules/room";
const roomStore = useRoomStore();
@@ -89,11 +90,9 @@
const initLoad = async () => {
  if (roomBedVO.value.roomId == null) {
    ElNotification.error({
      message: '请重新就座!',
      type: 'info',
      duration: 3000 // è‡ªåŠ¨å…³é—­æ—¶é—´ï¼Œé»˜è®¤ä¸º3000ms
    })
    // ã€æœ¬éœ€æ±‚】没有工位:停掉轮询并只提示一次(原来每 5 ç§’弹一次,现场表现就是一直跳)
    timerRunFlag = false
    notifySeatInvalidOnce()
    return
  }
@@ -102,7 +101,19 @@
  fetchInfo.value = `最近取数 ${new Date().toLocaleTimeString()} Â· è¿”回 ${patientList.value.length} æ¡`
  getInstallingPatient()
  patientStat.value = await DoctorApi.getDevInstallStatistic(roomBedVO.value)
  const queueVO2 = await DoctorApi.bedDoctorGet(roomBedVO.value)
  // ã€æœ¬éœ€æ±‚】bed-doctor-get æ˜¯ã€æˆ‘是否还坐在这里】的判定:失败说明工位已被释放 / ä»–人接手,
  // æ­¤æ—¶åœæŽ‰è½®è¯¢å¹¶ä¸€æ¬¡æ€§æç¤ºï¼Œé¿å…æ¯ 5 ç§’刷一次【请重新就座】。
  let queueVO2: any
  try {
    queueVO2 = await DoctorApi.bedDoctorGet(roomBedVO.value)
  } catch (e: any) {
    if (isSeatInvalidError(e)) {
      timerRunFlag = false
      notifySeatInvalidOnce()
      return
    }
    throw e
  }
  // ã€åŠ å›ºã€‘æŽ¥å£å¯èƒ½è¿”å›ž null,原来直接取 .status ä¼šæŠ›å¼‚常、中断本轮后续步骤
  if (queueVO2 && queueVO2.status === 20)
    state.value = true
@@ -239,6 +250,8 @@
    roomBedVO.value.bedNo = roomStore.room.bedNo
    roomBedVO.value.checkTypes = roomStore.room.checkTypes
    roomBedVO.value.opType = roomStore.room.opType
    // ã€æœ¬éœ€æ±‚】重新拿到工位 -> å¤ä½ã€å·¥ä½å¤±æ•ˆã€‘提示,允许下次再提示一次
    resetSeatInvalidNotify()
    // ã€æœ¬æ¬¡éœ€æ±‚】换工位后**整页数据都换成新工位的**:
    // â‘  å…ˆæ¸…空左侧"患者信息"(避免还显示上一个工位的患者)
    devInstallPanelRef.value?.setPatient?.(undefined)
@@ -266,6 +279,7 @@
    roomBedVO.value.bedNo = roomStore.room!.bedNo
    roomBedVO.value.checkTypes = roomStore.room!.checkTypes
    roomBedVO.value.opType = roomStore.room!.opType
    resetSeatInvalidNotify()
    timerRunFlag = true
    doctorTimer()
src/views/ecg/doctor/Routine.vue
@@ -9,8 +9,9 @@
import QueuePanel from '@/views/ecg/doctor/components/QueuePanel.vue'
import DoctorPageLayout from '@/components/DoctorPageLayout/src/DoctorPageLayout.vue'
import { DoctorApi, PatientStatisticVO, RoomBedVO, } from '@/api/ecg/doctor'
import { notifySeatInvalidOnce, isSeatInvalidError, resetSeatInvalidNotify } from '@/utils/seatGuard'
import { QueueVO } from '@/api/ecg/queue'
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import RoutinePanel from '@/views/ecg/doctor/components/RoutinePanel.vue'
import { useRoomStore } from '@/store/modules/room'
@@ -303,11 +304,9 @@
const initLoad = async () => {
  if (roomBedVO.roomId == null) {
    ElNotification.error({
      message: '请重新就座!',
      type: 'info',
      duration: 3000 // è‡ªåŠ¨å…³é—­æ—¶é—´ï¼Œé»˜è®¤ä¸º3000ms
    })
    // ã€æœ¬éœ€æ±‚】没有工位:**停掉轮询**并只提示一次(原来每 5 ç§’弹一次,现场表现就是"一直跳")
    timerRunFlag = false
    notifySeatInvalidOnce()
    return
  }
@@ -318,7 +317,19 @@
  loadRoomWaitingList()
  getOnstagePatient()
  patientStat.value = await DoctorApi.getPatientStatistic(roomBedVO)
  const queueVO2 = await DoctorApi.bedDoctorGet(roomBedVO)
  // ã€æœ¬éœ€æ±‚】bed-doctor-get æ˜¯"我是否还坐在这里"的判定:失败说明工位已被释放 / ä»–人接手,
  // æ­¤æ—¶åœæŽ‰è½®è¯¢å¹¶ä¸€æ¬¡æ€§æç¤ºï¼Œé¿å…æ¯ 5 ç§’刷一次"请重新就座"。
  let queueVO2: any
  try {
    queueVO2 = await DoctorApi.bedDoctorGet(roomBedVO)
  } catch (e: any) {
    if (isSeatInvalidError(e)) {
      timerRunFlag = false
      notifySeatInvalidOnce()
      return
    }
    throw e
  }
  // ã€åŠ å›ºã€‘æŽ¥å£å¯èƒ½è¿”å›ž null,原来直接取 .status ä¼šæŠ›å¼‚常、中断本轮后续步骤
  if (queueVO2 && queueVO2.status === 20) state.value = true
  else if (queueVO2 && queueVO2.status === 30) state.value = false
@@ -366,6 +377,8 @@
    roomBedVO.bedNo = roomStore.room.bedNo
    roomBedVO.checkTypes = roomStore.room.checkTypes
    roomBedVO.opType = roomStore.room.opType
    // ã€æœ¬éœ€æ±‚】重新拿到工位 -> å¤ä½ã€å·¥ä½å¤±æ•ˆã€‘提示,允许下次再提示一次
    resetSeatInvalidNotify()
    // ã€æœ¬æ¬¡éœ€æ±‚】换工位后**整页数据都换成新工位的**:
    // â‘  å…ˆæ¸…空左侧"患者信息"(避免还显示上一个工位的患者)
    routimePanelRef.value?.setPatient?.(undefined)
@@ -394,6 +407,7 @@
    roomBedVO.bedNo = roomStore.room!.bedNo
    roomBedVO.checkTypes = roomStore.room!.checkTypes
    roomBedVO.opType = roomStore.room!.opType
    resetSeatInvalidNotify()
    timerRunFlag = true
    doctorTimer()
src/views/ecg/doctor/index.vue
@@ -10,8 +10,9 @@
import QueuePanel from "@/views/ecg/doctor/components/QueuePanel.vue";
import DoctorPageLayout from "@/components/DoctorPageLayout/src/DoctorPageLayout.vue";
import {DoctorApi, PatientStatisticVO, RoomBedVO} from '@/api/ecg/doctor';
import { notifySeatInvalidOnce, isSeatInvalidError, resetSeatInvalidNotify } from '@/utils/seatGuard'
import {QueueVO} from "@/api/ecg/queue";
import {ElMessage, ElMessageBox, ElNotification} from "element-plus";
import {ElMessage, ElMessageBox} from "element-plus";
import DevReadyPanel from "@/views/ecg/doctor/components/DevReadyPanel.vue";
import {useRoomStore} from "@/store/modules/room";
@@ -243,11 +244,9 @@
const initLoad = async () => {
  if (roomBedVO.roomId == null) {
    ElNotification.error({
      message: '请重新就座!',
      type: 'info',
      duration: 3000 // è‡ªåŠ¨å…³é—­æ—¶é—´ï¼Œé»˜è®¤ä¸º3000ms
    })
    // ã€æœ¬éœ€æ±‚】没有工位:停掉轮询并只提示一次(原来每 5 ç§’弹一次,现场表现就是一直跳)
    timerRunFlag = false
    notifySeatInvalidOnce()
    return
  }
@@ -258,7 +257,19 @@
  loadRoomWaitingList()
  getOnstagePatient()
  patientStat.value = await DoctorApi.getDevReadyStatistic(roomBedVO)
  const queueVO2 = await DoctorApi.bedDoctorGet(roomBedVO)
  // ã€æœ¬éœ€æ±‚】bed-doctor-get æ˜¯ã€æˆ‘是否还坐在这里】的判定:失败说明工位已被释放 / ä»–人接手,
  // æ­¤æ—¶åœæŽ‰è½®è¯¢å¹¶ä¸€æ¬¡æ€§æç¤ºï¼Œé¿å…æ¯ 5 ç§’刷一次【请重新就座】。
  let queueVO2: any
  try {
    queueVO2 = await DoctorApi.bedDoctorGet(roomBedVO)
  } catch (e: any) {
    if (isSeatInvalidError(e)) {
      timerRunFlag = false
      notifySeatInvalidOnce()
      return
    }
    throw e
  }
  // ã€åŠ å›ºã€‘æŽ¥å£å¯èƒ½è¿”å›ž null,原来直接取 .status ä¼šæŠ›å¼‚常、中断本轮后续步骤
  if (queueVO2 && queueVO2.status === 20)  // å·¥ä½çŠ¶æ€ æœ‰åŒ»ç”Ÿ
    state.value = true
@@ -305,6 +316,8 @@
    roomBedVO.bedNo = roomStore.room.bedNo
    roomBedVO.checkTypes = roomStore.room.checkTypes
    roomBedVO.opType = roomStore.room.opType
    // ã€æœ¬éœ€æ±‚】重新拿到工位 -> å¤ä½ã€å·¥ä½å¤±æ•ˆã€‘提示,允许下次再提示一次
    resetSeatInvalidNotify()
    // ã€æœ¬æ¬¡éœ€æ±‚】换工位后**整页数据都换成新工位的**:
    // â‘  å…ˆæ¸…空左侧"患者信息"(避免还显示上一个工位的患者)
    devReadyPanelRef.value?.setPatient?.(undefined)
@@ -333,6 +346,7 @@
    roomBedVO.bedNo = roomStore.room!.bedNo
    roomBedVO.checkTypes = roomStore.room!.checkTypes
    roomBedVO.opType = roomStore.room!.opType
    resetSeatInvalidNotify()
    timerRunFlag = true
    doctorTimer()
src/views/ecg/room/index.vue
@@ -104,6 +104,31 @@
          >
            åˆ é™¤
          </el-button>
          <!--
            ã€æœ¬æ¬¡éœ€æ±‚】管理员强制清空工位:医生下班忘离座 / æ¢äººä½¿ç”¨åŒä¸€å·¥ä½æ—¶ç”¨ã€‚
            Â· æƒé™ï¼š`v-hasPermi` æ˜¯"任一命中即显示",这里同时接受 `clinic:room:update`(后端接口要求的)
              ä¸Ž `ecg:room:update`(本页其它按钮用的那套),避免角色只配了其中一套时按钮被隐藏。
              åŽç«¯ä»ä¸¥æ ¼æ ¡éªŒ `clinic:room:update`。
            Â· åªæœ‰"已被占用"(docId æœ‰å€¼ï¼‰çš„工位才可点;空闲工位置灰并提示,避免点了才报"状态不允许"。
          -->
          <el-tooltip
            :disabled="!!scope.row.docId"
            content="该工位当前空闲,无需清空"
            placement="top"
          >
            <span style="margin-left: 12px">
              <el-button
                link
                type="warning"
                :disabled="!scope.row.docId"
                @click="handleForceClearBed(scope.row)"
                v-hasPermi="['clinic:room:update', 'ecg:room:update']"
              >
                æ¸…空工位
              </el-button>
            </span>
          </el-tooltip>
        </template>
      </el-table-column>
    </el-table>
@@ -207,8 +232,29 @@
  } catch {}
}
/** å¯¼å‡ºæŒ‰é’®æ“ä½œ */
const handleExport = async () => {
/**
 * ã€æœ¬æ¬¡éœ€æ±‚】管理员强制清空工位。
 *
 * ç”¨é€”:医生下班忘记「离座」→ å·¥ä½ä¸€ç›´è¢«å ç”¨ï¼Œåˆ«çš„医生坐不上;
 * æˆ–换人使用同一工位时需要把上一位清掉。
 *
 * æœåŠ¡ç«¯åªåœ¨è¯¥å·¥ä½å¤„äºŽã€ŒåŒ»ç”Ÿåœ¨å²— / æš‚停」且已被占用时才允许清空,
 * ç©ºé—²å·¥ä½ä¼šè¿”回「当前工位状态不允许该操作」——这是预期行为,无需特殊处理。
 */
const handleForceClearBed = async (row: RoomVO) => {
  const who = row.docName ? `「${row.docName}」` : '当前医生'
  try {
    await message.confirm(
      `确认清空工位「${row.roomName ?? ''} Â· ${row.bedNo ?? ''}」吗?\n` +
        `该工位当前由${who}占用,清空后对方将变成"仅围观",需要重新入座才能操作。`
    )
    await RoomApi.forceClearBed({ roomId: row.roomId, bedNo: row.bedNo })
    message.success('已清空该工位')
    await getList()
  } catch {}
}
/** å¯¼å‡ºæŒ‰é’®æ“ä½œ */const handleExport = async () => {
  try {
    // å¯¼å‡ºçš„二次确认
    await message.exportConfirm()