yxh
2026-10-18 1446994d504819c367e1ff667360eadb314f9ac6
修改了诊室工号
已修改34个文件
已添加3个文件
3873 ■■■■■ 文件已修改
.env.local 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
nginx.conf 180 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/ecg/doctor/index.ts 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/ecg/jobrecord/index.ts 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/ecg/room/index.ts 122 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/ecg/screen/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/DoctorPageLayout/src/DoctorPageLayout.vue 350 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/RoomBedSelect/src/RoomBedSelect.vue 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/layout/components/CurrentRoomTag.vue 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/layout/components/ToolHeader.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/permission.ts 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/modules/room.ts 351 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/releaseBed.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/statusFormatter.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/appointment/CheckItemPanel.vue 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/appointment/alter.vue 133 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/appointment/index.vue 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/checktype/CheckTypeForm.vue 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/checktype/QueueSequenceForm.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/checktype/index.vue 150 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/doctor/DevDismantle.vue 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/doctor/DevInstall.vue 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/doctor/Routine.vue 140 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/doctor/components/BedSwitchSelect.vue 257 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/doctor/components/DevInstallPanel.vue 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/doctor/components/DevReadyPanel.vue 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/doctor/components/PatientInfoBlock.vue 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/doctor/components/QueuePanel.vue 207 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/doctor/components/RoutinePanel.vue 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/doctor/index.vue 125 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/queue/QueueForm.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/queue/index.vue 194 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/room/RoomForm.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/room/RoomLoginSelect.vue 634 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/room/RoomSetting.vue 130 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ecg/room/index.vue 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
vite.config.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.env.local
@@ -4,12 +4,24 @@
VITE_DEV=true
# è¯·æ±‚路径
VITE_BASE_URL='http://localhost:48080'
#
# ã€ä¸ºä»€ä¹ˆç•™ç©ºã€‘axios çš„ baseURL = VITE_BASE_URL + VITE_API_URL(见 src/config/axios/config.ts)。
# åŽŸå€¼ 'http://localhost:48080' ä¼šæ‹¼æˆç»å¯¹åœ°å€ http://localhost:48080/admin-api,
# äºŽæ˜¯æµè§ˆå™¨**直连后端、绕过 vite çš„ /admin-api ä»£ç†**:
#   Â· Windows ä¸Š localhost ä¼˜å…ˆè§£æžä¸º IPv6,后端看到的客户端地址是回环 ::1(不是本机局域网IP);
#   Â· åŽæžœï¼šsystem_login_log.user_ip è®°æˆ 0:0:0:0:0:0:0:1;
#     "按IP自动匹配诊室工位"(ecg.room.select.mode=1/3)永远匹配不到,
#     ä¹Ÿæ— æ³•把本机IP写入 clinic_room.ip("选择工位模式"验证不了)。
# ç•™ç©ºåŽ baseURL = "/admin-api"(相对路径),请求经 vite ä»£ç†è½¬å‘ï¼›
# ä»£ç†å·²é… xfwd: true,会把真实客户端IP写入 X-Forwarded-For,后端即可拿到正确的 IPv4。
# æ³¨æ„ï¼šç”¨ **127.0.0.1 æˆ– localhost è®¿é—®ä»ç„¶æ˜¯å›žçŽ¯åœ°å€**(无法作为工位标识),
# è¯·ç”¨æœ¬æœºå±€åŸŸç½‘地址访问,例如 http://192.168.3.15/ ã€‚
VITE_BASE_URL=''
# æ–‡ä»¶ä¸Šä¼ ç±»åž‹ï¼šserver - åŽç«¯ä¸Šä¼ ï¼Œ client - å‰ç«¯ç›´è¿žä¸Šä¼ ï¼Œä»…支持 S3 æœåŠ¡
VITE_UPLOAD_TYPE=server
# ä¸Šä¼ è·¯å¾„
VITE_UPLOAD_URL='http://localhost:48080/admin-api/infra/file/upload'
# ä¸Šä¼ è·¯å¾„(同样改为相对路径,走 vite ä»£ç†ï¼Œé¿å…ç›´è¿ž localhost ä¸¢IP)
VITE_UPLOAD_URL='/admin-api/infra/file/upload'
# æŽ¥å£åœ°å€
VITE_API_URL=/admin-api
nginx.conf
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,180 @@
user  nginx;
worker_processes  auto;
error_log  /var/log/nginx/error.log notice;
pid        /var/run/nginx.pid;
events {
    worker_connections  1024;
}
http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';
    access_log  /var/log/nginx/access.log  main;
    sendfile        on;
    #tcp_nopush     on;
    keepalive_timeout  65;
    #gzip  on;
    include /etc/nginx/conf.d/*.conf;
    server {
        listen       8081;
        server_name  localhost;
        charset utf-8;
        location / {
            root    /usr/local/smartor/patient/admin;
            try_files $uri $uri/ /index.html;
            index  index.html index.htm;
            }
        location /api/{
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header REMOTE-HOST $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_pass http://127.0.0.1:8090;
        }
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }
    server {
        listen       8082;
        server_name  localhost;
        charset utf-8;
        location / {
            root    /usr/local/smartor/patient/doctor;
            try_files $uri $uri/ /index.html;
            index  index.html index.htm;
            }
        location /api/{
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header REMOTE-HOST $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_pass http://127.0.0.1:8090;
        }
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }
    server {
        listen       8083;
        server_name  localhost;
        charset utf-8;
        location / {
            root    /usr/local/smartor/patient/patient;
            try_files $uri $uri/ /index.html;
            index  index.html index.htm;
            }
        location /api/{
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header REMOTE-HOST $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_pass http://127.0.0.1:8090;
        }
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }
    server {
        listen       8084;
        server_name  localhost;
        charset utf-8;
        location / {
            root    /usr/local/smartor/english/web;
            try_files $uri $uri/ /index.html;
            index  index.html index.htm;
            }
        location /api/{
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header REMOTE-HOST $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_pass http://127.0.0.1:8091;
        }
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }
server {
        listen       8085;
        server_name  localhost;
        charset utf-8;
        location / {
            root    /usr/local/smartor/service/web;
            try_files $uri $uri/ /index.html;
            index  index.html index.htm;
            }
        location /api/{
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header REMOTE-HOST $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_pass http://127.0.0.1:8092;
        }
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }
server {
        listen       8086;
        server_name  localhost;
        charset utf-8;
        location / {
            root    /usr/local/smartor/decision/web;
            try_files $uri $uri/ /index.html;
            index  index.html index.htm;
            }
        location /api/{
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header REMOTE-HOST $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_pass http://127.0.0.1:8093;
        }
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }
}
src/api/ecg/doctor/index.ts
@@ -106,6 +106,31 @@
    return await request.get({ url: `/ecg/doctor/get-to-be-installed-list`, params })
  },
  /**
   * å–「本诊室排队中(未分配工位)」患者列表。
   *
   * å€™è¯Šåˆ—表只含"已分流到本工位"的患者(queue.room_id + bed_no æŒ‡å‘本工位),
   * è€Œ"排队中(10)"的患者还没分配工位 â€”— å¤§å±èƒ½çœ‹åˆ°ã€å·¥ä½œå°åŽŸå…ˆçœ‹ä¸åˆ°ã€‚
   * è¿™ä¸ªæŽ¥å£æŒ‰æœ¬å·¥ä½å¼€å±•的检查项目把他们查出来,供医生手工「拉入」。
   */
  getRoomWaitingList: async (params: RoomBedVO) => {
    return await request.get({ url: `/ecg/doctor/get-room-waiting-list`, params })
  },
  /** æŠŠã€ŒæŽ’队中」的患者手工拉入本工位(只有工位主人能拉,后端有硬校验) */
  pullWaitingPatient: async (params: { roomId: any; bedNo: any; id: any }) => {
    return await request.get({ url: `/ecg/doctor/pull-waiting-patient`, params })
  },
  /**
   * ç«‹å³è¡¥äººï¼šæ‰‹åŠ¨è§¦å‘ä¸€æ¬¡æœ¬å·¥ä½çš„è‡ªåŠ¨åˆ†æµï¼Œå¹¶è¿”å›žè¯Šæ–­ä¿¡æ¯
   * ï¼ˆç³»ç»Ÿå¼€è¯ŠçŠ¶æ€ / æ˜¯å¦åœæ­¢å€™è¯Š / å€™è¯Šä¸Šé™ / è¡¥äººå‰åŽäººæ•° / ç»“论)。
   * å€™è¯ŠåŒºä¸ºç©ºæ—¶çš„自助排查:不必等定时任务,也不用重启去猜是哪道门闸挡住的。
   */
  hurryUpNow: async (params: RoomBedVO) => {
    return await request.get({ url: `/ecg/doctor/hurry-up-now`, params })
  },
  // å–患者统计
  getPatientStatistic: async (params: RoomBedVO) => {
    return await request.get({ url: `/ecg/doctor/get-patient-statistic`, params })
src/api/ecg/jobrecord/index.ts
@@ -1,4 +1,5 @@
import request from '@/config/axios'
import { useRoomStore } from '@/store/modules/room'
// å·¥ä½œè®°å½• VO
export interface JobRecordVO {
@@ -13,6 +14,12 @@
  summary: number // ä½œä¸šæ¦‚要
  remark: string // å¤‡æ³¨
  paid: number
  /**
   * ã€æ’¤é”€æ ‡è¯†ã€‘该条完成记录对应的患者"当日队列状态已不是已就诊(40)",
   * å³æ£€æŸ¥å®Œæˆè¢«æ’¤é”€è¿‡ã€è¿˜æ²¡é‡æ–°å®Œæˆã€‚后端按当日队列状态动态计算,不落库;
   * æ‚£è€…再次完成后自动变回 false。
   */
  undone?: boolean
  // éœ€æ±‚1:患者信息 18 é¡¹ï¼ˆåŽç«¯æŒ‰ job_record.rentId â†’ dev_rent.applyNo â†’ appointment æ‰¹é‡å›žå¡«ï¼‰
  applyNo?: string // ç”³è¯·ç¼–号
  episodeId?: string // å°±è¯Šæµæ°´å·
@@ -49,9 +56,24 @@
    return await request.get({ url: `/ecg/job-record/page`, params })
  },
  // æŸ¥è¯¢ç”¨æˆ·è‡ªå·±çš„工作记录分页
  /**
   * æŸ¥è¯¢å·¥ä½œè®°å½•分页(默认查"我自己的")。
   *
   * ã€ä¸»/围观】围观时必须查**该工位主人**的记录,否则看到的永远是自己那份(空的):
   * è¿™é‡Œç»Ÿä¸€æ³¨å…¥"我正在看的工位"的主人 docId + roomId/bedNo,
   * åŽç«¯ä¼šæ ¡éªŒè¿™ä¸ª docId ç¡®å®žæ˜¯è¯¥å·¥ä½å½“前的主人(不是随便查别人)。
   * æ”¾åœ¨ API å±‚统一处理,5 ä¸ªå·¥ä½œå°/面板的调用点不用各自改。
   */
  getJobRecordDoctorPage: async (params: any) => {
    return await request.get({ url: `/ecg/job-record/doctor-page`, params })
    const roomStore = useRoomStore()
    const view = roomStore.getRoom
    const query: any = { ...(params || {}) }
    if (!roomStore.getCanOperate && view?.roomId != null && view.bedNo && view.docId != null) {
      query.docId = view.docId
      query.roomId = view.roomId
      query.bedNo = view.bedNo
    }
    return await request.get({ url: `/ecg/job-record/doctor-page`, params: query })
  },
  // æŸ¥è¯¢å·¥ä½œè®°å½•详情
src/api/ecg/room/index.ts
@@ -6,12 +6,81 @@
  roomId: number | null // è¯Šå®¤ç¼–号
  roomName: string | null // è¯Šå®¤åç§°
  bedNo: string | null // è¯Šç–—床编号
  ip: string | null // è¯Šå®¤IP
  ip: string | null // è¯Šå®¤å·¥ä½IP
  status: number | null //开诊状态  0-关闭 1-关闭中 10-已开通 20-有医生 30-暂停
  docId: number | null
  docName: string | null
  checkTypes: number[] | null
  opType: number | null
  occupied?: boolean | null // è¯¥å·¥ä½æ˜¯å¦å·²è¢«åŒ»ç”Ÿå ç”¨ï¼ˆæŒ‰IP匹配工位时用)
}
// ã€Œé€‰æ‹©å·¥ä½æ¨¡å¼ã€- æŒ‰æœ¬æœºIP匹配工位的候选工位
export interface RoomSelectBedItem {
  id: number // å·¥ä½ä¸»é”® clinic_room.id
  roomId: number | null
  roomName: string | null
  bedNo: string | null
  ip: string | null
  docId: number | null
  docName: string | null
  opType: number | null
  checkTypes: number[] | null
  status: number | null
  occupied: boolean | null
  mine: boolean | null
}
// ã€Œé€‰æ‹©å·¥ä½æ¨¡å¼ã€- æŒ‰æœ¬æœºIP匹配工位的结果
export interface RoomSelectBedResp {
  clientIp: string | null // åŽç«¯å®žé™…识别到的来访IP(已规范化)
  ipValid: boolean | null // è¯¥IP是否可用于绑定
  ipLoopback: boolean | null // è¯¥IP是否是本机回环(127.x / ::1)→ è¯´æ˜Žæµè§ˆå™¨ä¸ŽæœåŠ¡åœ¨åŒä¸€å°æœºå™¨
  ipv6: boolean | null // è¯¥IP是否是 IPv6(本系统工位绑定仅采用 IPv4)
  ipBound: boolean | null // è¯¥IP是否已绑定工位
  matched: boolean | null // æ˜¯å¦æœ‰å¯ç›´æŽ¥å…¥åº§çš„工位
  beds: RoomSelectBedItem[]
}
/**
 * è¯¥IP能否用于自动匹配/绑定工位。
 * **本系统工位绑定只采用 IPv4**:IPv6(含 ::1、fe80::、2408:: ç­‰ï¼‰ä¸€å¾‹ä¸å¯ç”¨ã€‚
 * ç©ºå€¼ã€0.0.0.0、127.x åŒæ ·ä¸å¯ç”¨ï¼›`::ffff:32.88.65.208` ç”±åŽç«¯è¿˜åŽŸæˆ IPv4 åŽå¯ç”¨ã€‚
 */
export const isUsableSeatIp = (ip?: string | null): boolean => {
  const v = (ip || '').trim()
  if (!v || v === '0.0.0.0' || v.startsWith('127.')) return false
  if (v.includes(':')) return false // IPv6 ä¸é‡‡ç”¨
  if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(v)) return false
  return v.split('.').every((seg) => Number(seg) <= 255)
}
/**
 * ã€Œå·¥ä½å…¥åº§ã€ç»“果(自动入座 / æŸ¥è¯¢æˆ‘的工位 å…±ç”¨ï¼‰ã€‚
 *
 * reason å–值:
 *  - ip_invalid æœ¬æœºIP不可用(回环/IPv6/未指定/非法)
 *  - nomatch    è¯¥IP没匹配到工位(工位没录IP æˆ– å·²å…³é—­ï¼‰
 *  - multi      è¯¥IP对应多个空闲工位,需人工点选
 *  - occupied   åŒ¹é…åˆ°çš„工位已被**他人**占用(不挤掉)
 *  - error      å…¥åº§å†™åº“未生效
 */
export interface RoomSeatResp {
  seated: boolean
  reason?: string | null
  clientIp?: string | null
  ownerName?: string | null
  id?: number | null
  roomId?: number | null
  roomName?: string | null
  bedNo?: string | null
  ip?: string | null
  status?: number | null
  docId?: number | null
  docName?: string | null
  checkTypes?: number[] | null
  opType?: number | null
}
export interface MonitorInfo {
@@ -96,6 +165,57 @@
  // æ‰‹åŠ¨é—­è¯Š
  closeBiz: async () => {
    return await request.get({ url: `/clinic/room/close-biz` })
  },
  /** ã€Œé€‰æ‹©å·¥ä½æ¨¡å¼ã€æŒ‰æœ¬æœºIP匹配工位(模式 1/3):返回后端识别到的IP + å€™é€‰å·¥ä½ï¼ˆå·²æŽ’序) */
  getIpBed: async () => {
    return await request.get({ url: `/clinic/room/get-ip-bed` })
  },
  /**
   * ä¸€é”®è‡ªåŠ¨å…¥åº§ï¼ˆæ¨¡å¼ 1/3):后端按来访IP匹配工位并直接落座。
   *
   * ç›¸æ¯”原来「getIpBed â†’ bindIpToBed â†’ bedDoctorOn」三步拼装,这里只失败一次即可排查:
   * è¿”回体带 clientIp ä¸Ž reason(ip_invalid / nomatch / multi / occupied / error)。
   * æœåŠ¡ç«¯åŒæ—¶ä¿è¯ï¼šä¸€ä¸ªç”¨æˆ·åªå ä¸€ä¸ªå·¥ä½ï¼ˆå…¥åº§æˆåŠŸåŽé‡Šæ”¾å…¶å…¶å®ƒå·¥ä½ï¼‰ã€‚
   */
  autoSeat: async () => {
    return await request.post({ url: `/clinic/room/auto-seat` })
  },
  /** æŸ¥è¯¢å½“前登录人已入座的工位(系统顶栏"当前工位"标签用) */
  getMySeat: async () => {
    return await request.get({ url: `/clinic/room/my-seat` })
  },
  /**
   * å®¢æˆ·ç«¯ IP è¯†åˆ«è¯Šæ–­ï¼šè¿”回各代理头、直连地址、规范化结果、是否服务器自身地址、服务器网卡列表与结论。
   * çŽ°åœºæŽ’æŸ¥"工位绑定的 IP ä¸å¯¹"用(选工位页的「IP è¯Šæ–­ã€æŒ‰é’®ï¼‰ã€‚
   */
  ipDebug: async () => {
    return await request.get({ url: `/clinic/room/ip-debug` })
  },
  /**
   * å·¥ä½çŠ¶æ€è¯Šæ–­ï¼šè¿”å›žè¯¥å·¥ä½**库内原始行**(含 bed_no é•¿åº¦/HEX、status、doc_id、deleted)与结论。
   * ç”¨äºŽæŽ’查"工位明明空闲却入座失败(报状态异常)"。
   */
  bedState: async (params: { id?: number | null; roomId?: number | null; bedNo?: string | null }) => {
    const query: string[] = []
    if (params?.id != null) query.push(`id=${params.id}`)
    if (params?.roomId != null) query.push(`roomId=${params.roomId}`)
    if (params?.bedNo != null) query.push(`bedNo=${encodeURIComponent(params.bedNo)}`)
    return await request.get({ url: `/clinic/room/bed-state?${query.join('&')}` })
  },
  /**
   * ã€Œé€‰æ‹©å·¥ä½æ¨¡å¼ã€æŠŠæœ¬æœºIP绑定到指定工位(模式 2/3)。
   * force=false æ—¶è‹¥è¯¥IP已绑在别的工位,返回 code=409 + å†²çªå·¥ä½ä¿¡æ¯ï¼Œç”±ç•Œé¢ç¡®è®¤åŽå†å¸¦ force=true è¦†ç›–。
   */
  bindIpToBed: async (id: number, force = false) => {
    return await request.post({
      url: `/clinic/room/bind-ip?id=${id}&force=${force}`
    })
  }
}
src/api/ecg/screen/index.ts
@@ -15,7 +15,7 @@
  jumpFlag: number //插队标记
  roomId: number // è¯Šå®¤ç¼–号
  roomName: string // è¯Šå®¤åç§°
  bedNo: string // è¯Šç–—床编号
  bedNo: string // è¯Šå®¤å·¥ä½ç¼–号
}
// å«å·å± API
src/components/DoctorPageLayout/src/DoctorPageLayout.vue
@@ -7,13 +7,13 @@
  ã€Œå«å· / è¿‡å· / é‡å« / æš‚停 / æ’¤é”€å«å·ã€æŒ¡ä½ï¼›
  ä¸”外层容器没有确定高度,中间区无法产生滚动,内容只能一路把页面撑长。
  ã€å¸ƒå±€å¥‘约】——纵向三段 + å³ä¾§æ 
  ã€å¸ƒå±€å¥‘约】——纵向三段 + å³ä¾§æ ï¼ˆä¸­é—´æœ‰**可拖动分界线**)
  â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”
  â”‚ #header  æ ‡é¢˜æ ï¼ˆå›ºå®šé«˜åº¦ï¼Œä¸æ»šåŠ¨ï¼‰          â”‚  flex: none
  â”œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”¬â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”¤
  â”‚ #default ä¸»ä½“(滚动区)       â”‚ #aside      â”‚  ä»…中间区与右栏各自滚动
  â”‚  Â· æ‚£è€…信息(由各面板内部    â”‚ ï¼ˆç‹¬ç«‹æ»šåŠ¨ï¼‰â”‚
  â”‚    è®¾ä¸º sticky,钉在顶部)   â”‚             â”‚
  â”‚  Â· æ‚£è€…信息(由各面板内部    â”‚ ï¼ˆç‹¬ç«‹æ»šåŠ¨ï¼‰â”‚  ä¸¤è€…之间的竖线**可左右拖动**
  â”‚    è®¾ä¸º sticky,钉在顶部)   â”‚             â”‚  ä»¥è°ƒæ•´å³æ å®½åº¦
  â”‚  Â· å·²å®Œæˆè®°å½•列表(滚动)    â”‚             â”‚
  â”œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”´â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”¤
  â”‚ #footer  åŠŸèƒ½æŒ‰é’®ï¼ˆå›ºå®šåº•éƒ¨ï¼Œæ°¸ä¸è¢«é®æŒ¡ï¼‰    â”‚  flex: none
@@ -29,6 +29,7 @@
     å¸¦ä¸é€æ˜ŽèƒŒæ™¯ä¸Žä¸Šè¾¹æ¡†ï¼Œå†…容永远不会透过来或盖住它。
  4. æ ¹å…ƒç´ **自带不透明背景**:各面板内 sticky çš„æ‚£è€…信息若不透明,
     æ»šåŠ¨å†…å®¹ä¼šä»Žå®ƒä¸Žè¾¹ç¼˜çš„ç¼éš™é€å‡ºï¼Œæ•…æ•´é¡µç»Ÿä¸€åº•è‰²ã€‚
  5. ã€æœ¬æ¬¡æ–°å¢žã€‘分界线可拖动(见下),宽度记在 localStorage é‡Œï¼Œåˆ‡é¡µ/刷新后保持。
  ã€æ’槽】
    header   æ ‡é¢˜æ ï¼ˆå¯é€‰ï¼‰
@@ -37,19 +38,67 @@
    footer   åº•部功能按钮(可选;固定在页面底部)
-->
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import BedSwitchSelect from '@/views/ecg/doctor/components/BedSwitchSelect.vue'
import { useRoomStore } from '@/store/modules/room'
defineOptions({ name: 'DoctorPageLayout' })
withDefaults(
/* ==================== ä¸»/围观(只读模式) ==================== */
const roomStore = useRoomStore()
/**
 * æ˜¯å¦æ˜¾ç¤ºé¡¶éƒ¨æç¤ºæ¡ã€‚
 *
 * åˆ¤å®šï¼š**只要当前不可操作**就提示 â€”— å«ä¸¤ç§æƒ…况:
 *   â‘  è¿˜æ²¡é€‰å®šè¦çœ‹å“ªä¸ªå·¥ä½ï¼ˆæœªå…¥åº§ä¹Ÿæ²¡å›´è§‚);
 *   â‘¡ æ­£åœ¨å›´è§‚别人的工位(我入座的 â‰  æˆ‘正在看的)。
 */
const showReadonlyTip = computed(() => !roomStore.getCanOperate)
const readonlyTipText = computed(() =>
  roomStore.getViewText ? `「${roomStore.getViewText}」` : '(尚未选择工位)'
)
const readonlyTipPrefix = computed(() =>
  roomStore.getViewText ? '围观模式(只读):正在查看' : '尚未选择工位:请用右上角「工位」下拉选择要查看 / è¦å…¥åº§çš„工位'
)
const props = withDefaults(
  defineProps<{
    /** å³ä¾§æ å®½åº¦ */
    /** å³ä¾§æ **初始/默认**宽度(拖动之后以实际拖动值为准;双击分界线恢复该值) */
    asideWidth?: string
    /** å³ä¾§æ æœ€å°å®½åº¦ï¼ˆpx) */
    minAsideWidth?: number
    /** å·¦ä¾§å†…容区最小宽度(px):左侧是四列患者信息表单,太窄会折行 */
    minContentWidth?: number
    /** è®°å¿†æ‹–动结果的 localStorage key;传空字符串表示不记忆 */
    storageKey?: string
    /**
     * ã€æœ¬æ¬¡éœ€æ±‚】是否允许在标题栏切换工位。
     * è®¾å¤‡é¢†ç”¨ / è£…机页传 `false`:工位固定,不提供切换(未入座者即围观者,只读)。
     */
    bedSwitchEnabled?: boolean
    /**
     * ã€æœ¬æ¬¡éœ€æ±‚】可切换工位的**作业类型**过滤(0检查 1领用 2装机)。
     * å«å·æ£€æŸ¥é¡µä¼  `[0]` â†’ ä¸‹æ‹‰åªåˆ—检查类工位;不传 = ä¸è¿‡æ»¤ã€‚
     */
    bedSwitchOpTypes?: number[] | null
  }>(),
  {
    asideWidth: '400px'
    asideWidth: '400px',
    minAsideWidth: 280,
    minContentWidth: 520,
    storageKey: 'ecg.doctor.asideWidth',
    bedSwitchEnabled: true,
    bedSwitchOpTypes: null
  }
)
/** é»˜è®¤å®½åº¦ï¼ˆpx):由 asideWidth è§£æžè€Œæ¥ï¼Œç”¨ä½œåˆå§‹å€¼ä¸Ž"双击复位"值 */
const defaultAsidePx = (() => {
  const n = parseInt(props.asideWidth, 10)
  return Number.isFinite(n) && n > 0 ? n : 400
})()
/**
 * ã€å…³é”®ã€‘不依赖 CSS `height: 100%` çš„解析,改为**实测父容器可用高度**。
@@ -71,6 +120,111 @@
const rootRef = ref<HTMLElement>()
const measuredHeight = ref<string | undefined>(undefined)
// ==================== å³ä¾§æ å®½åº¦ï¼ˆå¯æ‹–动分界线) ====================
/** ä¸»ä½“行容器:拖动时用它算"右侧栏最多能有多宽" */
const bodyRef = ref<HTMLElement>()
/** å³ä¾§æ å½“前宽度(px) */
const asidePx = ref<number>(defaultAsidePx)
/** æ˜¯å¦æ­£åœ¨æ‹–动(用于换光标、禁选中) */
const dragging = ref(false)
/**
 * æŠŠå®½åº¦å¤¹åˆ°åˆæ³•区间。
 *
 * ä¸Šé™ = å®¹å™¨å®½åº¦ âˆ’ å·¦ä¾§æœ€å°å®½åº¦ï¼ˆä¿è¯å·¦ä¾§æ‚£è€…信息/记录列表不被挤没);
 * ä¸‹é™ = minAsideWidth。窗口极窄导致两者冲突时,**优先保证下限**,
 * å·¦ä¾§é è‡ªèº«çš„ `min-width:0 + overflow` ä¼˜é›…降级。
 */
const clampAsidePx = (px: number) => {
  const bodyPx = bodyRef.value?.clientWidth ?? 0
  const lower = props.minAsideWidth
  const upper = bodyPx > 0
    ? Math.max(lower, bodyPx - props.minContentWidth)
    : Number.MAX_SAFE_INTEGER
  return Math.round(Math.min(Math.max(px, lower), upper))
}
/** è®°ä½æ‹–动结果(隐私模式/禁用存储时静默忽略) */
const saveAsidePx = () => {
  if (!props.storageKey) return
  try {
    localStorage.setItem(props.storageKey, String(asidePx.value))
  } catch {
    /* ignore */
  }
}
/** è¯»å–上次拖动结果;必须等 bodyRef æŒ‚载后才能 clamp */
const loadAsidePx = () => {
  if (!props.storageKey) return
  try {
    const v = parseInt(localStorage.getItem(props.storageKey) || '', 10)
    if (Number.isFinite(v) && v > 0) asidePx.value = clampAsidePx(v)
  } catch {
    /* ignore */
  }
}
/** åŒå‡»åˆ†ç•Œçº¿ï¼šæ¢å¤é»˜è®¤å®½åº¦å¹¶æ¸…除记忆 */
const resetAsideWidth = () => {
  asidePx.value = clampAsidePx(defaultAsidePx)
  if (props.storageKey) {
    try {
      localStorage.removeItem(props.storageKey)
    } catch {
      /* ignore */
    }
  }
}
let startX = 0
let startPx = 0
/**
 * ç”¨ Pointer Events + setPointerCapture:
 * æ•获后 pointermove/up éƒ½åªåœ¨åˆ†ç•Œçº¿ä¸Šè§¦å‘,**不需要**再往 document ä¸ŠæŒ‚/摘监听,
 * ä¹Ÿå°±ä¸ä¼šå‡ºçް"拖到 iframe æˆ–窗口外松手后监听残留"的老问题。
 */
const onSplitterDown = (e: PointerEvent) => {
  const el = e.currentTarget as HTMLElement | null
  dragging.value = true
  startX = e.clientX
  startPx = asidePx.value
  el?.setPointerCapture?.(e.pointerId)
  e.preventDefault()
}
const onSplitterMove = (e: PointerEvent) => {
  if (!dragging.value) return
  // åˆ†ç•Œçº¿å‘右拖(Δx>0)→ å³ä¾§æ å˜çª„,故用减法
  asidePx.value = clampAsidePx(startPx - (e.clientX - startX))
}
const onSplitterUp = (e: PointerEvent) => {
  if (!dragging.value) return
  dragging.value = false
  const el = e.currentTarget as HTMLElement | null
  el?.releasePointerCapture?.(e.pointerId)
  saveAsidePx()
}
/** é”®ç›˜å¾®è°ƒï¼ˆâ†/→ æ¯æ¬¡ 20px),无鼠标也能调整 */
const onSplitterKey = (e: KeyboardEvent) => {
  const step = 20
  if (e.key === 'ArrowLeft') {
    asidePx.value = clampAsidePx(asidePx.value + step) // åˆ†ç•Œçº¿å·¦ç§» â†’ å³ä¾§å˜å®½
  } else if (e.key === 'ArrowRight') {
    asidePx.value = clampAsidePx(asidePx.value - step)
  } else {
    return
  }
  saveAsidePx()
  e.preventDefault()
}
let ro: ResizeObserver | undefined
let rafId = 0
@@ -87,6 +241,10 @@
  // çˆ¶çº§é«˜åº¦ä¸å¯æµ‹ï¼ˆauto/0)时保留 CSS å›žé€€ï¼Œä¸è®¾å†…联高度
  measuredHeight.value = inner > 0 ? `${inner}px` : undefined
  // å®½åº¦å˜åŒ–(含窗口缩放)时重新夹一次右栏宽度,
  // å¦åˆ™æ‹–宽后把窗口缩小,右侧栏可能把左侧内容挤没。
  asidePx.value = clampAsidePx(asidePx.value)
}
/** ç”¨ rAF åˆå¹¶è¿žç»­è§¦å‘,避免 ResizeObserver å¾ªçŽ¯å‘Šè­¦ */
@@ -100,6 +258,7 @@
onMounted(() => {
  measure()
  loadAsidePx()
  const parent = rootRef.value?.parentElement
  if (parent && typeof ResizeObserver !== 'undefined') {
    ro = new ResizeObserver(scheduleMeasure)
@@ -118,22 +277,65 @@
</script>
<template>
  <div ref="rootRef" class="doctor-page" :style="{ height: measuredHeight }">
    <!-- æ ‡é¢˜æ ï¼šå›ºå®šé«˜åº¦ï¼Œä¸æ»šåЍ -->
  <div
    ref="rootRef"
    class="doctor-page"
    :class="{ 'is-resizing': dragging }"
    :style="{ height: measuredHeight }"
  >
    <!--
      æ ‡é¢˜æ ï¼šå›ºå®šé«˜åº¦ï¼Œä¸æ»šåŠ¨ã€‚
      å³ä¾§å›ºå®šæ”¾ã€Œå·¥ä½åˆ‡æ¢ / å›´è§‚」下拉(主/围观模型的入口)——放在布局层,
      å«å·æ£€æŸ¥ / è®¾å¤‡é¢†ç”¨ / è£…机 ä¸‰é¡µè‡ªåŠ¨éƒ½æœ‰ï¼Œä¸ç”¨å„é¡µé‡å¤åŠ ã€‚
    -->
    <div v-if="$slots.header" class="doctor-page__header">
      <slot name="header"></slot>
      <div class="doctor-page__header-left">
        <slot name="header"></slot>
      </div>
      <BedSwitchSelect :enabled="bedSwitchEnabled" :op-types="bedSwitchOpTypes" />
    </div>
    <!-- å›´è§‚模式(只读)/ æœªé€‰å·¥ä½ æç¤ºæ¡ï¼šè®©åŒ»ç”Ÿä¸€çœ¼çŸ¥é“"现在只能看,不能操作" -->
    <div v-if="showReadonlyTip" class="doctor-page__readonly-tip">
      <Icon icon="ep:view" class="mr-4px" />
      {{ readonlyTipPrefix }}{{ roomStore.getViewText ? readonlyTipText : '' }}<template v-if="roomStore.getViewText && roomStore.viewOwnerName">,主人 {{ roomStore.viewOwnerName }}</template><template v-if="roomStore.getViewText">;如需操作,请在右上角「工位」里入座到该工位。</template>
    </div>
    <!-- ä¸»ä½“:占据剩余全部高度;内部只让"内容区"和"右栏"各自滚动 -->
    <div class="doctor-page__body">
    <div ref="bodyRef" class="doctor-page__body" :class="{ 'is-readonly': !roomStore.getCanOperate }">
      <div class="doctor-page__content">
        <slot></slot>
      </div>
      <!--
        å¯æ‹–动分界线(本次需求):
        Â· å·¦å³æ‹–动 = è°ƒæ•´å³ä¾§æ‚£è€…清单的宽度;
        Â· åŒå‡» = æ¢å¤é»˜è®¤å®½åº¦ï¼ˆå¹¶æ¸…除记忆);
        Â· èšç„¦åŽ â†/→ = æ¯æ¬¡ 20px å¾®è°ƒï¼›
        Â· å®½åº¦è®°å¿†åœ¨ localStorage,三个工作台页面共用同一个 key。
      -->
      <div
        v-if="$slots.aside"
        class="doctor-page__splitter"
        :class="{ 'is-dragging': dragging }"
        role="separator"
        aria-orientation="vertical"
        :aria-valuenow="asidePx"
        :aria-valuemin="minAsideWidth"
        tabindex="0"
        title="拖动调整右侧宽度(双击复位)"
        @pointerdown="onSplitterDown"
        @pointermove="onSplitterMove"
        @pointerup="onSplitterUp"
        @pointercancel="onSplitterUp"
        @dblclick="resetAsideWidth"
        @keydown="onSplitterKey"
      ></div>
      <div
        v-if="$slots.aside"
        class="doctor-page__aside"
        :style="{ width: asideWidth, flex: `0 0 ${asideWidth}` }"
        :style="{ width: `${asidePx}px`, flex: '0 0 auto' }"
      >
        <slot name="aside"></slot>
      </div>
@@ -144,7 +346,7 @@
      éœ€æ±‚:`自动`、`停止候诊` ä¸Ž `叫号/过号/重叫/暂停/撤销叫号` **合成一组**,
            ä¸€èµ·**靠右**(对着右侧候诊表),不要被拆散到左边/中间。
    -->
    <div v-if="$slots.footer" class="doctor-page__footer">
    <div v-if="$slots.footer" class="doctor-page__footer" :class="{ 'is-readonly': !roomStore.getCanOperate }">
      <div class="doctor-page__footer-right">
        <slot name="footer"></slot>
      </div>
@@ -168,6 +370,12 @@
  background-color: var(--el-bg-color);
}
/* æ‹–动分界线过程中:全局换成左右箭头光标,并禁止选中文字(否则会拖出一片蓝色选区) */
.doctor-page.is-resizing {
  cursor: col-resize;
  user-select: none;
}
/* ---------- æ ‡é¢˜æ  ---------- */
.doctor-page__header {
  flex: none;
@@ -175,6 +383,59 @@
  padding: 0 12px;
  overflow: hidden;
  background-color: var(--el-color-primary-light-7);
  /* å·¦ä¾§æ ‡é¢˜ï¼ˆå„页传入的 TitlePanel)+ å³ä¾§ã€Œå·¥ä½åˆ‡æ¢ / å›´è§‚」下拉 */
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}
/* æ ‡é¢˜åŒºå¯åŽ‹ç¼©ï¼ˆå·¥ä½ä¸‹æ‹‰å›ºå®šé å³ï¼‰ */
.doctor-page__header-left {
  flex: 1;
  min-width: 0;
  overflow: hidden;
}
/* ==========================================================================
   ã€æœ¬æ¬¡éœ€æ±‚】工作台按钮统一尺寸 / å­—号(方便鼠标击中)
   åŽŸæ¥æ··ç”¨ `size="small"`(24px / 12px å­—)与默认(32px / 14px å­—),
   åŒä¸€é¡µé‡Œå¤§å°ä¸ä¸€ã€‚这里做统一归一化,**不逐个改 size å±žæ€§**:
   å½’一化后 `--small` å·²æ— è§†è§‰æ•ˆæžœï¼ŒåŽç»­æ–°å¢žæŒ‰é’®ä¹Ÿä¼šè‡ªåŠ¨ä¸€è‡´ã€‚
   ========================================================================== */
.doctor-page :deep(.el-button),
.doctor-page :deep(.el-button--small),
.doctor-page :deep(.el-button--default) {
  height: 32px;
  padding: 0 14px;
  font-size: 14px;
}
/* è¡¨æ ¼ / é˜Ÿåˆ—列表内的行内按钮:小一档,避免把表格行撑高 */
.doctor-page :deep(.el-table .el-button:not(.is-link)),
.doctor-page :deep(.queue-list .el-button:not(.is-link)) {
  height: 28px;
  padding: 0 10px;
  font-size: 13px;
}
/* é“¾æŽ¥å¼æŒ‰é’®ï¼ˆè¡¨å¤´ã€Œæ’¤é”€ã€ã€å€™é€‰åŒºã€Œç«‹å³è¡¥äººã€ç­‰ï¼‰ï¼šåªæ”¾å¤§å­—号,不加高度 */
.doctor-page :deep(.el-button.is-link) {
  height: auto;
  padding: 0 4px;
  font-size: 14px;
}
/* ---------- å›´è§‚模式(只读)提示条 ---------- */
.doctor-page__readonly-tip {
  flex: none;
  padding: 4px 12px;
  font-size: 13px;
  line-height: 20px;
  color: var(--el-color-warning);
  background-color: var(--el-color-warning-light-9);
  border-bottom: 1px solid var(--el-color-warning-light-7);
}
/* ---------- ä¸»ä½“ ---------- */
@@ -183,6 +444,20 @@
  flex: 1;
  /* âš ï¸ å…³é”®ï¼šä¸åŠ  min-height:0,flex å­é¡¹ä¸ä¼šæ”¶ç¼©ï¼Œoverflow ä¸ç”Ÿæ•ˆ */
  min-height: 0;
}
/*
  å›´è§‚模式(只读):内容区里的**按钮 / å¼€å…³ / å‹¾é€‰æ¡†**一律不可点。
  è¦†ç›–各面板里的「检查完成 / æ”¾å¼ƒæ£€æŸ¥ / ç¡®è´¹ / é¢†ç”¨å®Œæˆ / è£…机完成 / æ•°æ®å½•å…¥ / æ’¤é”€ â€¦ã€ã€‚
  è¯´æ˜Žï¼š
    Â· å¼¹çª—(患者信息、打印预览等)是 teleport å‡ºåŽ»çš„ï¼Œä¸å—è¿™é‡Œå½±å“ï¼Œä»å¯æ­£å¸¸æŸ¥çœ‹ï¼›
    Â· æœåŠ¡ç«¯è¿˜æœ‰ä¸€é“ç¡¬æ ¡éªŒï¼ˆéžå·¥ä½ä¸»äººè°ƒç”¨å†™æŽ¥å£ä¼šè¢«æ‹’ç»ï¼‰ï¼Œè¿™é‡Œåªæ˜¯ä½“éªŒå±‚ã€‚
*/
.doctor-page__body.is-readonly :deep(.el-button),
.doctor-page__body.is-readonly :deep(.el-switch),
.doctor-page__body.is-readonly :deep(.el-checkbox) {
  pointer-events: none;
  opacity: 0.5;
}
/*
@@ -196,6 +471,9 @@
  è¿™é‡Œçš„ `overflow-y: auto` åªåœ¨**空间实在不足**(患者信息 + æŒ‰é’®æŠŠé«˜åº¦åƒå…‰ï¼‰æ—¶ç”Ÿæ•ˆï¼Œ
  ä½œä¸ºå…œåº•;正常情况不会出现滚动条。
  æ³¨æ„å¿…须保留 min-height:0,否则 flex å­é¡¹ä¸ä¼šæ”¶ç¼©ï¼Œè®°å½•列表拿不到受限高度。
  âš ï¸ å³è¾¹æ¡†å·²åŽ»æŽ‰ï¼šåˆ†ç•Œçº¿æ”¹ç”± `.doctor-page__splitter` æä¾›ï¼Œ
     å¦åˆ™ã€Œ3px å³è¾¹æ¡† + åˆ†ç•Œçº¿ã€ä¼šå¹¶æŽ’成两条线。
*/
.doctor-page__content {
  display: flex;
@@ -207,6 +485,42 @@
  overflow-y: auto;
  overflow-x: hidden;
  border: solid var(--el-color-primary-light-7);
  border-right: none;
}
/* ---------- å¯æ‹–动分界线 ---------- */
/*
  å‘½ä¸­åŒº 6px(好点中),视觉线 1px å±…中;hover / æ‹–动中加粗变主色。
  æ³¨æ„ï¼šå®½åº¦ç”±å®ƒè‡ªå·±å ä½ï¼Œå› æ­¤æ‹–动时"内容区宽度"由 flex è‡ªåŠ¨è®©å‡ºï¼Œæ— éœ€ JS æ”¹å·¦ä¾§ã€‚
*/
.doctor-page__splitter {
  position: relative;
  flex: none;
  width: 6px;
  cursor: col-resize;
  background-color: transparent;
  /* é˜²æ­¢æ‹–动时浏览器做原生拖拽/滚动 */
  touch-action: none;
  outline: none;
  &::before {
    content: '';
    position: absolute;
    top: 0;
    bottom: 0;
    left: 50%;
    width: 1px;
    transform: translateX(-50%);
    background-color: var(--el-border-color);
    transition: background-color 0.15s, width 0.15s;
  }
  &:hover::before,
  &.is-dragging::before,
  &:focus-visible::before {
    width: 2px;
    background-color: var(--el-color-primary);
  }
}
/* ---------- å³ä¾§æ  ---------- */
@@ -238,6 +552,16 @@
}
/*
  å›´è§‚模式(只读):底部功能按钮整组禁用 â€”—
  pointer-events:none åŒæ—¶æŒ¡ä½ç‚¹å‡»ä¸Žæ‚¬åœï¼Œå†åŠ é€æ˜Žåº¦è®©"不能点"一眼可见。
  ï¼ˆæœåŠ¡ç«¯å¦æœ‰ç¡¬æ ¡éªŒï¼šéžå·¥ä½ä¸»äººè°ƒç”¨å†™æŽ¥å£ä¼šè¢«æ‹’ç»ï¼Œå‰ç«¯ç¦ç”¨åªæ˜¯ä½“éªŒå±‚ã€‚ï¼‰
*/
.doctor-page__footer.is-readonly {
  pointer-events: none;
  opacity: 0.55;
}
/*
  å³ä¾§æŽ§ä»¶ç»„:`自动` å¤é€‰æ¡† + åŠŸèƒ½æŒ‰é’® + `停止候诊` å¼€å…³ **合成一组**,
  ä¸€èµ·é å³ã€‚用 gap ç»Ÿä¸€é—´è·ï¼ˆå¹¶æ¸…掉 Element Plus ç»™ç›¸é‚» el-button çš„
  é»˜è®¤ margin-left:12px,否则会与 gap å åŠ å¯¼è‡´é—´è·ä¸å‡ï¼‰ã€‚
src/components/RoomBedSelect/src/RoomBedSelect.vue
@@ -30,7 +30,20 @@
})
// è¯¥åºŠä½æ˜¯å¦å·²ç”±å½“前医生入座
const isMine = computed(() => props.bed.docId != null && props.bed.docId === props.curUser.id)
// æ³¨ï¼šç”¨å­—符串比较,避免后端返回 number / å‰ç«¯ userId ä¸º string æ—¶è¯¯åˆ¤ï¼ˆ"1" !== 1)
const isMine = computed(
  () => props.bed.docId != null && String(props.bed.docId) === String(props.curUser.id)
)
// è¯¥åºŠä½æ˜¯å¦**被别人**占用:此时不允许入座(不挤掉正在操作的人)
const occupiedByOther = computed(
  () => props.bed.docId != null && String(props.bed.docId) !== String(props.curUser.id)
)
// å ç”¨æƒ…况文案:空闲 / æˆ‘的工位 / å·²å…¥åº§ï¼šå¼ ä¸‰ï¼ˆæ‹¿ä¸åˆ°å§“名时显示编号)
const seatStateText = computed(() => {
  if (props.bed.docId == null) return '空闲'
  if (isMine.value) return '我的工位'
  return props.bed.docName ? `已入座:${props.bed.docName}` : `已入座(编号 ${props.bed.docId})`
})
// è¯¥åºŠä½æ˜¯å¦å°±æ˜¯å½“前会话使用中的工位
const isCurSeat = computed(
  () => props.bed.roomId === props.curBed.roomId && props.bed.bedNo === props.curBed.bedNo
@@ -77,6 +90,17 @@
      {{ bed.bedNo }} {{ bed.docName }} {{ bed.status === 30 ? "-暂停" : "" }}
    </div>
    <!--
      å ç”¨æƒ…况(本次需求):一眼看出这个工位是"空闲 / æˆ‘çš„ / åˆ«äººçš„"。
      åŽŸæ¥å¡ç‰‡åªåœ¨å›¾ç‰‡ä¸Žå§“åä¸Šä½“çŽ°ï¼Œåˆ«äººå äº†ä¹Ÿçœ‹ä¸å‡ºæ¥ï¼Œä¼šå‡ºçŽ°"点了入座却被后端拒绝"的困惑。
    -->
    <div
      class="bed-card__seat"
      :class="occupiedByOther ? 'is-occupied' : isMine ? 'is-mine' : 'is-free'"
    >
      {{ seatStateText }}
    </div>
    <div class="bed-card__meta">
      <span v-for="(checkType, subIndex) in bed.checkTypes" :key="subIndex">
        {{ getCheckTypeName(checkType) }}
@@ -86,7 +110,20 @@
    </div>
    <div class="bed-card__actions">
      <el-button v-if="!isMine" @click="onHaveSeatclick(bed)"> å…¥åº§ </el-button>
      <!-- å·²è¢«**别人**占用时禁用「入座」(不挤掉正在操作的人),并用 tooltip è¯´æ˜ŽåŽŸå›  -->
      <el-tooltip
        v-if="!isMine"
        :content="
          occupiedByOther
            ? `该工位由「${bed.docName || '其他医生'}」操作中,不能入座(不挤掉正在操作的人)`
            : '入座到该工位'
        "
        placement="top"
      >
        <span>
          <el-button :disabled="occupiedByOther" @click="onHaveSeatclick(bed)"> å…¥åº§ </el-button>
        </span>
      </el-tooltip>
      <template v-else>
        <el-button @click="onLeaveSeatclick(bed)"> ç¦»åº§ </el-button>
        <span v-if="isCurSeat" class="bed-card__current">当前</span>
@@ -192,6 +229,26 @@
    text-align: center;
  }
  /* å ç”¨æƒ…况:空闲=绿、我的=主色、被别人占=橙(配合禁用入座按钮) */
  &__seat {
    font-size: 13px;
    font-weight: 600;
    line-height: 1.3;
    text-align: center;
    &.is-free {
      color: var(--el-color-success);
    }
    &.is-occupied {
      color: var(--el-color-warning);
    }
    &.is-mine {
      color: var(--el-color-primary);
    }
  }
  &__actions {
    display: flex;
    flex-wrap: wrap;
src/layout/components/CurrentRoomTag.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,87 @@
<!--
  ç³»ç»Ÿé¡¶æ ã€Œå½“前工位 / å›´è§‚对象」标签(主/围观模型)
  æ˜¾ç¤ºä¸‰ç§çŠ¶æ€ï¼š
    Â· æˆ‘在看自己的工位  â†’ ç»¿è‰²  `诊室:1号门诊 Â· A1`            ï¼ˆå¯æ“ä½œï¼‰
    Â· æˆ‘在围观别人的工位 â†’ æ©™è‰²  `围观:2号门诊 Â· B1(主人 å¼ ä¸‰ï¼‰` ï¼ˆåªè¯»ï¼‰
    Â· å°šæœªé€‰æ‹©å·¥ä½      â†’ ç°è‰²  `未选择工位`
  äº¤äº’:**点击**(不论哪种状态)→ æ‰“开「诊室工位选择」页。
  æ•°æ®ä»¥åŽç«¯ä¸ºå‡†ï¼ˆ`GET /clinic/room/my-seat` å–"我入座的工位"),刷新/新标签页也准确。
-->
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useRoomStore } from '@/store/modules/room'
defineOptions({ name: 'CurrentRoomTag' })
const roomStore = useRoomStore()
const router = useRouter()
/** æˆ‘是否正在看自己入座的工位(= å¯æ“ä½œï¼‰ */
const canOperate = computed(() => roomStore.getCanOperate)
/** æ˜¯å¦é€‰æ‹©äº†è¦çœ‹çš„工位 */
const hasView = computed(() => roomStore.getIsSetRoom && !!roomStore.getViewText)
const tagType = computed(() => {
  if (!hasView.value) return 'info'
  return canOperate.value ? 'success' : 'warning'
})
const tagText = computed(() => {
  if (!hasView.value) return '未选择工位'
  if (canOperate.value) return `诊室:${roomStore.getViewText}`
  const owner = roomStore.viewOwnerName
  return `围观:${roomStore.getViewText}${owner ? `(主人 ${owner})` : '(只读)'}`
})
const tooltip = computed(() => {
  if (!hasView.value) return '尚未选择工位,点击选择要查看/入座的工位'
  if (canOperate.value) return `当前工位:${roomStore.getViewText}(点击切换工位)`
  const mine = roomStore.getSeatText
  return `围观模式(只读):正在查看「${roomStore.getViewText}」`
    + (roomStore.viewOwnerName ? `,主人 ${roomStore.viewOwnerName}` : '')
    + (mine ? `;我入座的工位:${mine}` : ';我还没有入座')
    + '。点击可切换工位'
})
/** ç‚¹å‡» â†’ æ‰“开「诊室工位选择」页(未选=去选;已选=去切换) */
const onClick = () => {
  router.push({ path: '/roomselect' })
}
onMounted(async () => {
  // æŸ¥è¯¢ä¸€æ¬¡ï¼šå·²å…¥åº§åˆ™è¡¥é½"我入座的工位"(并把 room å¯¹é½åˆ°æˆ‘的工位),未入座则显示"未选择工位"
  await roomStore.fetchMySeat()
})
</script>
<template>
  <el-tooltip :content="tooltip" placement="bottom">
    <el-tag class="current-room-tag" :type="tagType" :effect="canOperate ? 'light' : 'plain'" @click="onClick">
      <Icon :icon="canOperate ? 'ep:office-building' : 'ep:view'" class="mr-4px" />
      {{ tagText }}
    </el-tag>
  </el-tooltip>
</template>
<style scoped lang="scss">
.current-room-tag {
  margin-right: 12px;
  max-width: 360px;
  font-weight: 600;
  cursor: pointer;
  /* ã€æœ¬æ¬¡éœ€æ±‚】顶栏"当前工位"信息字号加大:原来继承 el-tag é»˜è®¤ 12px åå° */
  font-size: 15px;
  height: 30px;
  line-height: 28px;
  padding: 0 10px;
}
/* å›¾æ ‡åŒæ­¥æ”¾å¤§ï¼ˆel-tag å†…çš„ Icon é»˜è®¤è·Ÿéšå­—号,这里显式指定更稳) */
.current-room-tag :deep(.el-icon) {
  font-size: 16px;
  margin-right: 5px;
}
</style>
src/layout/components/ToolHeader.vue
@@ -7,6 +7,7 @@
import { SizeDropdown } from '@/layout/components/SizeDropdown'
import { LocaleDropdown } from '@/layout/components/LocaleDropdown'
import RouterSearch from '@/components/RouterSearch/index.vue'
import CurrentRoomTag from '@/layout/components/CurrentRoomTag.vue'
import { useAppStore } from '@/store/modules/app'
import { useDesign } from '@/hooks/web/useDesign'
@@ -58,6 +59,8 @@
          </div>
        ) : undefined}
        <div class="h-full flex items-center">
          {/* å½“前入座的诊室工位(需求:顶栏可见,未入座可点击去选) */}
          <CurrentRoomTag />
          {screenfull.value ? (
            <Screenfull class="custom-hover" color="var(--top-header-text-color)"></Screenfull>
          ) : undefined}
src/permission.ts
@@ -19,6 +19,56 @@
const { loadStart, loadDone } = usePageLoading()
/**
 * ã€Œé€‰æ‹©å·¥ä½æ¨¡å¼ã€çš„跳转判定(0/1/2/3)。
 *
 * - 0 æŒ‰éœ€è¦å¼ºåˆ¶é€‰æ‹©å·¥ä½ï¼šæœªå…¥åº§ â†’ å¼ºåˆ¶åŽ»é€‰å·¥ä½é¡µ
 * - 1 ç™»å½•IP匹配工位:先按本机IP自动匹配并自动入座;匹配不到 â†’ æŒ‰ 0(去选工位页,带原因)
 * - 2 ç™»å½•时手动选择工位:强制去选工位页,并在确认时把本机IP写入所选工位(写IP在选工位页里做)
 * - 3 æŒ‰ç³»ç»Ÿé»˜è®¤å¤„理:该IP未绑定过工位 â†’ æŒ‰ 2(选一次并记住IP);已绑定 â†’ æŒ‰ 1;匹配不到 â†’ æŒ‰ 0
 *
 * @returns true è¡¨ç¤ºå·²æ”¾è¡Œï¼ˆæ— éœ€è·³è½¬ï¼‰ï¼Œfalse è¡¨ç¤ºå·²ç»å‘起跳转
 */
const checkRoomSelect = async (
  to: { path: string; fullPath: string },
  next: (v?: any) => void
): Promise<boolean> => {
  const userStore = useUserStoreWithOut()
  const roomStore = useRoomStoreWithOut()
  // éžåŒ»ç”Ÿè§’色、或已入座 â†’ ä¸å¹²é¢„
  const isDoctor =
    userStore.getRoles.includes('doctor') && !userStore.getRoles.includes('super_admin')
  if (!isDoctor || roomStore.getIsSetRoom || to.path === '/roomselect') {
    // çŽ°åœºæŽ’æŸ¥ç”¨ï¼šè¯´æ˜Ž"为什么这次没有自动入座"(admin ç­‰éžåŒ»ç”Ÿè§’色按设计不自动入座,
    // éœ€è¦ç‚¹é¡¶æ çš„「未入座」标签进选工位页手动选一次)
    if (!isDoctor) {
      console.info('[room] å½“前角色非医生(roles=' + userStore.getRoles.join(',') + '),跳过按IP自动入座')
    }
    return true
  }
  const mode = await roomStore.loadRoomSelectMode()
  // æ¨¡å¼ 1 / 3:先尝试按本机IP自动匹配并自动入座(成功就直接进)
  if (mode === 1 || mode === 3) {
    const res = await roomStore.tryAutoSeatByIp()
    if (res.seated) {
      return true
    }
    // ã€ä¸»/围观·本次需求】没自动入座也**不再强制跳"选工位页"**:
    // å…è®¸ä»¥"未入座 / å›´è§‚"身份进入工作台(只读),由工作台顶部的「工位」下拉
    // é€‰æ‹©è¦æŸ¥çœ‹ï¼ˆå›´è§‚)或要入座的工位。若一律弹回选工位页,就永远无法围观别人。
    console.info('[room] æœªè‡ªåŠ¨å…¥åº§ï¼ˆ' + (res.reason || 'nomatch') + '),按"可围观"放行')
    return true
  }
  // æ¨¡å¼ 0 / 2:原来强制去选工位页;本次同样放行,理由同上(由顶部下拉选择工位)。
  // é€‰å·¥ä½é¡µä»å¯ä»Žé¡¶æ å·¥ä½æ ‡ç­¾ / é¡¶éƒ¨ä¸‹æ‹‰è¿›å…¥ã€‚
  console.info('[room] é€‰æ‹©å·¥ä½æ¨¡å¼=' + mode + ':不强制跳选工位页,允许进入后自行选择/围观')
  return true
}
const parseURL = (
  url: string | null | undefined
): { basePath: string; paramsObject: { [key: string]: string } } => {
@@ -102,7 +152,6 @@
    // èŽ·å–æ‰€æœ‰å­—å…¸
    const dictStore = useDictStoreWithOut()
    const userStore = useUserStoreWithOut()
    const roomStore = useRoomStoreWithOut()
    const checkTypeStore = useCheckTypeStoreWithOut()
    const permissionStore = usePermissionStoreWithOut()
    if (!dictStore.getIsSetDict) {
@@ -114,11 +163,8 @@
    if (userStore.getIsSetUser) {
      // <<<【诊室选择】<<<
      if (to.path !== '/roomselect' &&
          userStore.getRoles.includes("doctor") && !userStore.getRoles.includes("super_admin")
          && !roomStore.getIsSetRoom) {
        next({ path: `/roomselect?redirect=${to.fullPath}` })
      // <<<【诊室选择】按「选择工位模式」参数(0/1/2/3)决定是否强制选择工位 >>>
      if (!(await checkRoomSelect(to, next))) {
        return
      }
@@ -136,11 +182,8 @@
      router.addRoute(route as unknown as RouteRecordRaw) // åŠ¨æ€æ·»åŠ å¯è®¿é—®è·¯ç”±è¡¨
    })
    // <<<【诊室选择】<<<
    if (to.path !== '/roomselect' &&
        userStore.getRoles.includes("doctor") && !userStore.getRoles.includes("super_admin")
        && !roomStore.getIsSetRoom) {
      next({ path: `/roomselect?redirect=${to.fullPath}` })
    // <<<【诊室选择】按「选择工位模式」参数(0/1/2/3)决定是否强制选择工位 >>>
    if (!(await checkRoomSelect(to, next))) {
      return
    }
src/store/modules/room.ts
@@ -1,15 +1,65 @@
import { store } from '@/store'
import { defineStore } from 'pinia'
import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
import {RoomVO} from "@/api/ecg/room";
import { RoomApi, RoomVO, RoomSeatResp } from "@/api/ecg/room";
import { DoctorApi } from "@/api/ecg/doctor";
import { getConfigKey } from '@/api/infra/config'
import { useUserStore } from '@/store/modules/user'
const { wsCache } = useCache("sessionStorage")
/** å‚æ•° key:选择工位模式(与后端 Constants.ECG_ROOM_SELECT_MODE_KEY ä¸€è‡´ï¼‰ */
const ROOM_SELECT_MODE_KEY = 'ecg.room.select.mode'
/**
 * é€‰æ‹©å·¥ä½æ¨¡å¼ï¼š
 *  0 = æŒ‰éœ€è¦å¼ºåˆ¶é€‰æ‹©å·¥ä½ï¼ˆ=原行为)
 *  1 = ç™»å½•IP匹配工位(匹配不到按 0 æ‰§è¡Œï¼‰
 *  2 = ç™»å½•时手动选择工位,并写入本机IP
 *  3 = æŒ‰ç³»ç»Ÿé»˜è®¤å¤„理(该IP未绑定过→按2选一次并记住IP;已绑定→按1自动匹配;匹配不到→按0)
 */
export type RoomSelectMode = 0 | 1 | 2 | 3
/** è‡ªåŠ¨åŒ¹é…å·¥ä½çš„ç»“æžœï¼Œä¾›è·¯ç”±å®ˆå«åˆ†æ”¯ç”¨ */
export interface AutoSeatResult {
  /** æ˜¯å¦å·²æˆåŠŸå…¥åº§ */
  seated: boolean
  /** åŽç«¯è¯†åˆ«åˆ°çš„æœ¬æœºIP */
  clientIp: string | null
  /**
   * æœªå…¥åº§åŽŸå› ï¼ˆseated=true æ—¶æ— æ„ä¹‰ï¼‰ï¼š
   * - nomatch:该IP没匹配到工位
   * - occupied:匹配到但全被他人占用
   * - multi:**该IP对应多个可选工位**,无法只凭IP确定,需要人工点选
   * - ip_invalid:本机IP不可用(回环 / IPv6 / æœªæŒ‡å®š / éžæ³•),不能作为工位标识
   * - ip_is_server:识别到的是**服务器自身地址**(在服务器上/远程桌面里访问,或代理覆盖了客户端地址)
   * - error:接口异常或入座写库未生效
   */
  reason?: 'nomatch' | 'occupied' | 'multi' | 'ip_invalid' | 'ip_is_server' | 'error'
  /** è¢«å ç”¨æ—¶çš„占用人,便于提示 */
  occupiedBy?: string | null
}
interface RoomInfoVO {
  // åŒ»ç”Ÿè¯Šå®¤é€‰æ‹©
  // åŒ»ç”Ÿè¯Šå®¤é€‰æ‹©ï¼ˆ= å½“前**查看**的工位:看自己就是自己入座的,看别人就是围观对象)
  isSetRoom: boolean
  room: RoomVO
  // é€‰æ‹©å·¥ä½æ¨¡å¼ï¼ˆ-1 è¡¨ç¤ºè¿˜æ²¡å–到)
  roomSelectMode: number
  /**
   * å½“前登录人**已入座**的工位(系统顶栏显示用;未入座为 null)。
   *
   * ä¸Ž `room` çš„区别:`room` æ˜¯"我正在看哪个工位"(围观时是别人的工位),
   * è€Œ `currentSeat` æ˜¯"我入座在哪个工位"(服务端 clinic_room.doc_id = æˆ‘)。
   * ä¸¤è€…相同 â†’ å¯æ“ä½œï¼›ä¸åŒ â†’ åªè¯»å›´è§‚。
   */
  currentSeat: RoomSeatResp | null
  /** å½“前查看工位的主人的姓名(围观时用于提示"正在围观 X(主人 å¼ ä¸‰ï¼‰") */
  viewOwnerName: string | null
  /** å½“前查看的工位现在是否**无人操作**(主人已离座;只提示,不自动接管) */
  viewTargetVacant: boolean
}
export const useRoomStore = defineStore('current-room', {
  state: (): RoomInfoVO => ({
@@ -26,7 +76,15 @@
      docName: null,
      checkTypes: null,
      opType: null
    }
    },
    // é€‰æ‹©å·¥ä½æ¨¡å¼ï¼š-1 = å°šæœªä»ŽåŽç«¯å–到
    roomSelectMode: -1,
    // å½“前入座的工位(顶栏显示;未入座为 null)
    currentSeat: null,
    // æ­£åœ¨æŸ¥çœ‹çš„工位的主人姓名(围观提示用)
    viewOwnerName: null,
    // æ­£åœ¨æŸ¥çœ‹çš„工位现在是否无人操作(主人已离座 â†’ åªæç¤ºï¼Œä¸è‡ªåŠ¨æŽ¥ç®¡ï¼‰
    viewTargetVacant: false
  }),
  getters: {
    // åŒ»ç”Ÿè¯Šå®¤é€‰æ‹©
@@ -35,6 +93,45 @@
    },
    getRoom(): RoomVO | null {
      return this.room
    },
    /** å½“前入座的工位(可能为 null = æœªå…¥åº§ï¼‰ */
    getCurrentSeat(): RoomSeatResp | null {
      return this.currentSeat
    },
    /**
     * ã€ä¸»/围观】当前是否**可以操作业务**。
     *
     * åˆ¤å®šï¼šæˆ‘入座的工位 == æˆ‘正在看的工位(服务端另有一道硬校验:doc_id å¿…须是我)。
     * ä¸º false æ—¶å‰ç«¯åº”禁用所有写操作按钮,界面显示"围观模式 Â· åªè¯»"。
     */
    getCanOperate(): boolean {
      const seat = this.currentSeat
      const view = this.room
      if (!seat || !seat.seated || !view || view.roomId == null || !view.bedNo) {
        return false
      }
      return String(seat.roomId) === String(view.roomId) && String(seat.bedNo) === String(view.bedNo)
    },
    /** å½“前是否在**围观别人的工位**(有座但看的不是自己那座,或没座只看别人) */
    getIsViewingOther(): boolean {
      return this.isSetRoom && !this.getCanOperate
    },
    /**
     * é¡¶æ å±•示文案:`1号门诊 Â· A1`;未入座返回空串(由组件显示"未入座")。
     * è¯Šå®¤åä¸ºç©ºæ—¶é€€åŒ–为只显示工位号,避免出现 `undefined Â· A1`。
     */
    getSeatText(): string {
      const seat = this.currentSeat
      if (!seat || !seat.seated) return ''
      const name = seat.roomName || `诊室${seat.roomId ?? ''}`
      return seat.bedNo ? `${name} Â· ${seat.bedNo}` : name
    },
    /** é¡¶æ "当前查看的工位"文案(围观时用它,而不是我自己入座的工位) */
    getViewText(): string {
      const view = this.room
      if (!view || view.roomId == null || !view.bedNo) return ''
      const name = view.roomName || `诊室${view.roomId}`
      return `${name} Â· ${view.bedNo}`
    }
  },
  actions: {
@@ -51,6 +148,23 @@
      this.room!.checkTypes = room.checkTypes
      this.room!.opType = room.opType
      this.isSetRoom = true
      // ã€ä¸»/围观】这条路径都是"我入座的工位"(入座 / æ¢å¤æˆ‘的工位 / è‡ªåŠ¨å…¥åº§ / é€‰å·¥ä½é¡µå›žå¡«ï¼‰ï¼Œ
      // å› æ­¤æŠŠ currentSeat ä¸€èµ·è®¾ä¸Š â€”— å¦åˆ™ getCanOperate ä¼šè¯¯åˆ¤ä¸º"无座只读"。
      this.currentSeat = {
        seated: true,
        id: room.id ?? null,
        roomId: room.roomId ?? null,
        roomName: room.roomName ?? null,
        bedNo: room.bedNo ?? null,
        ip: room.ip ?? null,
        status: room.status ?? null,
        docId: room.docId ?? null,
        docName: room.docName ?? null,
        checkTypes: room.checkTypes ?? null,
        opType: room.opType ?? null
      } as RoomSeatResp
      this.viewOwnerName = room.docName ?? null
      this.viewTargetVacant = false
      // ã€ä¿®å¤ã€‘写入缓存(sessionStorage)。
      //
@@ -62,7 +176,47 @@
      //     æ‰å¯åŠ¨å–æ•°å®šæ—¶å™¨ â‡’ **页面一片空白,看起来像"没加载排队病人"**;
      //   Â· è§’色为 doctor çš„用户还会被路由守卫弹回 /roomselect é‡æ–°é€‰è¯Šå®¤ã€‚
      // è¿™é‡Œæ”¹ä¸º**无条件写入**;恢复见文件末尾的 setupRoomStorePersistence()。
      wsCache.set(CACHE_KEY.ROOM, { isSetRoom: true, room: this.room })
      wsCache.set(CACHE_KEY.ROOM, { isSetRoom: true, room: this.room, currentSeat: this.currentSeat })
    },
    /**
     * ã€ä¸»/围观】把"我正在看的工位"切到别人(或切回自己)——**不改我的入座工位**。
     *
     * å›´è§‚就是靠这个动作实现的:`room`(看谁)与 `currentSeat`(我坐哪)分离,
     * ä¸¤è€…不同时 {@link getCanOperate} ä¸º false â†’ ç•Œé¢åªè¯»ï¼ŒæœåŠ¡ç«¯å¦æœ‰ä¸€é“ç¡¬æ ¡éªŒã€‚
     *
     * @param bed       è¦æŸ¥çœ‹çš„工位(来自工位列表)
     * @param ownerName è¯¥å·¥ä½å½“前主人的姓名(无主时传 null)
     */
    async setViewTargetAction(bed: RoomVO, ownerName?: string | null) {
      this.room!.id = bed.id ?? null
      this.room!.roomId = bed.roomId ?? null
      this.room!.roomName = bed.roomName ?? null
      this.room!.bedNo = bed.bedNo ?? null
      this.room!.ip = bed.ip ?? null
      this.room!.status = bed.status ?? null
      this.room!.docId = bed.docId ?? null
      this.room!.docName = bed.docName ?? null
      this.room!.checkTypes = bed.checkTypes ?? null
      this.room!.opType = bed.opType ?? null
      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 })
    },
    /**
     * ã€ä¸»/围观】刷新"我正在看的工位"的最新状态(用于发现主人已离座)。
     *
     * åªæ›´æ–° docId/docName ä¸Ž vacant æ ‡è®°ï¼Œä¸æ”¹å˜"我在看哪个工位"。
     * æŒ‰ç¡®è®¤å£å¾„:主人离座后**只提示、不自动接管**。
     */
    refreshViewTargetState(bed: RoomVO) {
      if (!this.room || String(this.room.roomId) !== String(bed.roomId) || this.room.bedNo !== bed.bedNo) {
        return
      }
      this.room.docId = bed.docId ?? null
      this.room.docName = bed.docName ?? null
      this.viewOwnerName = bed.docName ?? null
      this.viewTargetVacant = bed.docId == null
    },
    // åŒ»ç”Ÿç¦»åº§
    async clearRoomInfoAction() {
@@ -76,6 +230,10 @@
      this.room.docName = null
      this.isSetRoom = false
      // é¡¶æ æ ‡ç­¾åŒæ­¥æ¸…空(离座后显示"未入座")
      this.currentSeat = null
      this.viewOwnerName = null
      this.viewTargetVacant = false
      // æ¸…缓存(与上面的写入对应,避免下次启动把已离座的诊室恢复出来)
      wsCache.delete(CACHE_KEY.ROOM)
@@ -94,8 +252,193 @@
      if (cached?.isSetRoom && cached?.room?.roomId != null && cached?.room?.bedNo != null) {
        this.room = { ...this.room, ...cached.room }
        this.isSetRoom = true
        // ã€ä¸»/围观】连"我入座的工位"一起恢复:否则刷新后 getCanOperate ä¼šè¯¯åˆ¤ä¸ºåªè¯»
        if (cached?.currentSeat) {
          this.currentSeat = cached.currentSeat
          this.viewOwnerName = cached.currentSeat.docName ?? null
          // âš ï¸ã€æœ¬æ¬¡ä¿®å¤ã€‘**不把"围观对象"带到下一次打开**:
          // ç¼“存里的 room è‹¥ä¸Žæˆ‘入座的工位不是同一个,说明上次在围观别人。
          // è‹¥ç…§åŽŸæ ·æ¢å¤ï¼Œåˆ·æ–°åŽå·¥ä½œå°ä¼šä¸€ç›´æ˜¾ç¤º**别人工位的队列**(看着像"患者列表空了"),
          // è€ŒåŒ»ç”Ÿä»¥ä¸ºåœ¨çœ‹è‡ªå·±çš„工位 â€”— çŽ°åœºåé¦ˆçš„"列表整个是空的"很可能就是这个。
          // å›´è§‚是"当前会话里的临时选择",刷新/重开一律回到我自己的工位。
          const seat = cached.currentSeat
          const cachedIsMine =
            String(cached.room.roomId) === String(seat.roomId) && cached.room.bedNo === seat.bedNo
          if (!cachedIsMine) {
            this.room = {
              ...this.room,
              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
            }
            this.viewOwnerName = seat.docName ?? null
            this.viewTargetVacant = false
            console.info('[room] ä¸Šæ¬¡åœ¨å›´è§‚其他工位,本次打开回到我自己的工位', this.room)
          }
        }
      }
    },
    /* ==================== é€‰æ‹©å·¥ä½æ¨¡å¼ï¼ˆ0/1/2/3) ==================== */
    /** è¯»å–「选择工位模式」参数:会话内只取一次;取不到或非法一律按 0(原行为),避免影响存量部署 */
    async loadRoomSelectMode(force = false): Promise<number> {
      if (!force && this.roomSelectMode >= 0) {
        return this.roomSelectMode
      }
      let mode = 0
      try {
        const v = await getConfigKey(ROOM_SELECT_MODE_KEY)
        const n = Number(v)
        if ([0, 1, 2, 3].includes(n)) {
          mode = n
        }
      } catch (e) {
        mode = 0
      }
      this.roomSelectMode = mode
      return mode
    },
    /**
     * æ¨¡å¼ 1/3:按本机IP自动匹配并自动入座。
     *
     * ã€æœ¬æ¬¡ä¿®å¤ã€‘改为**调用后端一个接口**(`POST /clinic/room/auto-seat`)完成
     * ã€ŒåŒ¹é… â†’ åˆ¤å®š â†’ è½åº§ã€ï¼Œä¸å†ç”±å‰ç«¯æ‹¼ `getIpBed â†’ bindIpToBed â†’ bedDoctorOn` ä¸‰æ­¥ï¼š
     * åŽŸæ¥ä»»ä¸€æ­¥å¤±è´¥éƒ½åªèƒ½å›žè½"手选工位",现场表现就是"登录后没有自动入座"且看不出原因。
     * çŽ°åœ¨åŽç«¯ç›´æŽ¥è¿”å›ž `reason`(ip_invalid / nomatch / multi / occupied / error)与识别到的 IP。
     *
     * åŒ¹é…ç­–略(后端实现,与原前端逻辑一致):
     * Â· å·²ç»æ˜¯æˆ‘的工位      â†’ å¹‚等恢复(刷新/重进/上次没正常离座都走这条);
     * Â· ç©ºé—²å€™é€‰**恰好 1 ä¸ª** â†’ å…¥åº§ï¼ˆæœåŠ¡ç«¯åŒæ—¶ä¿è¯"一个用户只占一个工位");
     * Â· ç©ºé—²å€™é€‰**多个**     â†’ ä¸æ“…自入座(reason='multi'),交人工点选;
     * Â· å…¨è¢«ä»–人占用        â†’ reason='occupied' + å ç”¨äººå§“名(**不挤掉**已在操作的人)。
     */
    async tryAutoSeatByIp(): Promise<AutoSeatResult> {
      try {
        const resp: any = (await RoomApi.autoSeat()) || {}
        const clientIp: string | null = resp.clientIp ?? null
        if (resp.seated) {
          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)
          // é¡¶æ "当前工位"标签用
          this.currentSeat = resp as RoomSeatResp
          console.info('[room] æŒ‰IP自动入座成功', resp)
          return { seated: true, clientIp }
        }
        console.info('[room] æŒ‰IP自动入座未成功,转人工选工位', resp)
        return {
          seated: false,
          clientIp,
          reason: (resp.reason || 'nomatch') as AutoSeatResult['reason'],
          occupiedBy: resp.ownerName ?? null
        }
      } catch (e) {
        console.error('[tryAutoSeatByIp] è‡ªåŠ¨å…¥åº§æŽ¥å£å¼‚å¸¸', e)
        return { seated: false, clientIp: null, reason: 'error' }
      }
    },
    /**
     * æŸ¥è¯¢å¹¶è®°å½•"当前登录人已入座的工位"(系统顶栏显示用)。
     *
     * åœºæ™¯ï¼šåˆ·æ–°é¡µé¢ / æ–°å¼€æ ‡ç­¾é¡µ / ç›´æŽ¥è¿›ç³»ç»Ÿï¼Œå†…存里的 `currentSeat` å¯èƒ½æ˜¯ç©ºçš„,
     * ä½†åŽç«¯çŸ¥é“我是哪个工位的主人 â€”— æŸ¥ä¸€æ¬¡å°±èƒ½è®©é¡¶æ ä¸Žå„页面口径一致。
     *
     * ã€ä¸»/围观】注意:**只有在我还没选"要看的工位"、或我看的就是自己工位时**,
     * æ‰æŠŠæŸ¥çœ‹ç›®æ ‡ä¹Ÿå¯¹é½åˆ°æˆ‘的工位;否则会把正在围观的对象覆盖掉(围观就没法用了)。
     */
    async fetchMySeat(): Promise<RoomSeatResp | null> {
      try {
        const resp: any = (await RoomApi.getMySeat()) || {}
        if (!resp.seated) {
          this.currentSeat = null
          return null
        }
        this.currentSeat = resp as RoomSeatResp
        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)
          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)
        return this.currentSeat
      } catch (e) {
        console.error('[fetchMySeat] æŸ¥è¯¢å½“前工位失败', e)
        return null
      }
    },
    /**
     * å…¥åº§ï¼ˆbed-doctor-on)+ å†™å…¥ä¼šè¯ã€‚抽出来给「手动选择」和「按IP自动匹配」共用。
     * @returns æ˜¯å¦å…¥åº§æˆåŠŸ
     */
    async seatRoom(bed: Partial<RoomVO>): Promise<boolean> {
      const userStore = useUserStore()
      const curUser: any = userStore.getUser || {}
      const tempRoomVO: RoomVO = {
        id: bed.id ?? null,
        roomId: bed.roomId ?? null,
        roomName: bed.roomName ?? null,
        bedNo: bed.bedNo ?? null,
        ip: bed.ip ?? null,
        status: bed.status ?? null,
        docId: curUser.id ?? null,
        docName: curUser.nickname ?? null,
        checkTypes: bed.checkTypes ?? null,
        opType: bed.opType ?? null
      }
      const data = await DoctorApi.bedDoctorOn(tempRoomVO)
      if (data !== 0) {
        console.warn('[seatRoom] bedDoctorOn è¿”回非 0:', data)
        return false
      }
      await this.setRoomInfoAction(tempRoomVO)
      return true
    },
    /**
     * æ¨¡å¼ 2/3:把本机IP写到指定工位。
     * @returns { code, conflict, msg } â€”— code=409 è¡¨ç¤ºè¯¥IP已绑在别的工位(conflict=true),需确认后带 force é‡è°ƒ
     */
    async bindCurrentIp(bedId: number, force = false): Promise<any> {
      const resp: any = await RoomApi.bindIpToBed(bedId, force)
      return resp || {}
    }
  }
})
src/utils/releaseBed.ts
@@ -2,7 +2,7 @@
import { getAccessToken } from '@/utils/auth'
/**
 * é€€å‡ºç³»ç»Ÿæ—¶è‡ªåŠ¨é‡Šæ”¾è¯Šç–—åºŠä½ï¼ˆéœ€æ±‚ï¼šåŒ»ç”Ÿå¸¸å¿˜è®°ã€Œç¦»åº§ã€å°±é€€å‡ºç³»ç»Ÿï¼‰ã€‚
 * é€€å‡ºç³»ç»Ÿæ—¶è‡ªåŠ¨é‡Šæ”¾è¯Šå®¤å·¥ä½ï¼ˆéœ€æ±‚ï¼šåŒ»ç”Ÿå¸¸å¿˜è®°ã€Œç¦»åº§ã€å°±é€€å‡ºç³»ç»Ÿï¼‰ã€‚
 *
 * èƒŒæ™¯
 * ----
src/utils/statusFormatter.ts
@@ -56,7 +56,7 @@
  else if (15 === status)
    return '已召回';
  else if (20 === status)
    return '候诊中';
    return '准备中';   // 2026-10-16:原「候诊中」统一改名为「准备中」(与字典 ecg_queue_status ä¸€è‡´ï¼‰
  else if (30 === status)
    return '就诊中';
  else if (33 === status)
src/views/ecg/appointment/CheckItemPanel.vue
@@ -9,7 +9,7 @@
import { cloneDeep } from 'lodash-es'
import { hiprint } from 'vue-plugin-hiprint'
import { calGender, getBookBeginTime } from '@/utils'
import { getCheckTypeName } from '@/utils/checkTypeFormatter'
import { getCheckTypeName, getCheckTypeSeqPrefix } from '@/utils/checkTypeFormatter'
defineComponent({
  name: 'CheckItemPanel'
@@ -29,7 +29,21 @@
const checkTypeTimeslotList = ref<QueueSequenceVO>()
const bookTimeSlotVip = ref<number>()
const bookSeqNum = ref<number>()
/**
 * æœ¬æ¬¡ç­¾åˆ°æ‹¿åˆ°çš„æŽ’队号(号统一后由后端写入 queue.seq_num,并由确认接口直接返回)。
 *
 * è¯´æ˜Žï¼šå˜é‡åä»Ž bookSeqNum æ”¹ä¸º seqNum â€”— å·å·²ç»ç»Ÿä¸€ï¼Œç³»ç»Ÿé‡Œä¸å†æœ‰ book_seq_num è¿™ä¸€è·¯ã€‚
 * é¢„约对象上的 `bookSeqNum` å­—段名保留(接口字段不动),值来源也已改为 queue.seq_num,
 * ä»…在"该预约已经排过队"时用于回显。
 */
const seqNum = ref<number>()
/** æŽ’队号显示文案:检查项目前缀 + å·ï¼ˆå·æŒ‰æ£€æŸ¥é¡¹ç›®å„自编号,必须靠前缀区分) */
const seqNumText = computed(() => {
  const num = seqNum.value ?? props.appointment?.bookSeqNum
  if (num === undefined || num === null || num === '') return ''
  return `${getCheckTypeSeqPrefix(props.appointment?.bookCheckType)}${num}`
})
const _confirmAppointment = async () => {
  if (!isCurrentDay(props.appointment.bookDate)) {
@@ -44,7 +58,7 @@
          tempAppointment.bookTimeslot = bookTimeSlotVip.value
        }
        const data = await AppointmentApi.confirmAppointmentVip(tempAppointment)
        bookSeqNum.value = data
        seqNum.value = data
        // å‘送操作成功的事件
        emit('event_appoint_confirm')
        _printNote()
@@ -79,7 +93,7 @@
          tempAppointment.bookTimeslot = bookTimeSlotVip.value
        }
        const data = await AppointmentApi.confirmAppointmentVip(tempAppointment)
        bookSeqNum.value = data
        seqNum.value = data
        // å‘送操作成功的事件
        emit('event_appoint_confirm')
        _printNote()
@@ -100,7 +114,7 @@
      })
  } else {
    const data = await AppointmentApi.confirmAppointment(props.appointment)
    bookSeqNum.value = data
    seqNum.value = data
    // å‘送操作成功的事件
    emit('event_appoint_confirm')
    // _printNote()
@@ -206,15 +220,14 @@
    }
  })
  // å³ä¾§ Pane è¾“出
  const _bookSeqNum = bookSeqNum.value ?? props.appointment?.bookSeqNum
  // å³ä¾§ Pane è¾“出:排队号(前缀 + å·ï¼Œä¸Žå±ä¸Šæ˜¾ç¤ºä¸€è‡´ï¼‰
  panel.addPrintText({
    options: {
      width: 130,
      height: 20,
      top: 7,
      left: 170,
      title: '' + _bookSeqNum,
      title: seqNumText.value,
      textAlign: 'center',
      fontSize: 30,
      fontFamily: '微软雅黑',
@@ -268,7 +281,7 @@
        :disabled="item.queueVipNo === item.queueVipFull"
      />
    </el-select>
    {{ bookSeqNum ?? appointment.bookSeqNum }}
    {{ seqNumText }}
    <el-divider />
    <el-button
      @click="_confirmAppointment"
src/views/ecg/appointment/alter.vue
@@ -98,12 +98,7 @@
  <!-- åˆ—表 -->
  <ContentWrap>
    <el-table v-loading="loading" :data="appointmentList" :stripe="true" :show-overflow-tooltip="true">
      <el-table-column label="申请单号" align="center" prop="applyNo" min-width="110px" />
      <el-table-column label="检查项目" align="center" prop="bookCheckType" min-width="120px">
        <template #default="scope">
          {{ getCheckTypeName(scope.row.bookCheckType) }}
        </template>
      </el-table-column>
      <el-table-column label="申请单号" align="center" prop="applyNo" min-width="110px" />
      <el-table-column label="患者编号" align="center" prop="patId" />
      <el-table-column label="姓名" align="center" min-width="160px">
        <template #default="scope">
@@ -111,44 +106,46 @@
          <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="scope.row.patGender" />
          <dict-tag :type="DICT_TYPE.ECG_PAT_SOURCE" :value="scope.row.patSrc" />
        </template>
      </el-table-column>
      <el-table-column label="就诊号" align="center" prop="episodeId" min-width="110px" />
      <el-table-column label="多耐" align="center" width="110px">
      </el-table-column> <el-table-column label="检查项目" align="center" prop="bookCheckType" min-width="120px">
        <template #default="scope">
          <dict-tag :type="DICT_TYPE.ECG_TOLERANCE" :value="scope.row.tolerance ?? 0" />
          {{ getCheckTypeName(scope.row.bookCheckType) }}
        </template>
      </el-table-column>
      <el-table-column label="科室名称" align="center" prop="patDeptDesc" />
      <el-table-column label="病区" align="center" prop="patWardDesc" />
      <el-table-column label="床号" align="center" prop="patBedNo" />
      <!--
      <el-table-column label="ç¼´è´¹" align="center" prop="paid">
        <template #default="scope">
          {{scope.row.paid == 1 ? "已付" : ""}}
        </template>
      </el-table-column>
-->
      <el-table-column label="预约日期" align="center" prop="bookDate" :formatter="dateFormatter2" width="120px" />
      </el-table-column> <el-table-column label="预约日期" align="center" prop="bookDate" :formatter="dateFormatter2"
        width="120px" />
      <el-table-column label="预约时间段" align="center" prop="bookTimeslot" width="100px">
        <template #default="scope">
          <dict-tag :type="DICT_TYPE.ECG_BOOK_TIMESLOT" :value="scope.row.bookTimeslot" />
        </template>
      </el-table-column>
      <el-table-column label="开单时间" align="center" prop="createTime" :formatter="dateFormatter2" width="110px" />
      <el-table-column label="开单医生" align="center" prop="doctor" width="110px" /><!-- éœ€æ±‚1:医嘱号(HIS observationRequest.id.item[root=2.16.156.10011.1.28])。
           è¯¥èŠ‚ç‚¹åœ¨è§„èŒƒä¸­éžå¿…é¡»é¡¹ï¼ŒåŽ†å²æ•°æ®ï¼ˆæ–°å¢žåˆ—ä¹‹å‰é‡‡é›†çš„ï¼‰ä¸ºç©ºï¼Œæ˜¾ç¤º '-'。 -->
      <el-table-column label="医嘱号" align="center" prop="doctorAdviceNo" min-width="110px">
      <el-table-column label="多耐" align="center" width="110px">
        <template #default="scope">
          {{ scope.row.doctorAdviceNo || '-' }}
          <dict-tag :type="DICT_TYPE.ECG_TOLERANCE" :value="scope.row.tolerance ?? 0" />
        </template>
      </el-table-column> <el-table-column label="ç¼´è´¹" align="center" prop="paid">
        <template #default="scope">
          {{ scope.row.paid == 1 ? "已付" : "" }}
        </template>
      </el-table-column>
      <el-table-column label="就诊号" align="center" prop="episodeId" min-width="110px" />
      <el-table-column label="门诊号" align="center" min-width="140px">
        <template #default="scope">
          <span>{{ resolveCardNo(scope.row).value }}</span>
          <el-tag v-if="resolveCardNo(scope.row).from" size="small" type="info" effect="plain" class="ml-1">
            {{ resolveCardNo(scope.row).from }}
          </el-tag>
        </template>
      </el-table-column>
      <el-table-column label="科室名称" align="center" prop="patDeptDesc" min-width="120px" />
      <el-table-column label="病区" align="center" prop="patWardDesc" />
      <el-table-column label="床号" align="center" prop="patBedNo" />
      <el-table-column label="开单时间" align="center" prop="createTime" :formatter="dateFormatter2" width="110px" />
      <el-table-column label="开单医生" align="center" prop="doctor" width="110px" />
      <!-- éœ€æ±‚1:医嘱号(HIS observationRequest.id.item[root=2.16.156.10011.1.28])。
           è¯¥èŠ‚ç‚¹åœ¨è§„èŒƒä¸­éžå¿…é¡»é¡¹ï¼ŒåŽ†å²æ•°æ®ï¼ˆæ–°å¢žåˆ—ä¹‹å‰é‡‡é›†çš„ï¼‰ä¸ºç©ºï¼Œæ˜¾ç¤º '-'。 -->
      <el-table-column label="医嘱号" align="center" prop="doctorAdviceNo" min-width="110px">
        <template #default="scope">
          {{ scope.row.doctorAdviceNo || '-' }}
        </template>
      </el-table-column>
      <el-table-column label="登记时间" align="center" min-width="150px">
@@ -159,44 +156,33 @@
      </el-table-column>
      <el-table-column label="距今天数" align="center" width="90px">
        <template #default="scope">{{ formatDaysFromOrder(scope.row.doctorTime) || '-' }}</template>
      </el-table-column>
      <el-table-column label="来源" align="center" width="90px">
        <template #default="scope">
          <dict-tag v-if="scope.row.patSrc !== undefined && scope.row.patSrc !== null" :type="DICT_TYPE.ECG_PAT_SOURCE"
            :value="scope.row.patSrc" />
          <span v-else>-</span>
        </template>
      </el-table-column>
      <el-table-column label="性别" align="center" width="70px">
        <template #default="scope">
          <!-- 1男/2女;0 æˆ–缺失(HIS æœªæä¾›ï¼Œå®žæµ‹åº“中有 2123 è¡Œ=0)显示"未知" -->
          <dict-tag v-if="scope.row.patGender === 1 || scope.row.patGender === 2" :type="DICT_TYPE.SYSTEM_USER_SEX"
            :value="scope.row.patGender" />
          <span v-else>未知</span>
        </template>
      </el-table-column>
      </el-table-column>
      <el-table-column label="身份证号" align="center" prop="patIdentityId" />
      <el-table-column label="出生日期" align="center" prop="patBirthday" :formatter="dateFormatter2" width="110px" />
      <el-table-column label="手机" align="center" prop="patMobile" />
      <el-table-column label="年龄" align="center" width="80px">
        <template #default="scope">{{ formatAge(scope.row.patBirthday) || '-' }}</template>
      </el-table-column>
      <!--      <el-table-column label="电话" align="center" prop="patPhone" />-->
      <!--      <el-table-column label="身份证号" align="center" prop="patIdentityId" />-->
      </el-table-column>
      <!-- <el-table-column label="科室代码" align="center" prop="patDeptCode" /> -->
      <el-table-column label="手机" align="center" prop="patMobile" />
      <el-table-column label="地址" align="center" prop="patAddr" />
      <!-- <el-table-column label="病区代码" align="center" prop="patWardCode" /> -->
      <el-table-column label="操作" fixed="right" align="center" min-width="220px">
      <!-- æ“ä½œåˆ—:按钮样式 + å›ºå®šåœ¨åˆ—表最后位置(fixed="right" å³å§‹ç»ˆè´´åœ¨æœ€å³ä¾§ï¼‰ -->
      <el-table-column label="操作" fixed="right" align="center" width="240px">
        <template #default="scope">
          <el-button link type="primary" @click="openForm('update', scope.row.id)"
          <el-button type="primary" plain size="small" @click="openForm('update', scope.row.id)"
            v-hasPermi="['ecg:appointment:update']">
            ç¼–辑
          </el-button>
          <el-button link type="primary" @click="statusalter(scope.row)" v-hasPermi="['ecg:appointment:update']">
          <el-button type="primary" plain size="small" class="status-edit-btn" @click="statusalter(scope.row)"
            v-hasPermi="['ecg:appointment:update']">
            <!-- çŠ¶æ€æ ‡ç­¾å†…åµŒåœ¨æŒ‰é’®é‡Œï¼šç”±ä¸‹æ–¹ scoped æ ·å¼æŠŠ el-tag åŽ‹æˆã€Œé€æ˜Žåº• + ä¸ŽæŒ‰é’®åŒé«˜ã€ï¼Œ
                 å¦åˆ™æ ‡ç­¾è‡ªå¸¦ç™½åº•与内边距,会显得背景不透明且超出按钮高度 -->
            <dict-tag v-if="scope.row.status !== null && scope.row.status !== undefined && scope.row.status !== ''"
              :type="DICT_TYPE.ECG_STATUS" :value="scope.row.status" />
            <div v-else>状态编辑</div>
            <span v-else>状态编辑</span>
          </el-button>
          <el-button link type="danger" @click="handleDelete(scope.row.id)" v-hasPermi="['ecg:appointment:delete']">
          <el-button type="danger" plain size="small" @click="handleDelete(scope.row.id)"
            v-hasPermi="['ecg:appointment:delete']">
            åˆ é™¤
          </el-button>
        </template>
@@ -387,3 +373,46 @@
  getList()
})
</script>
<style scoped lang="scss">
/**
 * ã€ŒçŠ¶æ€ä¿®æ”¹ã€æŒ‰é’®å†…åµŒçš„ el-tag ä¼˜åŒ–。
 *
 * ã€é—®é¢˜ 1】标签超出按钮高度、背景不透明:
 *   el-tag è‡ªå¸¦ height:24px + padding:0 9px + æµ…色背景,
 *   è€Œ size="small" çš„æŒ‰é’®å†…容区只有 22px é«˜ã€ä¸”按钮本身已有 padding,
 *   äºŽæ˜¯æ ‡ç­¾æ—¢æ¯”按钮高,又盖出一块不透明的浅色底。
 *   â†’ æŠŠæ ‡ç­¾åŽ‹æˆã€Œé€æ˜Žåº• + æ— å†…边距 + ä¸ŽæŒ‰é’®åŒé«˜ã€ï¼Œè§†è§‰ä¸Šåªæ˜¯æŒ‰é’®é‡Œçš„一段文字。
 *
 * ã€é—®é¢˜ 2】鼠标悬停时按钮上的文字看不见:
 *   Element Plus çš„ plain ä¸»è‰²æŒ‰é’® hover æ—¶ä¼šå˜æˆ**实心蓝底 + ç™½å­—**:
 *     --el-button-hover-bg-color:   var(--el-color-primary);
 *     --el-button-hover-text-color: var(--el-color-white);
 *   è€Œè¿™é‡ŒåŽŸå…ˆæŠŠæ ‡ç­¾æ–‡å­—**写死**成 color: var(--el-color-primary)(蓝色),
 *   å åŠ å®žå¿ƒè“åº• â‡’ è“åº•蓝字,文字消失。
 *   â†’ æ”¹ä¸º `color: inherit`,让标签文字始终跟随按钮的文字色:
 *       â€¢ å¸¸æ€ï¼šæŒ‰é’®è“å­— â†’ æ ‡ç­¾è“å­—,配浅蓝底(--el-button-bg-color)对比清晰
 *       â€¢ hover:按钮白字 â†’ æ ‡ç­¾ç™½å­—,配实心蓝底对比清晰
 */
.status-edit-btn {
  :deep(.el-tag) {
    height: 20px;
    line-height: 18px;
    padding: 0;
    margin: 0;
    // èƒŒæ™¯/边框加 !important:
    //   Â· å¸¸è§„情况 el-tag ç”¨ CSS å˜é‡ --el-tag-bg-color ä¸Šè‰²ï¼ˆæ ·å¼è¡¨è§„则即可覆盖)
    //   Â· ä½† ElTag åœ¨æ”¶åˆ° color å±žæ€§æ—¶ä¼šå†™æˆ**内联** background-color,
    //     å†…联样式优先级高于样式表,只有 !important èƒ½åŽ‹ä½
    background-color: transparent !important;
    border-color: transparent !important;
    // å…³é”®ï¼šè·ŸéšæŒ‰é’®çš„æ–‡å­—色,不要再写死颜色,否则 hover æ—¶ä¼šä¸ŽèƒŒæ™¯åŒè‰²è€Œã€Œæ¶ˆå¤±ã€
    color: inherit !important;
    font-size: 12px;
  }
  // æ ‡ç­¾å†…的文字与图标也不需要额外边距
  :deep(.el-tag .el-tag__content) {
    padding: 0;
  }
}
</style>
src/views/ecg/appointment/index.vue
@@ -84,8 +84,41 @@
  <!-- åˆ—表 -->
  <ContentWrap>
    <el-table v-loading="loading" :data="appointmentList" :stripe="true" :show-overflow-tooltip="true">
      <el-table-column label="申请号" align="center" prop="applyNo" min-width="110px" />
      <el-table-column label="患者编号" align="center" prop="patId" />
      <el-table-column label="申请单号" align="center" prop="applyNo" min-width="110px" />
      <el-table-column label="患者编号" align="center" prop="patId" />
      <el-table-column label="姓名" align="center" min-width="160px">
        <template #default="scope">
          {{ scope.row.patName }} <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="scope.row.patGender" />
          <dict-tag :type="DICT_TYPE.ECG_PAT_SOURCE" :value="scope.row.patSrc" />
        </template>
      </el-table-column>
      <el-table-column label="检查项目" align="center" prop="bookCheckType" min-width="140px">
        <template #default="scope">
          {{ getCheckTypeName(scope.row.bookCheckType) }}
        </template>
      </el-table-column>
      <el-table-column label="预约日期" align="center" prop="bookDate" :formatter="dateFormatter2" width="120px" />
      <el-table-column label="预约时间段" align="center" prop="bookTimeslot" width="100px">
        <template #default="scope">
          <dict-tag :type="DICT_TYPE.ECG_BOOK_TIMESLOT" :value="scope.row.bookTimeslot" />
        </template>
      </el-table-column>
      <!-- éœ€æ±‚1:状态 -->
      <!-- æ³¨æ„ï¼šå¿…须显式判空,不能用 v-if="scope.row.status" â€”—
           çŠ¶æ€ '0'(未登记)是合法值,真值判断会把它误判为"无数据"而显示 '-' -->
      <el-table-column label="状态" align="center" prop="status" width="110px">
        <template #default="scope">
          <dict-tag v-if="scope.row.status !== null && scope.row.status !== undefined && scope.row.status !== ''"
            :type="DICT_TYPE.ECG_STATUS" :value="scope.row.status" />
          <span v-else>-</span>
        </template>
      </el-table-column>
      <el-table-column label="ç¼´è´¹" align="center" prop="paid">
        <template #default="scope">
          {{ scope.row.paid == 1 ? "已付" : "" }}
        </template>
      </el-table-column>
      <el-table-column label="就诊号" align="center" prop="episodeId" min-width="110px" />
      <!-- æœ¬åˆ—标签由「就诊卡号」改为「门诊号」(经确认)。原因(详见《部署清单》5.1):
           HIS åœ¨å¡å·æ§½ä½ä¼ ç©ºï¼Œmedical_card å…¨åº“ 159,116 è¡Œ**无一非空**;
@@ -99,39 +132,6 @@
          <el-tag v-if="resolveCardNo(scope.row).from" size="small" type="info" effect="plain" class="ml-1">
            {{ resolveCardNo(scope.row).from }}
          </el-tag>
        </template>
      </el-table-column>
      <el-table-column label="姓名" align="center" min-width="160px">
        <template #default="scope">
          {{ scope.row.patName }} <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="scope.row.patGender" />
          <dict-tag :type="DICT_TYPE.ECG_PAT_SOURCE" :value="scope.row.patSrc" />
        </template>
      </el-table-column>
      <el-table-column label="预约日期" align="center" prop="bookDate" :formatter="dateFormatter2" width="120px" />
      <el-table-column label="预约时间段" align="center" prop="bookTimeslot" width="100px">
        <template #default="scope">
          <dict-tag :type="DICT_TYPE.ECG_BOOK_TIMESLOT" :value="scope.row.bookTimeslot" />
        </template>
      </el-table-column>
      <el-table-column label="检查项目" align="center" prop="bookCheckType" min-width="120px">
        <template #default="scope">
          {{ getCheckTypeName(scope.row.bookCheckType) }}
        </template>
      </el-table-column>
      <el-table-column label="ç¼´è´¹" align="center" prop="paid">
        <template #default="scope">
          {{ scope.row.paid == 1 ? "已付" : "" }}
        </template>
      </el-table-column>
      <!-- éœ€æ±‚1:状态 -->
      <!-- æ³¨æ„ï¼šå¿…须显式判空,不能用 v-if="scope.row.status" â€”—
           çŠ¶æ€ '0'(未登记)是合法值,真值判断会把它误判为"无数据"而显示 '-' -->
      <el-table-column label="状态" align="center" prop="status" width="110px">
        <template #default="scope">
          <dict-tag v-if="scope.row.status !== null && scope.row.status !== undefined && scope.row.status !== ''"
            :type="DICT_TYPE.ECG_STATUS" :value="scope.row.status" />
          <span v-else>-</span>
        </template>
      </el-table-column>
      <!-- éœ€æ±‚1:年龄(库中无此列,由生日实时计算) -->
@@ -157,23 +157,25 @@
        </template>
      </el-table-column>
      <el-table-column label="登记时间" align="center" prop="registerDate" />
      <el-table-column label="科室代码" align="center" prop="patDeptCode" />
      <el-table-column label="科室名称" align="center" prop="patDeptDesc" />
      <el-table-column label="病区代码" align="center" prop="patWardCode" />
      <!-- <el-table-column label="科室代码" align="center" prop="patDeptCode" /> -->
      <el-table-column label="科室名称" align="center" prop="patDeptDesc" min-width="120px"/>
      <!-- <el-table-column label="病区代码" align="center" prop="patWardCode" /> -->
      <el-table-column label="病区名称" align="center" prop="patWardDesc" />
      <el-table-column label="床号" align="center" prop="patBedNo" />
      <el-table-column label="生日" align="center" prop="patBirthday" :formatter="dateFormatter2" width="110px" />
      <el-table-column label="手机" align="center" prop="patMobile" />
      <!--      <el-table-column label="电话" align="center" prop="patPhone" />-->
      <!--      <el-table-column label="身份证号" align="center" prop="patIdentityId" />-->
      <el-table-column label="地址" align="center" prop="patAddr" />
      <el-table-column label="操作" align="center" min-width="120px">
      <el-table-column label="地址" align="center" prop="patAddr" min-width="150px"/>
      <!-- æ“ä½œåˆ—:按钮样式 + å›ºå®šåœ¨åˆ—表最右侧 -->
      <el-table-column label="操作" fixed="right" align="center" width="160px">
        <template #default="scope">
          <el-button link type="primary" @click="openForm('update', scope.row.id)"
          <el-button type="primary" plain size="small" @click="openForm('update', scope.row.id)"
            v-hasPermi="['ecg:appointment:update']">
            ç¼–辑
          </el-button>
          <el-button link type="danger" @click="handleDelete(scope.row.id)" v-hasPermi="['ecg:appointment:delete']">
          <el-button type="danger" plain size="small" @click="handleDelete(scope.row.id)"
            v-hasPermi="['ecg:appointment:delete']">
            åˆ é™¤
          </el-button>
        </template>
src/views/ecg/checktype/CheckTypeForm.vue
@@ -17,12 +17,12 @@
    >
      <el-row :gutter="20">
        <el-col :span="12">
          <el-form-item label="检查项目名称" prop="name">
          <el-form-item label="项目名称" prop="name">
            <el-input v-model="formData.name" placeholder="请输入检查项目名称" />
          </el-form-item>
        </el-col>
        <el-col :span="12">
          <el-form-item label="检查项目值" prop="value">
          <el-form-item label="项目编码" prop="value">
            <el-input v-model="formData.value" placeholder="请输入检查项目值" />
          </el-form-item>
        </el-col>
@@ -38,13 +38,19 @@
          </el-form-item>
        </el-col>
        <el-col :span="12">
          <el-form-item label="允许准备中人数" prop="readyNum">
            <el-input v-model="formData.readyNum" placeholder="请输入允许准备中人数" />
          <el-form-item label="候诊准备人数" prop="readyNum">
            <!--
              ã€æœ¬æ¬¡è¯´æ˜Žã€‘这个值决定该检查项目在工位上「候诊区」的容量上限:
              ç³»ç»Ÿè‡ªåŠ¨æŠŠã€ŒæŽ’é˜Ÿä¸­ã€çš„æ‚£è€…æ¬è¿›ã€Œå€™è¯ŠåŒºã€æ—¶ï¼Œæœ€å¤šæ¬åˆ°è¿™ä¸ªäººæ•°ã€‚
              **å¡« 0 = ä¸è‡ªåŠ¨åˆ†æµ**(排队中的患者不会主动进候诊区,需在工位候诊区用「拉入」手动补人)。
              å¤šä¸ªæ£€æŸ¥é¡¹ç›®çš„工位,取其中**最大值**。
            -->
            <el-input v-model="formData.readyNum" placeholder="候诊区自动补人上限;0=不自动分流(靠手动拉入)" />
          </el-form-item>
        </el-col>
        <el-col :span="12">
          <el-form-item label="需要设备领用" prop="needDevReady">
          <el-form-item label="设备领用" prop="needDevReady">
            <el-switch v-model="formData.needDevReady" :active-value="1" :inactive-value="0" />
          </el-form-item>
        </el-col>
@@ -55,7 +61,7 @@
        </el-col>
        <el-col :span="12">
          <el-form-item label="叫号列" prop="callingColumn">
          <el-form-item label="大屏显示列" prop="callingColumn">
            <el-input v-model="formData.callingColumn" placeholder="请输入叫号列" />
          </el-form-item>
        </el-col>
@@ -66,25 +72,25 @@
        </el-col>
        <el-col :span="12">
          <el-form-item label="时段预约规定人数" prop="timeslotBookNum">
          <el-form-item label="时段预约人数" prop="timeslotBookNum">
            <el-input v-model="formData.timeslotBookNum" placeholder="请输入时段预约规定人数" />
          </el-form-item>
        </el-col>
        <el-col :span="12">
          <el-form-item label="每时段预留人数" prop="timeslotReservedNum">
          <el-form-item label="时段预留人数" prop="timeslotReservedNum">
            <el-input v-model="formData.timeslotReservedNum" placeholder="请输入每时段预留人数" />
          </el-form-item>
        </el-col>
        <el-col :span="12">
          <el-form-item label="预约注意事项" prop="notes">
          <el-form-item label="注意事项" prop="notes">
            <el-input v-model="formData.notes" placeholder="请输入预约注意事项" />
          </el-form-item>
        </el-col>
        <!-- â†“↓↓ ä»¥ä¸‹ä¸‰é¡¹æŒ‰è¦æ±‚「单占一行」 â†“↓↓ -->
        <el-col :span="24">
          <el-form-item label="显示患者条码" prop="displayBarcode">
          <el-form-item label="患者条码显示" prop="displayBarcode">
            <el-checkbox-group v-model="formData.displayBarcode">
              <el-checkbox
                  v-for="(dict, index) in getIntDictOptions(DICT_TYPE.ECG_PAT_SOURCE)"
@@ -98,8 +104,8 @@
        <!-- éœ€æ±‚:排队序号规则(签到时如何给患者排队序号) -->
        <el-col :span="24">
          <el-form-item label="排队序号规则" prop="seqRule">
            <el-select v-model="formData.seqRule" placeholder="请选择排队序号规则" class="w-full">
          <el-form-item label="排队规则" prop="seqRule">
            <el-select v-model="formData.seqRule" placeholder="请选择患者排队序号生成规则" class="w-full">
              <el-option
                v-for="dict in getIntDictOptions(DICT_TYPE.ECG_SEQ_RULE)"
                :key="dict.value"
@@ -108,9 +114,9 @@
              />
            </el-select>
            <div class="text-12px text-gray-500 leading-5 mt-1">
              é¢„约号:用预约系统下发的号,签到不再分配;<br />
              ç­¾åˆ°åºå·ï¼šå½“天同检查项目内连续递增(第 1 ä¸ªç­¾åˆ°=1 å·ï¼‰ï¼Œæ¯ 9 ä¸ªå·é¢„ç•™ 1 ä¸ªç»™åŠ æ€¥ï¼›<br />
              æ—¶æ®µå·ï¼šæŒ‰é¢„约时间段给号(<b>不选时默认此项</b>,与改造前行为一致)。
              æŒ‰é¢„约号:用预约系统下发的号,签到不再分配;<br />
              æŒ‰ç­¾åˆ°é¡ºåºï¼šå½“天同检查项目内连续递增(第 1 ä¸ªç­¾åˆ°=1 å·ï¼‰ï¼Œæ¯ 9 ä¸ªå·é¢„ç•™ 1 ä¸ªç»™åŠ æ€¥ï¼›<br />
              æŒ‰æ—¶æ®µåˆ†é…ï¼šæŒ‰é¢„约时间段给号(<b>不选时默认此项</b>,与改造前行为一致)。
            </div>
          </el-form-item>
        </el-col>
src/views/ecg/checktype/QueueSequenceForm.vue
@@ -125,7 +125,7 @@
        </template>
      </el-table-column>
      <el-table-column label="操作" align="center" width="140px" fixed="right">
      <el-table-column label="操作" align="center" width="160px" fixed="right">
        <template #default="scope">
          <el-button
            link
@@ -214,7 +214,6 @@
defineOptions({ name: 'QueueSequenceForm' })
const message = useMessage() // æ¶ˆæ¯å¼¹çª—
const dialogVisible = ref(false) // ä¸»å¼¹çª—
const loading = ref(false) // åˆ—表加载中
const list = ref<QueueSequenceVO[]>([]) // æŸæ£€æŸ¥é¡¹ç›®ä¸‹çš„全部时段
@@ -222,7 +221,6 @@
const checkTypeName = ref<string>('') // æ£€æŸ¥é¡¹ç›®åç§°ï¼ˆä»…用于展示)
const changedRows = ref<Set<number>>(new Set()) // å·²è¢«æ”¹åŠ¨ä½†æœªä¿å­˜çš„è¡Œ id
const savingId = ref<number>() // æ­£åœ¨ä¿å­˜çš„行 id
const dialogTitle = computed(
  () => `预约时段维护${checkTypeName.value ? ' - ' + checkTypeName.value : ''}`
)
src/views/ecg/checktype/index.vue
@@ -1,40 +1,23 @@
<template>
  <ContentWrap>
    <!-- æœç´¢å·¥ä½œæ  -->
    <el-form
      class="-mb-15px"
      :model="queryParams"
      ref="queryFormRef"
      :inline="true"
      label-width="80px"
    >
    <el-form class="-mb-15px" :model="queryParams" ref="queryFormRef" :inline="true" label-width="80px">
      <el-form-item label="项目名称" prop="name">
        <el-input
          v-model="queryParams.name"
          placeholder="请输入检查项目名称"
          clearable
          @keyup.enter="handleQuery"
          class="!w-240px"
        />
      </el-form-item>
        <el-input v-model="queryParams.name" placeholder="请输入检查项目名称" clearable @keyup.enter="handleQuery"
          class="!w-240px" />
      </el-form-item>
      <el-form-item>
        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> æœç´¢</el-button>
        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> é‡ç½®</el-button>
        <el-button
          type="primary"
          plain
          @click="openForm('create')"
          v-hasPermi="['ecg:check-type:create']"
        >
        <el-button @click="handleQuery">
          <Icon icon="ep:search" class="mr-5px" /> æœç´¢
        </el-button>
        <el-button @click="resetQuery">
          <Icon icon="ep:refresh" class="mr-5px" /> é‡ç½®
        </el-button>
        <el-button type="primary" plain @click="openForm('create')" v-hasPermi="['ecg:check-type:create']">
          <Icon icon="ep:plus" class="mr-5px" /> æ–°å¢ž
        </el-button>
        <el-button
          type="success"
          plain
          @click="handleExport"
          :loading="exportLoading"
          v-hasPermi="['ecg:check-type:export']"
        >
        <el-button type="success" plain @click="handleExport" :loading="exportLoading"
          v-hasPermi="['ecg:check-type:export']">
          <Icon icon="ep:download" class="mr-5px" /> å¯¼å‡º
        </el-button>
      </el-form-item>
@@ -45,77 +28,67 @@
  <ContentWrap>
    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
      <!-- éœ€æ±‚9:不再显示数据库 id;改为显示**可编辑**的「排序序号」(越小越靠前) -->
      <el-table-column label="排序序号" align="center" prop="sort" width="130px">
      <el-table-column label="序号" align="center" prop="sort" width="130px">
        <template #default="scope">
          <el-input-number
            v-model="scope.row.sort"
            :min="0"
            :controls="false"
            size="small"
            style="width: 100%"
            @change="handleSortChange(scope.row)"
          />
          <el-input-number v-model="scope.row.sort" :min="0" :controls="false" size="small" style="width: 100%"
            @change="handleSortChange(scope.row)" />
        </template>
      </el-table-column>
      <el-table-column label="项目名称" align="center" prop="name" width="180px"/>
      <el-table-column label="项目值" align="center" prop="value" />
      <el-table-column label="候诊人数" align="center" prop="readyNum" />
<!--      <el-table-column label="创建时间" align="center" prop="createTime" :formatter="dateFormatter" width="180px />-->
      <el-table-column label="需要设备领用" align="center" prop="needDevReady">
        <template #default="scope">
          {{scope.row.needDevReady === 0 ? "不需要":"需要"}}
        </template>
      </el-table-column>
      <el-table-column label="叫号列" align="center" prop="callingColumn" />
      <el-table-column label="显示患者条码" align="center" prop="displayBarcode" />
      <el-table-column label="需要确费" align="center" prop="expenseRecognition" />
      <el-table-column label="时段预约人数" align="center" prop="timeslotBookNum" />
      <el-table-column label="时段预留人数" align="center" prop="timeslotReservedNum" />
      <el-table-column label="预约注意事项" align="center" prop="notes" />
      <el-table-column label="序号前缀" align="center" prop="seqPrefix" />
      <el-table-column label="亲和项目" align="center" prop="affinityCheckTypes" >
      <el-table-column label="项目名称" align="center" prop="name" width="180px" />
      <el-table-column label="项目编码" align="center" prop="value" />
      <el-table-column label="亲和项目" align="center" prop="affinityCheckTypes">
        <template #default="scope">
          <div v-for="(checkType, subIndex) in scope.row.affinityCheckTypes" :key="subIndex">
            {{getCheckTypeName(checkType)}}
            {{ getCheckTypeName(checkType) }}
          </div>
        </template>
      </el-table-column>
      <!--      <el-table-column label="创建时间" align="center" prop="createTime" :formatter="dateFormatter" width="180px />-->
      <el-table-column label="设备领用" align="center" prop="needDevReady">
        <template #default="scope">
          {{ scope.row.needDevReady === 0 ? "不需要" : "需要" }}
        </template>
      </el-table-column>
      <!-- éœ€æ±‚:库里存的是 0/1,直接渲染会显示数字,这里按中文展示(与上一列「设备领用」写法一致) -->
      <el-table-column label="需要确费" align="center" prop="expenseRecognition">
        <template #default="scope">
          {{ scope.row.expenseRecognition === 1 ? "需要" : "不需要" }}
        </template>
      </el-table-column>
      <el-table-column label="时段预约人数" align="center" prop="timeslotBookNum" />
      <el-table-column label="时段预留人数" align="center" prop="timeslotReservedNum" />
      <!-- æŽ’队规则(check_type.seq_rule):库里是 0/1/2,按字典 ecg_seq_rule æ˜¾ç¤ºä¸­æ–‡ï¼›
           æœªé…ç½®(null) æ—¶åŽç«¯æŒ‰ã€Œæ—¶æ®µå·ã€å¤„理,这里显式提示,避免出现空白单元格 -->
      <el-table-column label="排队规则" align="center" prop="seqRule">
        <template #default="scope">
          <span v-if="scope.row.seqRule === null || scope.row.seqRule === undefined">时段号(默认)</span>
          <dict-tag v-else :type="DICT_TYPE.ECG_SEQ_RULE" :value="scope.row.seqRule" />
        </template>
      </el-table-column>
      <el-table-column label="序号前缀" align="center" prop="seqPrefix" />
      <el-table-column label="候诊人数" align="center" prop="readyNum" />
      <el-table-column label="大屏显示列" align="center" prop="callingColumn" />
      <!-- <el-table-column label="患者条码" align="center" prop="displayBarcode" /> -->
      <el-table-column label="注意事项" align="center" prop="notes" />
      <el-table-column label="操作" align="center" min-width="200px" fixed="right">
        <template #default="scope">
          <el-button
            link
            type="primary"
            @click="openQueueSequenceForm(scope.row)"
            v-hasPermi="['ecg:check-type:update']"
          >
            é¢„约时段
          </el-button>
          <el-button
            link
            type="primary"
            @click="openForm('update', scope.row.id)"
            v-hasPermi="['ecg:check-type:update']"
          >
          <el-button link type="primary" @click="openForm('update', scope.row.id)"
            v-hasPermi="['ecg:check-type:update']">
            ç¼–辑
          </el-button>
          <el-button
            link
            type="danger"
            @click="handleDelete(scope.row.id)"
            v-hasPermi="['ecg:check-type:delete']"
          >
          <el-button link type="primary" @click="openQueueSequenceForm(scope.row)"
            v-hasPermi="['ecg:check-type:update']">
            é¢„约时段
          </el-button>
          <el-button link type="danger" @click="handleDelete(scope.row.id)" v-hasPermi="['ecg:check-type:delete']">
            åˆ é™¤
          </el-button>
        </template>
      </el-table-column>
    </el-table>
    <!-- åˆ†é¡µ -->
    <Pagination
      :total="total"
      v-model:page="queryParams.pageNo"
      v-model:limit="queryParams.pageSize"
      @pagination="getList"
    />
    <Pagination :total="total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize"
      @pagination="getList" />
  </ContentWrap>
  <!-- è¡¨å•弹窗:添加/修改 -->
@@ -126,22 +99,21 @@
</template>
<script setup lang="ts">
import {dateFormatter, dateFormatter2} from '@/utils/formatTime'
import { dateFormatter, dateFormatter2 } from '@/utils/formatTime'
import download from '@/utils/download'
import { CheckTypeApi, CheckTypeVO } from '@/api/ecg/checktype'
import { DICT_TYPE } from '@/utils/dict'
import CheckTypeForm from './CheckTypeForm.vue'
import QueueSequenceForm from './QueueSequenceForm.vue'
import {useCheckTypeStore} from "@/store/modules/checkType";
import {getCheckTypeName} from "../../../utils/checkTypeFormatter";
import { useCheckTypeStore } from "@/store/modules/checkType";
import { getCheckTypeName } from "../../../utils/checkTypeFormatter";
/** æ£€æŸ¥é¡¹ç›® åˆ—表 */
defineOptions({ name: 'CheckType' })
const message = useMessage() // æ¶ˆæ¯å¼¹çª—
const { t } = useI18n() // å›½é™…化
const checkTypeStore = useCheckTypeStore();
const loading = ref(true) // åˆ—表的加载中
const list = ref<CheckTypeVO[]>([]) // åˆ—表的数据
const total = ref(0) // åˆ—表的总页数
@@ -226,7 +198,7 @@
    message.success(t('common.delSuccess'))
    // åˆ·æ–°åˆ—表
    await getList()
  } catch {}
  } catch { }
}
/** å¯¼å‡ºæŒ‰é’®æ“ä½œ */
src/views/ecg/doctor/DevDismantle.vue
@@ -75,10 +75,19 @@
        <el-input v-model="formData.remark" placeholder="请输入备注" />
      </el-form-item>
    </el-form>
    <el-button @click="submitForm" type="primary" :disabled="formLoading">拆机完成</el-button>
    <el-button @click="resetForm()">重 ç½®</el-button>
    <el-button @click="loseDismantle" :disabled="formLoading">遗 å¤±</el-button>
    <el-button @click="damageDismantle" :disabled="formLoading">损 æ¯</el-button>
    <!-- ã€æœ¬æ¬¡éœ€æ±‚】操作功能区:与其它三个工作台同款容器(浅主色底 + å·¦ä¾§ä¸»è‰²ç«–条) -->
    <div class="patient-actions">
      <el-button @click="submitForm" type="primary" :disabled="formLoading">拆机完成</el-button>
      <el-button @click="resetForm()">重 ç½®</el-button>
      <el-button @click="loseDismantle" :disabled="formLoading">遗 å¤±</el-button>
      <el-button @click="damageDismantle" :disabled="formLoading">损 æ¯</el-button>
    </div>
  <!-- ã€æœ¬æ¬¡éœ€æ±‚】操作功能区与已完成记录之间的标题栏(与检查/领用/装机三个工作台同款) -->
  <div class="records-title">
    <span class="records-title__bar"></span>
    <span class="records-title__text">已完成列表</span>
  </div>
  <!-- åŒ»ç”Ÿæ‹†æœºåˆ—表 -->
  <ContentWrap>
@@ -400,6 +409,75 @@
</script>
<style scoped lang="scss">
/*
  ã€æœ¬æ¬¡éœ€æ±‚】按钮统一尺寸 / å­—号(与其它三个工作台一致;本页不使用 DoctorPageLayout)
  æ™®é€šæŒ‰é’® 32px / 14px å­—;表格内行内按钮 28px / 13px å­—;链接式按钮只放大字号。
*/
.patient-actions :deep(.el-button) {
  height: 32px;
  padding: 0 14px;
  font-size: 14px;
}
.el-table :deep(.el-button:not(.is-link)) {
  height: 28px;
  padding: 0 10px;
  font-size: 13px;
}
.patient-actions :deep(.el-button.is-link),
.el-table :deep(.el-button.is-link) {
  height: auto;
  padding: 0 4px;
  font-size: 14px;
}
/*
  ã€æœ¬æ¬¡éœ€æ±‚】操作功能区:与检查/领用/装机三个工作台完全一致 â€”—
  æžæµ…主色底 + ç»†è¾¹æ¡† + å·¦ä¾§ 3px ä¸»è‰²ç«–条(与「已完成列表」标题条同款语言)。
*/
.patient-actions {
  display: flex;
  flex: none;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
  padding: 8px 10px;
  margin: 2px 0 10px;
  background-color: var(--el-color-primary-light-9);
  border: 1px solid var(--el-color-primary-light-7);
  border-left: 3px solid var(--el-color-primary);
  border-radius: 4px;
  /* Element Plus ç»™ç›¸é‚» el-button é»˜è®¤åŠ äº† margin-left:12px,会与 gap å åŠ ï¼Œæ¸…æŽ‰ */
  :deep(.el-button + .el-button) {
    margin-left: 0;
  }
}
/* ã€æœ¬æ¬¡éœ€æ±‚】「已完成列表」标题栏:与检查/领用/装机三个工作台完全一致
   ï¼ˆå·¦ä¾§ä¸»è‰²ç«–线 + 14px åŠ ç²—ï¼Œç´§å‡‘é«˜åº¦ä¸æŒ¤åŽ‹è¡¨æ ¼ï¼‰ */
.records-title {
  display: flex;
  align-items: center;
  flex: none;
  gap: 6px;
  padding: 2px 0 6px;
  &__bar {
    width: 3px;
    height: 14px;
    background-color: var(--el-color-primary);
    border-radius: 2px;
  }
  &__text {
    font-size: 14px;
    font-weight: 600;
    color: var(--el-text-color-primary);
  }
}
/* ==========================================================================
   éœ€æ±‚:表单内容挤占了下方记录表格的空间 â€”— åŽ‹ç¼©è¡Œé—´è·ã€‚
   Element Plus é»˜è®¤ `.el-form-item { margin-bottom: 18px }`;
src/views/ecg/doctor/DevInstall.vue
@@ -40,6 +40,8 @@
const state = ref<boolean>()
const patientList = ref<QueueVO[]>([])
/** ã€è‡ªåŠ©è¯Šæ–­ã€‘æœ€è¿‘ä¸€æ¬¡å–æ•°çš„è¯´æ˜Žï¼ˆç©ºåˆ—è¡¨æ—¶æ˜¾ç¤ºï¼‰ */
const fetchInfo = ref('')
let timerRunFlag : boolean = false
const bedControlFlag = ref<boolean>(true);
@@ -95,13 +97,16 @@
    return
  }
  patientList.value = await DoctorApi.getToBeInstalledPatientList(roomBedVO.value)
  patientList.value = (await DoctorApi.getToBeInstalledPatientList(roomBedVO.value)) || []
  // ã€è‡ªåŠ©è¯Šæ–­ã€‘è®°å½•æœ¬æ¬¡å–æ•°æ—¶é—´ä¸Žæ¡æ•°ï¼ˆç©ºåˆ—è¡¨æ—¶æ˜¾ç¤ºï¼‰
  fetchInfo.value = `最近取数 ${new Date().toLocaleTimeString()} Â· è¿”回 ${patientList.value.length} æ¡`
  getInstallingPatient()
  patientStat.value = await DoctorApi.getDevInstallStatistic(roomBedVO.value)
  const queueVO2 = await DoctorApi.bedDoctorGet(roomBedVO.value)
  if (queueVO2.status === 20)
  // ã€åŠ å›ºã€‘æŽ¥å£å¯èƒ½è¿”å›ž null,原来直接取 .status ä¼šæŠ›å¼‚常、中断本轮后续步骤
  if (queueVO2 && queueVO2.status === 20)
    state.value = true
  else if (queueVO2.status === 30)
  else if (queueVO2 && queueVO2.status === 30)
    state.value = false
  // éœ€æ±‚3:同步"停止候诊"开关状态
  await loadStopReady()
@@ -188,6 +193,8 @@
      checkType: last.bookCheckType
    } as any)
    ElMessage.success(msg || '已撤销')
    // ã€æ’¤é”€æ ‡è¯†ã€‘撤销后立刻刷新「已完成记录」列表,让这条记录显示成「已撤销」
    devInstallPanelRef.value?.getList?.()
    await initLoad()
  } finally {
    undoFlag.value = false
@@ -211,12 +218,42 @@
const doctorTimer = () => {
  console.log('doctorTimer ...')
  initLoad()
  // ã€åŠ å›ºã€‘å•è½®å–æ•°å¤±è´¥ä¸èƒ½å˜æˆ unhandled rejection(原来 `initLoad()` ä¸ await ä¸ catch,
  // æŸè½®æŽ¥å£å¼‚常会让页面停在旧数据,现场表现就是"列表一直是空的")。
  initLoad().catch((e) => console.error('[doctorTimer] æœ¬è½®å–数失败(已忽略,下一轮继续)', e))
  if (timerRunFlag)
    setTimeout(doctorTimer, 5000) // five seconds
}
/** åˆå§‹åŒ– **/
/**
 * ã€ä¸»/围观】跟随 store åˆ‡æ¢"正在查看的工位"(原来只在 onMounted æ‹·ä¸€æ¬¡ï¼Œ
 * åˆ‡åˆ°åˆ«çš„人工位围观后本页数据不变,必须刷新页面才生效)。
 */
watch(
  () => [roomStore.room?.roomId, roomStore.room?.bedNo],
  async () => {
    if (!roomStore.room) return
    roomBedVO.value.roomId = roomStore.room.roomId
    roomBedVO.value.roomName = roomStore.room.roomName
    roomBedVO.value.bedNo = roomStore.room.bedNo
    roomBedVO.value.checkTypes = roomStore.room.checkTypes
    roomBedVO.value.opType = roomStore.room.opType
    // ã€æœ¬æ¬¡éœ€æ±‚】换工位后**整页数据都换成新工位的**:
    // â‘  å…ˆæ¸…空左侧"患者信息"(避免还显示上一个工位的患者)
    devInstallPanelRef.value?.setPatient?.(undefined)
    // â‘¡ å¯åЍ/继续取数定时器(待装列表、统计、当前叫号患者、停止候诊开关都会刷新)
    if (!timerRunFlag && roomBedVO.value.roomId != null) {
      timerRunFlag = true
      doctorTimer()
    } else if (roomBedVO.value.roomId != null) {
      await initLoad()
    }
    // â‘¢ åˆ·æ–°ã€Œå·²å®Œæˆåˆ—表」(面板内会按新工位的 checkTypes é‡æ–°è¿‡æ»¤ï¼‰
    devInstallPanelRef.value?.getList?.()
  }
)
onMounted(() => {
  timerRunFlag = false
  bedControlFlag.value = true
@@ -278,7 +315,7 @@
    å¸ƒå±€ä¼˜åŒ–:功能按钮固定在页面底部,不再用 position:absolute æµ®åœ¨å†…容上。
    è¯¦è§ components/DoctorPageLayout(与叫号检查/设备领用页共用同一布局)。
  -->
  <DoctorPageLayout>
  <DoctorPageLayout :bed-switch-enabled="false">
    <template #header>
      <TitlePanel :titleType=2 :room="roomBedVO" :patientStatistic="patientStat" />
    </template>
@@ -293,7 +330,8 @@
    />
    <template #aside>
      <QueuePanel :room="roomBedVO" :queue="patientList" @event_RecallFinish="onEventRecallFinish"/>
      <QueuePanel :room="roomBedVO" :queue="patientList" :fetch-info="fetchInfo"
                  @event_RecallFinish="onEventRecallFinish"/>
    </template>
src/views/ecg/doctor/Routine.vue
@@ -16,13 +16,19 @@
const roomStore = useRoomStore()
const roomBedVO: RoomBedVO = {
/**
 * å½“前工位信息。
 *
 * ã€æœ¬æ¬¡éœ€æ±‚】必须是**响应式**对象:切换工位时页面会把它改成新工位的值,
 * è‹¥æ˜¯æ™®é€šå¯¹è±¡ï¼Œå·¥ä½åç§°/检查项目等显示不会重绘(看起来像"没换过来")。
 */
const roomBedVO = reactive<RoomBedVO>({
  roomId: null,
  roomName: null,
  bedNo: null,
  checkTypes: null,
  opType: null
}
})
const setappointmentId = ref<string>('');
let onStagePatient: QueueVO = {
  id: 0,
@@ -55,6 +61,10 @@
const state = ref<boolean>()
const patientList = ref<QueueVO[]>([])
/** ã€æœ¬è¯Šå®¤æœªåˆ†é…ã€‘本工位检查类型下、还没分配工位的"排队中"患者(可手工拉入) */
const roomWaitingList = ref<QueueVO[]>([])
/** ã€è‡ªåŠ©è¯Šæ–­ã€‘æœ€è¿‘ä¸€æ¬¡å–æ•°çš„è¯´æ˜Žï¼ˆç©ºåˆ—è¡¨æ—¶æ˜¾ç¤ºï¼‰ */
const fetchInfo = ref('')
let timerRunFlag: boolean = false
const bedControlFlag = ref<boolean>(true)
@@ -145,6 +155,8 @@
      checkType: last.bookCheckType
    } as any)
    ElMessage.success(msg || '已撤销')
    // ã€æ’¤é”€æ ‡è¯†ã€‘撤销后立刻刷新「已完成记录」列表,让这条记录显示成「已撤销」
    routimePanelRef.value?.getList?.()
    await initLoad()
  } finally {
    undoFlag.value = false
@@ -220,6 +232,75 @@
  }
}
/**
 * ã€æœ¬è¯Šå®¤æœªåˆ†é…ã€‘加载"本工位检查类型下、还没分配工位的排队中患者"。
 *
 * å€™è¯Šåˆ—表按 room_id + bed_no æŸ¥ï¼Œåªå«å·²åˆ†æµåˆ°æœ¬å·¥ä½çš„人;而"排队中(10)"的人 bed_no ä¸ºç©º
 * ï¼ˆå¤§å±ä¼šæ˜¾ç¤ºã€å·¥ä½œå°åŽŸå…ˆçœ‹ä¸åˆ°ï¼‰â€”â€”çŽ°åœºåé¦ˆçš„"患者列表没取到患者信息"就是这个。
 * è¿™é‡ŒæŠŠä»–们单独取回来展示,工位主人可一键「拉入」。
 *
 * å¤±è´¥ä¸å½±å“ä¸»æµç¨‹ï¼ˆåªæ‰“日志):这个区块只提供便利,不是必需数据。
 */
const loadRoomWaitingList = async () => {
  if (roomBedVO.roomId == null) return
  try {
    roomWaitingList.value = (await DoctorApi.getRoomWaitingList(roomBedVO)) || []
  } catch (e) {
    console.warn('[loadRoomWaitingList] å–本诊室未分配患者失败', e)
  }
}
/** ã€Œæ‹‰å…¥ã€ï¼šæŠŠæŽ’队中的患者手工分到本工位(后端校验:必须是本工位主人 + æ£€æŸ¥é¡¹ç›®åŒ¹é…ï¼‰ */
const onEventPullWaiting = async (item: any) => {
  try {
    await ElMessageBox.confirm(
      `把「${item.patName || ''}」拉入本工位(${roomBedVO.roomName || ''} ${roomBedVO.bedNo || ''})?`,
      '拉入排队患者',
      { confirmButtonText: '拉入', cancelButtonText: '取消', type: 'warning' }
    )
  } catch {
    return
  }
  try {
    const data = await DoctorApi.pullWaitingPatient({
      roomId: roomBedVO.roomId,
      bedNo: roomBedVO.bedNo,
      id: item.id
    })
    if (data !== 'success' && data !== undefined && data !== null && data !== '') {
      // åŽç«¯é”™è¯¯ç ä¼šæŠ›å‡ºå¼‚常(如"已被其他工位拉入"),走到这里的正常返回是 success
      console.warn('[onEventPullWaiting] è¿”回:', data)
    }
    ElMessage.success(`已把「${item.patName || ''}」拉入本工位`)
  } catch (e) {
    // æŽ¥å£å¼‚常(含错误码提示)由全局拦截器提示,这里只需刷新列表
    console.warn('[onEventPullWaiting] æ‹‰å…¥å¤±è´¥', e)
  }
  await initLoad()
}
/**
 * ã€Œç«‹å³è¡¥äººã€ï¼šæ‰‹åŠ¨è§¦å‘ä¸€æ¬¡æœ¬å·¥ä½çš„è‡ªåŠ¨åˆ†æµï¼Œå¹¶æŠŠåŽç«¯è¿”å›žçš„"为什么没补/补了几人"展示出来。
 *
 * ç³»ç»Ÿåªåœ¨ã€å¼€è¯Šã€‘、本工位未开【停止候诊】、候诊人数未达上限时才会自动补人;
 * è¿™ä¸‰é“门闸原先都是静默的,现场只能看到"排队的人就是不进候诊区"。
 */
const onEventHurryUp = async () => {
  try {
    const data: any = await DoctorApi.hurryUpNow(roomBedVO)
    const lines = Object.entries(data || {})
      .map(([k, v]) => `${k}:${v === null || v === undefined || v === '' ? '(空)' : v}`)
      .join('\n')
    await ElMessageBox.alert(lines, '立即补人 Â· è¯Šæ–­ç»“æžœ', {
      confirmButtonText: '知道了',
      type: data?.本次补入 > 0 ? 'success' : 'warning'
    })
  } catch (e: any) {
    ElMessage({ message: '立即补人失败:' + (e?.message || e), type: 'warning', duration: 4000 })
  }
  await initLoad()
}
const initLoad = async () => {
  if (roomBedVO.roomId == null) {
    ElNotification.error({
@@ -230,12 +311,17 @@
    return
  }
  patientList.value = await DoctorApi.getToBeCheckedPatientList(roomBedVO)
  patientList.value = (await DoctorApi.getToBeCheckedPatientList(roomBedVO)) || []
  // ã€è‡ªåŠ©è¯Šæ–­ã€‘è®°å½•æœ¬æ¬¡å–æ•°æ—¶é—´ä¸Žæ¡æ•°ï¼ˆç©ºåˆ—è¡¨æ—¶æ˜¾ç¤ºï¼Œä¾¿äºŽåŒºåˆ†"轮询没跑/后端没数据")
  fetchInfo.value = `最近取数 ${new Date().toLocaleTimeString()} Â· è¿”回 ${patientList.value.length} æ¡`
  // ã€æœ¬è¯Šå®¤æœªåˆ†é…ã€‘本工位检查类型下、还没分配工位的"排队中"患者(可手工拉入)
  loadRoomWaitingList()
  getOnstagePatient()
  patientStat.value = await DoctorApi.getPatientStatistic(roomBedVO)
  const queueVO2 = await DoctorApi.bedDoctorGet(roomBedVO)
  if (queueVO2.status === 20) state.value = true
  else if (queueVO2.status === 30) state.value = false
  // ã€åŠ å›ºã€‘æŽ¥å£å¯èƒ½è¿”å›ž null,原来直接取 .status ä¼šæŠ›å¼‚常、中断本轮后续步骤
  if (queueVO2 && queueVO2.status === 20) state.value = true
  else if (queueVO2 && queueVO2.status === 30) state.value = false
  // éœ€æ±‚3:同步"停止候诊"开关状态(其他医生/其他终端改动后本端也能看到)
  await loadStopReady()
}
@@ -257,9 +343,43 @@
const doctorTimer = () => {
  console.log('doctorTimer ...')
  initLoad()
  // ã€åŠ å›ºã€‘å•è½®å–æ•°å¤±è´¥ä¸èƒ½å˜æˆ unhandled rejection:
  // åŽŸæ¥ç›´æŽ¥ `initLoad()`(不 await、不 catch),一旦某轮接口异常,错误被吞掉、
  // é¡µé¢åœåœ¨æ—§æ•°æ®ï¼ˆçŽ°åœºè¡¨çŽ°å°±æ˜¯"患者列表一直是空的",而库里有患者)。
  initLoad().catch((e) => console.error('[doctorTimer] æœ¬è½®å–数失败(已忽略,下一轮继续)', e))
  if (timerRunFlag) setTimeout(doctorTimer, 5000) // five seconds
}
/**
 * ã€ä¸»/围观】跟随 store åˆ‡æ¢"正在查看的工位"。
 *
 * roomBedVO åŽŸæ¥åªåœ¨ onMounted æ—¶ä»Ž store æ‹·ä¸€æ¬¡ â†’ ç”¨é¡¶éƒ¨ã€Œå·¥ä½ã€ä¸‹æ‹‰åˆ‡åˆ°åˆ«çš„人工位(围观)后,
 * æœ¬é¡µæ•°æ®ä¸ä¼šè·Ÿç€å˜ï¼Œå¿…须刷新页面才生效。这里 watch ä¸€ä¸‹å³å¯å³æ—¶ç”Ÿæ•ˆ
 * ï¼ˆå–数定时器会立刻拉到新工位的数据)。
 */
watch(
  () => [roomStore.room?.roomId, roomStore.room?.bedNo],
  async () => {
    if (!roomStore.room) return
    roomBedVO.roomId = roomStore.room.roomId
    roomBedVO.roomName = roomStore.room.roomName
    roomBedVO.bedNo = roomStore.room.bedNo
    roomBedVO.checkTypes = roomStore.room.checkTypes
    roomBedVO.opType = roomStore.room.opType
    // ã€æœ¬æ¬¡éœ€æ±‚】换工位后**整页数据都换成新工位的**:
    // â‘  å…ˆæ¸…空左侧"患者信息"(避免还显示上一个工位的患者)
    routimePanelRef.value?.setPatient?.(undefined)
    // â‘¡ å¯åЍ/继续取数定时器(候诊列表、统计、当前叫号患者、停止候诊开关都会刷新)
    if (!timerRunFlag && roomBedVO.roomId != null) {
      timerRunFlag = true
      doctorTimer()
    } else if (roomBedVO.roomId != null) {
      await initLoad()
    }
    // â‘¢ åˆ·æ–°ã€Œå·²å®Œæˆåˆ—表」(面板内会按新工位的 checkTypes é‡æ–°è¿‡æ»¤ï¼‰
    routimePanelRef.value?.getList?.()
  }
)
/** åˆå§‹åŒ– **/
onMounted(() => {
@@ -352,7 +472,7 @@
    Â· ã€Œå«å·/过号/重叫/暂停/撤销叫号」等按钮进 #footer,固定在底部,永不被记录列表遮挡
    è¯¦è§ components/DoctorPageLayout。
  -->
  <DoctorPageLayout>
  <DoctorPageLayout :bed-switch-op-types="[0]">
    <template #header>
      <TitlePanel :titleType="0" :room="roomBedVO" :patientStatistic="patientStat" />
    </template>
@@ -368,7 +488,11 @@
    />
    <template #aside>
      <QueuePanel :room="roomBedVO" :queue="patientList" @event_RecallFinish="onEventRecallFinish" />
      <QueuePanel :room="roomBedVO" :queue="patientList" :waiting-list="roomWaitingList"
                  :fetch-info="fetchInfo"
                  @event_RecallFinish="onEventRecallFinish"
                  @event_PullWaiting="onEventPullWaiting"
                  @event_HurryUp="onEventHurryUp" />
    </template>
    <!-- åº•部控件:自动 + åŠŸèƒ½æŒ‰é’® + åœæ­¢å€™è¯Šï¼Œæ•´ç»„靠右 -->
src/views/ecg/doctor/components/BedSwitchSelect.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,257 @@
<!--
  å·¥ä½œå°é¡¶éƒ¨ã€Œå·¥ä½åˆ‡æ¢ / å›´è§‚」下拉(主/围观模型的入口)
  ä¸ºä»€ä¹ˆæ”¾åœ¨è¿™é‡Œï¼šåŒ»ç”Ÿåœ¨å·¥ä½œå°é‡Œå¹²æ´»æ—¶ï¼Œæœ€è‡ªç„¶çš„诉求是"顺便看一眼别的诊室在做什么"。
  ä¸‹æ‹‰åˆ—出所有**已开放**工位,并标出相对我的身份:
    Â· æˆ‘的工位      â†’ åˆ‡å›žè‡ªå·±ï¼ˆå¯æ“ä½œï¼‰
    Â· ç©ºé—²          â†’ è¯¢é—®åŽå…¥åº§ï¼ˆæˆä¸ºä¸»äººï¼›æœåŠ¡ç«¯ä¼šè‡ªåŠ¨é‡Šæ”¾æˆ‘åŽŸæ¥çš„å·¥ä½ï¼‰
    Â· å·²å…¥åº§ï¼šå¼ ä¸‰   â†’ **围观**(只读,不能改别人的数据)
  å›´è§‚只改"我在看哪个工位"(roomStore.room),**不动我入座的工位**(roomStore.currentSeat);
  ä¸¤è€…不同时 `getCanOperate` ä¸º false â†’ é¡µé¢åªè¯»ã€‚服务端另有硬校验(doc_id å¿…须是我)。
-->
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { RoomApi, RoomVO } from '@/api/ecg/room'
import { useRoomStore } from '@/store/modules/room'
defineOptions({ name: 'BedSwitchSelect' })
const router = useRouter()
const roomStore = useRoomStore()
/**
 * ã€æœ¬æ¬¡éœ€æ±‚】工位切换的范围控制:
 *   Â· å«å·æ£€æŸ¥é¡µï¼š`opTypes=[0]` â†’ ä¸‹æ‹‰åªåˆ—**检查类**工位;
 *   Â· è®¾å¤‡é¢†ç”¨ / è£…机页:`enabled=false` â†’ å·¥ä½å›ºå®šï¼ˆ= å½“前查看的工位),不提供切换。
 * ä¸‰ä¸ªé¡µé¢é€šè¿‡ DoctorPageLayout é€ä¼ ã€‚
 */
const props = withDefaults(
  defineProps<{
    /** æ˜¯å¦å…è®¸åˆ‡æ¢å·¥ä½ï¼ˆfalse æ—¶åªæ˜¾ç¤º"固定工位"标签) */
    enabled?: boolean
    /** å¯åˆ‡æ¢å·¥ä½çš„作业类型过滤(null/空 = ä¸è¿‡æ»¤ï¼‰ï¼›0检查 1领用 2装机 */
    opTypes?: number[] | null
  }>(),
  { enabled: true, opTypes: null }
)
const beds = ref<RoomVO[]>([])
const loading = ref(false)
let timer: number | undefined
const canOperate = computed(() => roomStore.getCanOperate)
const dropdownText = computed(() => {
  if (!roomStore.getIsSetRoom || !roomStore.getViewText) return '选择工位'
  return canOperate.value
    ? roomStore.getViewText
    : `围观 ${roomStore.getViewText}`
})
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)
/**
 * æŒ‰ `opTypes` è¿‡æ»¤å·¥ä½ï¼ˆnull/空 = ä¸è¿‡æ»¤ï¼‰ã€‚
 *
 * ã€æœ¬æ¬¡éœ€æ±‚】叫号检查页只允许切到**检查类**工位(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)))
}
/** å›ºå®šå·¥ä½çš„只读文案(`enabled=false` æ—¶æ˜¾ç¤ºï¼‰ */
const fixedText = computed(() => {
  const text = roomStore.getViewText
  if (!text) return '未选择工位'
  return canOperate.value ? text : `围观 ${text}`
})
/** æ¯ä¸ªå·¥ä½çš„身份文案 */
const bedLabel = (bed: RoomVO) => {
  const name = `${bed.roomName || ''} Â· ${bed.bedNo || ''}`
  if (isMySeat(bed)) return `${name}(我的工位)`
  if (bed.docId == null) return `${name}(空闲,可入座)`
  return `${name}(已入座:${bed.docName || '其他医生'},只读围观)`
}
const loadBeds = async () => {
  try {
    loading.value = true
    const map: any = await RoomApi.getOpeningBedMap()
    const all: RoomVO[] = []
    for (const key in map || {}) {
      const arr = map[key]
      if (Array.isArray(arr)) all.push(...arr)
    }
    beds.value = all
    // ã€æœ¬æ¬¡éœ€æ±‚】按作业类型过滤"可切换的工位"(叫号检查页只列检查类工位)
    const visible = filterBeds(all)
    beds.value = visible
    // æ­£åœ¨å›´è§‚时顺便刷新"主人是否已离开"(只提示,不自动接管)
    const cur = roomStore.getRoom
    if (cur?.roomId != null && cur.bedNo) {
      // æ³¨æ„ï¼šç”¨**全量** all åŽ»æ‰¾å½“å‰å·¥ä½ï¼ˆå®ƒå¯èƒ½ä¸åœ¨è¿‡æ»¤åŽçš„åˆ—è¡¨é‡Œï¼‰
      const latest = all.find((b) => isSameBed(cur, b))
      if (latest) {
        roomStore.refreshViewTargetState(latest)
      } else {
        // æ­£åœ¨çœ‹çš„工位已不在"已开放工位"列表里(被关闭/删除)→ è‡ªåŠ¨å›žåˆ°æˆ‘è‡ªå·±çš„å·¥ä½ï¼Œ
        // å¦åˆ™é¡µé¢ä¼šä¸€ç›´æŸ¥ä¸€ä¸ªä¸å­˜åœ¨å·¥ä½çš„æ•°æ®ï¼ˆè¡¨çŽ°å°±æ˜¯"患者列表一直是空的")。
        const seat = roomStore.getCurrentSeat
        if (seat?.seated && !isSameBed(cur, seat)) {
          console.warn('[BedSwitchSelect] æ­£åœ¨æŸ¥çœ‹çš„工位已不可用,切回我的工位', cur)
          await roomStore.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)
          ElMessage({
            message: `原查看的工位已不可用,已切回我的工位:${seat.roomName || ''} ${seat.bedNo || ''}`,
            type: 'warning',
            duration: 5000
          })
        }
      }
    }
  } catch (e) {
    console.warn('[BedSwitchSelect] åŠ è½½å·¥ä½åˆ—è¡¨å¤±è´¥', e)
  } finally {
    loading.value = false
  }
}
/** é€‰ä¸­ä¸€ä¸ªå·¥ä½ */
const onPick = async (bed: RoomVO) => {
  // â‘  æˆ‘的工位 â†’ åˆ‡å›žè‡ªå·±
  if (isMySeat(bed)) {
    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
    }
    const ok = await roomStore.seatRoom(bed)
    if (!ok) {
      ElMessage({ message: '入座失败:该工位可能已被他人占用,或状态不允许(已关闭)', type: 'warning', duration: 4000 })
    } else {
      await roomStore.fetchMySeat()
      ElMessage.success(`已入座:${bed.roomName || ''} ${bed.bedNo || ''}`)
    }
    return
  }
  // â‘¢ å·²è¢«åˆ«äººå ç”¨ â†’ å›´è§‚(只读)
  await roomStore.setViewTargetAction(bed, bed.docName)
  ElMessage({
    message: `正在围观「${bed.roomName || ''} / ${bed.bedNo || ''}」(主人 ${bed.docName || '其他医生'}),只读查看`,
    type: 'info',
    duration: 4000
  })
}
onMounted(async () => {
  await loadBeds()
  // 30s è½®è¯¢ï¼šåˆ·æ–°å ç”¨çŠ¶æ€ï¼Œå¹¶å‘çŽ°"主人已离开"(只提示,不自动接管)
  timer = window.setInterval(loadBeds, 30000)
})
onBeforeUnmount(() => {
  if (timer) window.clearInterval(timer)
})
</script>
<template>
  <div class="bed-switch">
    <!--
      ã€æœ¬æ¬¡éœ€æ±‚】领用 / è£…机页不提供切换:工位固定为"当前查看的工位",
      è¿™é‡Œåªæ˜¾ç¤ºä¸€ä¸ªåªè¯»æ ‡ç­¾ï¼›è¦æ¢å·¥ä½è¯·åˆ°é¡¶æ å·¥ä½æ ‡ç­¾ â†’ é€‰å·¥ä½é¡µã€‚
    -->
    <el-tooltip v-if="!enabled" content="本工作台工位固定;如需查看其它工位,请点顶栏工位标签进入「选工位页」" placement="bottom">
      <el-tag :type="canOperate ? 'primary' : 'warning'" effect="plain" class="bed-switch__fixed">
        <Icon :icon="canOperate ? 'ep:office-building' : 'ep:view'" class="mr-4px" />
        å·¥ä½ï¼š{{ fixedText }}
        <span class="bed-switch__fixed-tip">(固定)</span>
      </el-tag>
    </el-tooltip>
    <el-dropdown v-else trigger="click" :disabled="loading" @command="onPick">
      <el-button size="small" :type="canOperate ? 'primary' : 'warning'" plain>
        <Icon :icon="canOperate ? 'ep:office-building' : 'ep:view'" class="mr-4px" />
        {{ dropdownText }}
        <Icon icon="ep:arrow-down" class="ml-4px" />
      </el-button>
      <template #dropdown>
        <el-dropdown-menu>
          <el-dropdown-item
            v-for="bed in beds"
            :key="`${bed.roomId}-${bed.bedNo}`"
            :command="bed"
            :class="{ 'is-current': isViewing(bed) }"
          >
            {{ bedLabel(bed) }}
          </el-dropdown-item>
          <el-dropdown-item v-if="beds.length === 0" disabled>暂无已开放工位</el-dropdown-item>
          <el-dropdown-item divided @click="router.push({ path: '/roomselect' })">
            åŽ»ã€Œé€‰å·¥ä½é¡µã€â€¦
          </el-dropdown-item>
        </el-dropdown-menu>
      </template>
    </el-dropdown>
    <!-- ä¸»äººå·²ç¦»å¼€ï¼šåªæç¤ºï¼Œä¸è‡ªåŠ¨æŽ¥ç®¡ï¼ˆæŒ‰ç¡®è®¤å£å¾„ï¼‰ -->
    <el-tag v-if="!canOperate && roomStore.viewTargetVacant" type="info" size="small" class="ml-8px">
      è¯¥å·¥ä½çŽ°æ— äººæ“ä½œï¼Œå¦‚éœ€æ“ä½œè¯·åˆ°é€‰å·¥ä½é¡µå…¥åº§
    </el-tag>
  </div>
</template>
<style scoped lang="scss">
.bed-switch {
  display: flex;
  align-items: center;
  flex: none;
}
/* ã€æœ¬æ¬¡éœ€æ±‚】固定工位的只读标签(领用 / è£…机页) */
.bed-switch__fixed {
  font-weight: 600;
}
.bed-switch__fixed-tip {
  margin-left: 2px;
  font-weight: 400;
  color: var(--el-text-color-secondary);
}
:deep(.el-dropdown-menu__item.is-current) {
  color: var(--el-color-primary);
  font-weight: 600;
}
</style>
src/views/ecg/doctor/components/DevInstallPanel.vue
@@ -43,6 +43,11 @@
  </div>
  <!-- åŒ»ç”Ÿè£…机列表 -->
  <!-- ã€æœ¬æ¬¡éœ€æ±‚】操作功能区与已完成记录之间的标题栏 -->
  <div class="records-title">
    <span class="records-title__bar"></span>
    <span class="records-title__text">已完成列表</span>
  </div>
  <div class="records-host">
    <ContentWrap class="records-wrap">
    <el-table
@@ -69,7 +74,9 @@
      </el-table-column>
      <el-table-column label="装机情况" align="center" prop="lost">
        <template #default="scope">
          <span v-if="scope.row.jobType === 10">"已装机"</span>
          <!-- ã€æœ¬éœ€æ±‚】完成被撤销过:该条记录显示「已撤销」;再次完成时后端 undone è‡ªåŠ¨å˜å›ž false -->
          <el-tag v-if="scope.row.undone" type="danger" size="small">已撤销</el-tag>
          <span v-else-if="scope.row.jobType === 10">"已装机"</span>
          <span v-if="scope.row.jobType === 35">"已放弃"</span>
        </template>
      </el-table-column>
@@ -213,6 +220,10 @@
  formData.value.doctor = queueVO?.doctor
  formData.value.doctorTime = queueVO?.doctorTime
  formData.value.patSrc = queueVO?.patSrc
  // ã€æœ¬æ¬¡ä¿®å¤ã€‘条码/打印小票依赖 patDetails(含 source/appointmentId),
  // é˜Ÿåˆ—行本就带着它;原先只在 getFreeRentInfo() æŸ¥åˆ°"待检查单"时才赋值,
  // å¯¼è‡´ç»å¤§å¤šæ•°æƒ…况下 patDetails ä¸º undefined â†’ æ¡ç ä¸æ˜¾ç¤ºã€‚
  formData.value.patDetails = queueVO?.patDetails
  formData.value.patMobile = queueVO?.patMobile
  formData.value.patPhone = queueVO?.patPhone
  formData.value.patWardDesc = queueVO?.patWardDesc
@@ -225,7 +236,7 @@
  getReadyRentInfo(formData.value)    // èŽ·å–é¢†ç”¨ä¿¡æ¯
  copyBarcode()
}
defineExpose({ setPatient }) // æä¾› setPatient æ–¹æ³•,用于设置患者
defineExpose({ setPatient, getList: () => getList() }) // æä¾› setPatient æ–¹æ³•,用于设置患者
/** æäº¤è¡¨å• */
@@ -467,9 +478,17 @@
  if (!formData.value.checkType)
    return ""
  const displayBarcode: number[] = checkTypeStore.getCheckTypeDispBarCode(formData.value.checkType!)
  if (formData.value.patDetails == null || formData.value.patDetails.source == null ||
    !displayBarcode.includes(formData.value.patDetails.source))
  // ã€æœ¬æ¬¡ä¿®å¤ã€‘「患者条码显示」未配置(null/空)→ ä¸æ˜¾ç¤ºï¼ˆæŒ‰çŽ°åœºç¡®è®¤å£å¾„ï¼‰
  const displayBarcode: number[] | undefined = checkTypeStore.getCheckTypeDispBarCode(formData.value.checkType!)
  if (displayBarcode == null || displayBarcode.length === 0)
    return ""
  // ã€æœ¬æ¬¡ä¿®å¤ã€‘来源优先取 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 ""
  return getBarcode()
@@ -479,13 +498,14 @@
  if (!formData.value.checkType)
    return ""
  const patSource = formData.value.patDetails?.source
  const patSource = formData.value.patDetails?.source ?? formData.value.patSrc
  if (patSource == null)
    return ""
  if (1 === patSource || 2 === patSource)
  // é—¨è¯Š/急诊 â†’ ç”³è¯·å•号;住院/体检 â†’ å°±è¯Šæµæ°´å·
  if (1 === Number(patSource) || 2 === Number(patSource))
    return formData.value.applyNo ?? ""
  else if (3 === patSource || 4 === patSource)
  else if (3 === Number(patSource) || 4 === Number(patSource))
    return formData.value.episodeId ?? ""
  return ""
@@ -505,6 +525,28 @@
</script>
<style scoped lang="scss">
/* ã€æœ¬æ¬¡éœ€æ±‚】「已完成列表」标题栏:左侧主色竖线 + åŠ ç²—å°æ ‡é¢˜ï¼Œç´§å‡‘é«˜åº¦ä¸æŒ¤åŽ‹è¡¨æ ¼ */
.records-title {
  display: flex;
  align-items: center;
  flex: none;
  gap: 6px;
  padding: 2px 0 6px;
  &__bar {
    width: 3px;
    height: 14px;
    background-color: var(--el-color-primary);
    border-radius: 2px;
  }
  &__text {
    font-size: 14px;
    font-weight: 600;
    color: var(--el-text-color-primary);
  }
}
/* ==========================================================================
   éœ€æ±‚:表单内容挤占了下方记录表格的空间 â€”— åŽ‹ç¼©è¡Œé—´è·ã€‚
   Element Plus é»˜è®¤ `.el-form-item { margin-bottom: 18px }`;
@@ -556,15 +598,21 @@
.patient-actions {
  display: flex;
  flex: none;
  flex-wrap: nowrap;
  /* æŒ‰é’®å¤šæ—¶å…è®¸æ¢è¡Œï¼Œé¿å…æŒ¤åœ¨ä¸€è¡Œï¼ˆæ£€æŸ¥é¡µæœ€å¤š 6 ä¸ªæŒ‰é’®ï¼‰ */
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
  /* ä¸Šä¸‹ç•™ç™½ + åˆ†éš”线,形成独立功能块 */
  padding: 6px 4px;
  margin-bottom: 6px;
  background-color: var(--el-bg-color);
  border-top: 1px solid var(--el-border-color-lighter);
  border-bottom: 1px solid var(--el-border-color-lighter);
  /*
    ã€æœ¬æ¬¡éœ€æ±‚】操作功能区做一点视觉区分,方便医生一眼定位:
    æžæµ…的主色底 + ç»†è¾¹æ¡† + å·¦ä¾§ 3px ä¸»è‰²ç«–条(与「▎已完成列表」标题条同款语言)。
    åªåš"稍有区分",不改按钮本身的尺寸/颜色/顺序。
  */
  padding: 8px 10px;
  margin: 2px 0 10px;
  background-color: var(--el-color-primary-light-9);
  border: 1px solid var(--el-color-primary-light-7);
  border-left: 3px solid var(--el-color-primary);
  border-radius: 4px;
  /* Element Plus ç»™ç›¸é‚» el-button é»˜è®¤åŠ äº† margin-left:12px,会与 gap å åŠ ï¼Œæ¸…æŽ‰ */
  :deep(.el-button + .el-button) {
src/views/ecg/doctor/components/DevReadyPanel.vue
@@ -74,6 +74,11 @@
    </div>
  <!-- åŒ»ç”Ÿè£…机列表 -->
  <!-- ã€æœ¬æ¬¡éœ€æ±‚】操作功能区与已完成记录之间的标题栏 -->
  <div class="records-title">
    <span class="records-title__bar"></span>
    <span class="records-title__text">已完成列表</span>
  </div>
  <div class="records-host">
    <ContentWrap class="records-wrap">
    <el-table
@@ -105,7 +110,9 @@
      </el-table-column>
      <el-table-column label="领用情况" align="center" prop="cancelled" >
        <template #default="scope">
          <span v-if="scope.row.jobType === 5">"已领用"</span>
          <!-- ã€æœ¬éœ€æ±‚】完成被撤销过:该条记录显示「已撤销」;再次完成时后端 undone è‡ªåŠ¨å˜å›ž false -->
          <el-tag v-if="scope.row.undone" type="danger" size="small">已撤销</el-tag>
          <span v-else-if="scope.row.jobType === 5">"已领用"</span>
          <span v-if="scope.row.jobType === 30">"已放弃"</span>
        </template>
      </el-table-column>
@@ -416,6 +423,10 @@
  formData.value.doctor = queueVO?.doctor
  formData.value.doctorTime = queueVO?.doctorTime
  formData.value.patSrc = queueVO?.patSrc
  // ã€æœ¬æ¬¡ä¿®å¤ã€‘条码/打印小票依赖 patDetails(含 source/appointmentId),
  // é˜Ÿåˆ—行本就带着它;原先只在 getFreeRentInfo() æŸ¥åˆ°"待检查单"时才赋值,
  // å¯¼è‡´ç»å¤§å¤šæ•°æƒ…况下 patDetails ä¸º undefined â†’ æ¡ç ä¸æ˜¾ç¤ºã€‚
  formData.value.patDetails = queueVO?.patDetails
  formData.value.patMobile = queueVO?.patMobile
  formData.value.patPhone = queueVO?.patPhone
  formData.value.patWardDesc = queueVO?.patWardDesc
@@ -428,7 +439,7 @@
  getFreeRentInfo(formData.value)  // å¾…检查的信息  @ dev_rent è¡¨
  copyBarcode()
}
defineExpose({ setPatient }) // æä¾› setPatient æ–¹æ³•,用于设置患者
defineExpose({ setPatient, getList: () => getList() }) // æä¾› setPatient æ–¹æ³•,用于设置患者
const loading = ref(true) // åˆ—表的加载中
@@ -635,14 +646,17 @@
  if (formData.value.checkType == null)
    return ""
  if (formData.value.patDetails?.source == null)
    return ""
  const displayBarcode: number[] = checkTypeStore.getCheckTypeDispBarCode(formData.value.checkType)
  // ã€æœ¬æ¬¡ä¿®å¤ã€‘「患者条码显示」未配置(null/空)→ ä¸æ˜¾ç¤ºï¼ˆæŒ‰çŽ°åœºç¡®è®¤å£å¾„ï¼‰
  const displayBarcode: number[] | undefined = checkTypeStore.getCheckTypeDispBarCode(formData.value.checkType)
  if (displayBarcode == null || displayBarcode.length === 0)
    return ""
  if (!displayBarcode.includes( formData.value.patDetails.source ))
  // ã€æœ¬æ¬¡ä¿®å¤ã€‘来源优先取 patDetails.source,缺失时回退到平铺的 patSrc
  const patSource = formData.value.patDetails?.source ?? formData.value.patSrc
  if (patSource == null)
    return ""
  if (!displayBarcode.map(Number).includes(Number(patSource)))
    return ""
  return getBarcode()
@@ -652,13 +666,14 @@
  if (formData.value.checkType == null)
    return ""
  const patSource = formData.value.patDetails?.source
  const patSource = formData.value.patDetails?.source ?? formData.value.patSrc
  if (patSource == null)
    return ""
  if (1 === patSource || 2 === patSource)
  // é—¨è¯Š/急诊 â†’ ç”³è¯·å•号;住院/体检 â†’ å°±è¯Šæµæ°´å·
  if (1 === Number(patSource) || 2 === Number(patSource))
    return formData.value.applyNo??""
  else if (3 === patSource || 4 === patSource)
  else if (3 === Number(patSource) || 4 === Number(patSource))
    return formData.value.episodeId??""
  return ""
@@ -682,6 +697,28 @@
</script>
<style scoped lang="scss">
/* ã€æœ¬æ¬¡éœ€æ±‚】「已完成列表」标题栏:左侧主色竖线 + åŠ ç²—å°æ ‡é¢˜ï¼Œç´§å‡‘é«˜åº¦ä¸æŒ¤åŽ‹è¡¨æ ¼ */
.records-title {
  display: flex;
  align-items: center;
  flex: none;
  gap: 6px;
  padding: 2px 0 6px;
  &__bar {
    width: 3px;
    height: 14px;
    background-color: var(--el-color-primary);
    border-radius: 2px;
  }
  &__text {
    font-size: 14px;
    font-weight: 600;
    color: var(--el-text-color-primary);
  }
}
/* ==========================================================================
   éœ€æ±‚:表单内容挤占了下方记录表格的空间 â€”— åŽ‹ç¼©è¡Œé—´è·ã€‚
   Element Plus é»˜è®¤ `.el-form-item { margin-bottom: 18px }`;
@@ -733,15 +770,21 @@
.patient-actions {
  display: flex;
  flex: none;
  flex-wrap: nowrap;
  /* æŒ‰é’®å¤šæ—¶å…è®¸æ¢è¡Œï¼Œé¿å…æŒ¤åœ¨ä¸€è¡Œï¼ˆæ£€æŸ¥é¡µæœ€å¤š 6 ä¸ªæŒ‰é’®ï¼‰ */
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
  /* ä¸Šä¸‹ç•™ç™½ + åˆ†éš”线,形成独立功能块 */
  padding: 6px 4px;
  margin-bottom: 6px;
  background-color: var(--el-bg-color);
  border-top: 1px solid var(--el-border-color-lighter);
  border-bottom: 1px solid var(--el-border-color-lighter);
  /*
    ã€æœ¬æ¬¡éœ€æ±‚】操作功能区做一点视觉区分,方便医生一眼定位:
    æžæµ…的主色底 + ç»†è¾¹æ¡† + å·¦ä¾§ 3px ä¸»è‰²ç«–条(与「▎已完成列表」标题条同款语言)。
    åªåš"稍有区分",不改按钮本身的尺寸/颜色/顺序。
  */
  padding: 8px 10px;
  margin: 2px 0 10px;
  background-color: var(--el-color-primary-light-9);
  border: 1px solid var(--el-color-primary-light-7);
  border-left: 3px solid var(--el-color-primary);
  border-radius: 4px;
  /* Element Plus ç»™ç›¸é‚» el-button é»˜è®¤åŠ äº† margin-left:12px,会与 gap å åŠ ï¼Œæ¸…æŽ‰ */
  :deep(.el-button + .el-button) {
src/views/ecg/doctor/components/PatientInfoBlock.vue
@@ -31,7 +31,7 @@
  formatDateTime,
  resolveCardNo
} from '@/utils/patientInfo'
import { getCheckTypeName } from '@/utils/checkTypeFormatter'
import { getCheckTypeName, getCheckTypeSeqPrefix } from '@/utils/checkTypeFormatter'
const props = defineProps<{
  /** æ‚£è€…/排队/预约/租赁 æ•°æ®å¯¹è±¡ï¼ˆå½¢æ€å…¼å®¹ï¼Œè§æ–‡ä»¶å¤´è¯´æ˜Žï¼‰ */
@@ -77,6 +77,21 @@
const phone = computed(() =>
  pick(d.value.patMobile, d.value.patPhone, pd.value.mobile) ?? '-'
)
/**
 * æŽ’队号显示文案(本需求:号统一)。
 * Â· åªè®¤ `seq_num`(签到时分配、之后不变),**不再回落 bookSeqNum**(该号已停用);
 * Â· æ˜¾ç¤ºä¸ºã€Œæ£€æŸ¥é¡¹ç›®å‰ç¼€ + å·ã€â€”— å·æŒ‰æ£€æŸ¥é¡¹ç›®å„自编号,不同项目之间会出现相同数字,
 *   å¿…须靠 `check_type.seq_prefix` åŒºåˆ†ï¼ˆä¾‹ï¼šA12 / 12)。
 */
const seqNumText = computed(() => {
  const seq = pick(d.value.seqNum)
  if (seq === undefined) return '-'
  return `${getCheckTypeSeqPrefix(d.value[checkField.value])}${seq}`
})
/** æ˜¯å¦åŠ æ€¥ï¼ˆHIS åŠ æ€¥ jjbz=J æˆ–人工插队共用 jump_flag):医生侧要显眼提示 */
const isJump = computed(() => Number(d.value.jumpFlag) === 1)
const ward = computed(() => pick(d.value.patWardDesc, pd.value.wardDesc) ?? '-')
// æ³¨æ„ï¼šd.bedNo åœ¨ queue é‡Œæ˜¯ã€è¯Šç–—床/工位号】,真正的病床号是 patBedNo / patDetails.bedNo
const patBed = computed(() => pick(d.value.patBedNo, pd.value.bedNo) ?? '-')
@@ -100,8 +115,9 @@
      <span class="pi-item">患者编号:{{ d.patId || '-' }}</span>
      <span class="pi-item">就诊号:{{ d.episodeId || '-' }}</span>
      <span v-if="showSeqNum" class="pi-item">
        æŽ’队序号:{{ pick(d.seqNum, d.bookSeqNum) ?? '-' }}
        æŽ’队序号:{{ seqNumText }}
      </span>
      <span v-if="isJump" class="pi-item pi-warn">(加急)</span>
      <span v-if="d.status !== undefined && d.status !== null && statusDict" class="pi-item">
        çŠ¶æ€ï¼š<dict-tag :type="statusDict" :value="d.status" />
      </span>
@@ -121,7 +137,7 @@
      </el-col>
      <el-col v-if="showSeqNum" :span="colSpan">
        <el-form-item label="排队序号">
          {{ pick(d.seqNum, d.bookSeqNum) ?? '-' }}
          {{ seqNumText }}
        </el-form-item>
      </el-col><el-col :span="colSpan">
        <!-- æ ‡ç­¾ç”±ã€Œå°±è¯Šå¡å·ã€æ”¹ä¸ºã€Œé—¨è¯Šå·ã€ï¼ˆç»ç¡®è®¤ï¼Œè¯¦è§ã€Šéƒ¨ç½²æ¸…单》5.1):
src/views/ecg/doctor/components/QueuePanel.vue
@@ -2,15 +2,15 @@
import {defineComponent, PropType} from "vue";
import { QueueVO } from '@/api/ecg/queue'
import {DICT_TYPE} from "@/utils/dict";
import {PatientVO, DoctorApi, RoomBedVO} from "@/api/ecg/doctor";
import {getCheckTypeName, getCheckTypeSeqPrefix} from "../../../../utils/checkTypeFormatter";
import { PatientVO, DoctorApi, RoomBedVO } from "@/api/ecg/doctor";
import { getCheckTypeName, getCheckTypeSeqPrefix } from "../../../../utils/checkTypeFormatter";
import { useRoomStore } from '@/store/modules/room'
defineComponent({
  name: 'QueuePanel'
})
const emit = defineEmits(['event_RecallFinish']) // å®šä¹‰ success äº‹ä»¶ï¼Œç”¨äºŽæ“ä½œæˆåŠŸåŽçš„å›žè°ƒ
const emit = defineEmits(['event_RecallFinish', 'event_PullWaiting', 'event_HurryUp']) // å®šä¹‰ success äº‹ä»¶ï¼Œç”¨äºŽæ“ä½œæˆåŠŸåŽçš„å›žè°ƒ
const message = useMessage() // æ¶ˆæ¯å¼¹çª—
const  props = defineProps({
@@ -21,8 +21,38 @@
  room: {
    type: Object as PropType<RoomBedVO>,
    required: true
  },
  /**
   * ã€æœ¬è¯Šå®¤æœªåˆ†é…ã€‘本工位检查类型下、**还没分配工位**的"排队中"患者。
   * å€™è¯Šåˆ—表看不到他们(列表按 room_id+bed_no æŸ¥ï¼‰ï¼Œä½†å¤§å±èƒ½çœ‹åˆ° â€”— åŒ»ç”Ÿå¯åœ¨æ­¤æ‰‹å·¥ã€Œæ‹‰å…¥ã€ã€‚
   */
  waitingList: {
    type: Array as PropType<QueueVO[]>,
    default: () => []
  },
  /**
   * ã€è‡ªåŠ©è¯Šæ–­ã€‘"最近一次取数"的说明,例如 `最近取数 12:03:45 è¿”回 0 æ¡`。
   * ç©ºåˆ—表时显示:时间不刷新=轮询没在跑;返回 0 æ¡=后端确实没数据。
   */
  fetchInfo: {
    type: String,
    default: ''
  }
})
/** ã€ä¸»/围观】当前是否可操作:false è¡¨ç¤ºæ­£åœ¨å›´è§‚别人的工位(列表是别人的队列) */
const roomStore = useRoomStore()
const canOperate = computed(() => roomStore.getCanOperate)
/** æ‹‰å…¥æŽ’队中的患者(父页面负责调接口、刷新列表) */
const pullWaiting = (item: any) => {
  emit('event_PullWaiting', item)
}
/** ç«‹å³è¡¥äººï¼ˆçˆ¶é¡µé¢è´Ÿè´£è°ƒæŽ¥å£å¹¶æŠŠ"结论"展示出来) */
const hurryUp = () => {
  emit('event_HurryUp')
}
const recall = async (item) => {
  const patientVO: PatientVO = {
@@ -46,41 +76,99 @@
}
/**
 * éœ€æ±‚:右侧患者列表要把「就诊中 / å·²è¿‡å· / å€™è¯Šä¸­ã€ç”¨ä¸åŒé¢œè‰²åŒºåˆ†ï¼Œæ–¹ä¾¿åŒ»ç”Ÿè¯†åˆ«ã€‚
 * éœ€æ±‚:右侧患者列表要把「就诊中 / å‡†å¤‡ä¸­(原候诊中) / è¿‡å·ã€ç”¨ä¸åŒé¢œè‰²åŒºåˆ†ï¼Œæ–¹ä¾¿åŒ»ç”Ÿè¯†åˆ«ã€‚
 *
 * é¢œè‰²æ–¹æ¡ˆï¼ˆå·¦ä¾§è‰²æ¡ + æµ…色底 + å·¦ä¾§ç«–线):
 *   Â· å°±è¯Šä¸­(30)      â€”— è“è‰²ï¼ˆä¸»è‰²ï¼‰  ï¼šæ­£åœ¨çœ‹çš„æ‚£è€…,最醒目
 *   Â· å·²è¿‡å·(3/5)     â€”— çº¢è‰²          ï¼šéœ€è¦å…³æ³¨ï¼ˆå¯èƒ½å·²é”™è¿‡å«å·ï¼‰
 *   Â· å€™è¯Šä¸­(20/15)   â€”— ç»¿è‰²          ï¼šæ­£å¸¸ç­‰å¾…
 *   Â· å…¶ä»–            â€”— ç°è‰²          ï¼šæŽ’队中/亲和/已领用/安装中等
 * é¢œè‰²æ–¹æ¡ˆï¼ˆ**只用文字颜色**,不再有整行背景色,也没有行首色条):
 *   Â· å°±è¯Šä¸­(30)      â€”— è“è‰²ï¼ˆä¸»è‰²ï¼‰ + å§“名加粗 ï¼šæ­£åœ¨çœ‹çš„æ‚£è€…,最醒目
 *   Â· è¿‡å·(3/5)       â€”— çº¢è‰² + å§“名加粗        ï¼šéœ€è¦å…³æ³¨ï¼ˆå¯èƒ½å·²é”™è¿‡å«å·ï¼‰
 *   Â· å‡†å¤‡ä¸­(20/15)   â€”— ç»¿è‰²                   ï¼šæ­£å¸¸ç­‰å¾…(20=准备中,原"候诊中")
 *   Â· å…¶ä»–            â€”— ç°è‰²                   ï¼šæŽ’队中/亲和/已领用/安装中等
 *
 * ä¹‹æ‰€ä»¥ä¸åªé  dict-tag çš„æ–‡å­—:医生扫一眼列表时要能**快速定位**当前就诊者与过号者,
 * é¢œè‰²æ¯”文字更快。
 * é¢œè‰²æ¯”文字更快。改成"文字染色"是因为整行底色会让列表显得很花、也压掉了行与行的区分度。
 */
const rowClass = (item: any): string => {
  const st = Number(item?.status)
  if (st === 30) return 'row-onstage'            // å°±è¯Šä¸­
  if (st === 3 || st === 5) return 'row-passed'  // å·²è¿‡å·ï¼ˆå«"已过号-排队")
  if (st === 20 || st === 15) return 'row-ready' // å€™è¯Šä¸­ / å·²å¬å›ž
  if (st === 20 || st === 15) return 'row-ready' // å‡†å¤‡ä¸­(原候诊中) / å·²å¬å›ž
  return 'row-other'
}
</script>
<template>
<div class="queue-list">
  <!--
    ç©ºåˆ—表提示(本次新增):把"这份列表属于哪个工位、我是什么身份"直接写出来。
    çŽ°åœºå‡ºçŽ°è¿‡"患者列表是空的",光看空列表无法判断是排队真的没人、还是看错了工位
    ï¼ˆå›´è§‚别人的工位 / æ¢äº†å·¥ä½ä½†æ‚£è€…还在原工位)。
  -->
  <div v-if="!queue || queue.length === 0" class="queue-empty">
    <div class="queue-empty__title">暂无候诊患者</div>
    <div class="queue-empty__meta">
      å½“前列表:{{ room?.roomName || '—' }} Â· {{ room?.bedNo || '—' }}
      <template v-if="!canOperate">(<b>围观模式 Â· åªè¯»</b>,这是他人工位的队列)</template>
      <template v-else>(我的工位)</template>
    </div>
    <!-- è‡ªåŠ©è¯Šæ–­ï¼šä¸€çœ¼çœ‹å‡º"轮询没跑"(时间不动)还是"后端返回 0 æ¡" -->
    <div v-if="fetchInfo" class="queue-empty__meta">{{ fetchInfo }}</div>
    <!--
      å€™è¯ŠåŒºä¸ºç©ºæ—¶çš„自救入口:
      ç³»ç»Ÿ**只有在【开诊】状态、且本工位没开【停止候诊】、且候诊人数未达上限**时才会自动补人;
      ç‚¹è¿™é‡Œç«‹åˆ»è§¦å‘一次并直接告诉你被哪道门闸挡住了(后端返回"结论")。
    -->
    <div class="queue-empty__meta">
      ç³»ç»Ÿåªåœ¨ã€å¼€è¯Šã€‘、本工位未开【停止候诊】、且候诊人数未达上限时才会自动补人;
      <template v-if="canOperate">
        <el-button link type="primary" @click="hurryUp">立即补人(并查看原因)</el-button>
      </template>
    </div>
  </div>
  <div
    v-for="(item, index) in queue"
    :key="index"
    class="queue-row"
    :class="rowClass(item)"
  >
    <span class="col-seq"> {{item.seqNum}} </span>
    <span class="col-bookseq"> {{getCheckTypeSeqPrefix(item.bookCheckType)}}{{item.bookSeqNum}} </span>
    <!--
      æŽ’队号(本需求):统一显示「检查项目前缀 + seq_num」,不再显示 bookSeqNum。
      å‰ç¼€æ¥è‡ª check_type.seq_prefix â€”— å·çŽ°åœ¨æ˜¯**按检查项目各自编号**,不同检查项目之间
      ä¼šå‡ºçŽ°ç›¸åŒæ•°å­—ï¼Œå¿…é¡»é å‰ç¼€åŒºåˆ†ï¼ˆä¾‹ï¼šA12 / 12)。
      åŠ æ€¥(jump_flag=1)在号前面加红色「急」字,医生一眼可见。
    -->
    <span class="col-seq">
      <span v-if="Number(item.jumpFlag) === 1" class="jump-badge">急</span>
      {{ getCheckTypeSeqPrefix(item.bookCheckType) }}{{ item.seqNum }}
    </span>
    <span class="col-name"> &nbsp; {{item.patName}}{{item.passed === 0 ? "":"*"}} </span>
    <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="item.patGender" />
    {{getCheckTypeName(item.bookCheckType)}}
    <dict-tag :type="DICT_TYPE.ECG_QUEUE_STATUS" :value="item.status" />
    <el-button v-if="item.status === 5 || item.status === 7" size="small" @click="recall(item)"> å¬å›ž </el-button>
  </div>
  <!--
    ã€æœ¬è¯Šå®¤æœªåˆ†é…ã€‘本工位检查类型下、还没分配工位的"排队中"患者。
    ä¸ºä»€ä¹ˆéœ€è¦ï¼šå€™è¯Šåˆ—表按 room_id + bed_no æŸ¥ï¼Œåªå«"已分流到本工位"的人;
    è€Œ"排队中(10)"的人 bed_no æ˜¯ç©ºçš„ â€”— å¤§å±èƒ½çœ‹åˆ°ä»–们,工作台原先看不到,
    åŒ»ç”Ÿä¼šä»¥ä¸º"患者列表没取到数据"。这里列出来,工位主人可一键「拉入」。
  -->
  <div v-if="waitingList && waitingList.length > 0" class="room-waiting">
    <div class="room-waiting__title">
      æœ¬è¯Šå®¤æŽ’队中(未分配工位){{ waitingList.length }} äºº
    </div>
    <div v-for="(item, index) in waitingList" :key="'w' + index" class="room-waiting__row">
      <span class="col-seq">
        <span v-if="Number(item.jumpFlag) === 1" class="jump-badge">急</span>
        {{ getCheckTypeSeqPrefix(item.bookCheckType) }}{{ item.seqNum }}
      </span>
      <span class="col-name"> &nbsp; {{ item.patName }} </span>
      {{ getCheckTypeName(item.bookCheckType) }}
      <template v-if="canOperate">
        <el-button size="small" type="primary" plain @click="pullWaiting(item)"> æ‹‰å…¥ </el-button>
      </template>
      <span v-else class="room-waiting__readonly">(只读)</span>
    </div>
  </div>
</div>
</template>
@@ -94,61 +182,116 @@
  padding-left: 5px;
}
/* ç©ºåˆ—表提示(本次新增):把"这份列表属于哪个工位、我是什么身份"写清楚,
   é¿å…æŠŠ"看错了工位(围观/切换过)"当成"排队没人"。 */
.queue-empty {
  padding: 18px 8px;
  text-align: center;
  &__title {
    font-size: 15px;
    color: var(--el-text-color-secondary);
  }
  &__meta {
    margin-top: 6px;
    font-size: 13px;
    line-height: 1.6;
    color: var(--el-text-color-placeholder);
  }
}
/*
  ã€Œæœ¬è¯Šå®¤æŽ’队中(未分配工位)」分区(本次新增):
  ä¸Žä¸Šé¢çš„候诊列表用一条浅色分隔线 + å°æ ‡é¢˜åŒºåˆ†å¼€ï¼Œè§†è§‰ä¸Šæ˜Žç¡®"这些人还没进本工位"。
*/
.room-waiting {
  margin-top: 10px;
  padding-top: 8px;
  border-top: 1px dashed var(--el-border-color);
  &__title {
    margin-bottom: 4px;
    font-size: 13px;
    font-weight: 600;
    color: var(--el-color-primary);
  }
  &__row {
    padding: 3px 6px;
    margin-bottom: 3px;
    color: var(--el-text-color-regular);
  }
  &__readonly {
    font-size: 12px;
    color: var(--el-text-color-placeholder);
  }
}
/*
  è¡Œæ ·å¼ï¼ˆæœ¬æ¬¡éœ€æ±‚):
    Â· åŽ»æŽ‰æ•´è¡ŒèƒŒæ™¯è‰²ï¼Œä¹ŸåŽ»æŽ‰è¡Œé¦–é‚£æ¡ 4px è‰²æ¡ â€”— çŠ¶æ€åªé **文字颜色**区分;
    Â· å› æ­¤è¿™é‡Œåªä¿ç•™é—´è·/圆角,颜色规则全部下沉到下面的 .row-* é‡Œã€‚
*/
.queue-row {
  padding: 3px 6px;
  margin-bottom: 3px;
  border-left: 4px solid transparent;
  border-radius: 3px;
  line-height: 22px;
  background-color: transparent;
}
/* å°±è¯Šä¸­ï¼šè“è‰²ï¼ˆä¸»è‰²ï¼‰â€”— å½“前正在处理的患者 */
.row-onstage {
  background-color: var(--el-color-primary-light-8);
  border-left-color: var(--el-color-primary);
  color: var(--el-color-primary);
  .col-name {
    font-weight: 700;
    color: var(--el-color-primary);
  }
}
/* å·²è¿‡å·ï¼šçº¢è‰² â€”— éœ€æé†’医生注意 */
/* è¿‡å·ï¼šçº¢è‰² â€”— éœ€æé†’医生注意 */
.row-passed {
  background-color: var(--el-color-danger-light-8);
  border-left-color: var(--el-color-danger);
  color: var(--el-color-danger);
  .col-name {
    font-weight: 600;
    color: var(--el-color-danger);
  }
}
/* å€™è¯Šä¸­ / å·²å¬å›žï¼šç»¿è‰² â€”— æ­£å¸¸ç­‰å¾… */
/* å‡†å¤‡ä¸­(原"候诊中") / å·²å¬å›žï¼šç»¿è‰² â€”— æ­£å¸¸ç­‰å¾… */
.row-ready {
  background-color: var(--el-color-success-light-8);
  border-left-color: var(--el-color-success);
  color: var(--el-color-success);
}
/* å…¶ä»–状态:灰色,弱化显示 */
.row-other {
  background-color: var(--el-fill-color-light);
  border-left-color: var(--el-border-color);
  color: var(--el-text-color-secondary);
}
.col-seq,
.col-bookseq,
.col-name {
  display: inline-block;
}
/* å· + å‰ç¼€ + å¯èƒ½çš„「急」标记,给足宽度避免与姓名挤在一起 */
.col-seq {
  width: 30px;
}
.col-bookseq {
  width: 40px;
  width: 78px;
}
.col-name {
  width: 70px;
}
/* åŠ æ€¥æ ‡è®°ï¼šçº¢åº•ç™½å­—ï¼Œç´§è´´åœ¨æŽ’é˜Ÿå·å‰é¢ */
.jump-badge {
  display: inline-block;
  padding: 0 3px;
  margin-right: 2px;
  border-radius: 2px;
  background-color: var(--el-color-danger);
  color: #fff;
  font-size: 11px;
  line-height: 15px;
  vertical-align: 1px;
}
</style>
src/views/ecg/doctor/components/RoutinePanel.vue
@@ -22,7 +22,7 @@
      </el-col>
      <el-col :span="6">
        <el-form-item label="患者姓名" prop="patName">
          <el-input v-model="formData.episodeId" placeholder="请输入患者姓名" />
          <el-input v-model="formData.patName" placeholder="请输入患者姓名" />
        </el-form-item>
      </el-col>        
    </el-row>
@@ -62,6 +62,11 @@
    ï¼ˆæ›¾å°è¯•让 el-table å†…部滚动以固定表头,但本项目 Element Plus 2.7.0 ä¸‹
       height/maxHeight å‡æœªç”Ÿæ•ˆï¼Œå·²å›žé€€ï¼Œè¯¦è§ã€Šéƒ¨ç½²æ¸…单》5.3.15。)
  -->
  <!-- ã€æœ¬æ¬¡éœ€æ±‚】操作功能区与已完成记录之间的标题栏 -->
  <div class="records-title">
    <span class="records-title__bar"></span>
    <span class="records-title__text">已完成列表</span>
  </div>
  <div class="records-host">
    <ContentWrap class="records-wrap">
      <el-table
@@ -92,7 +97,9 @@
      </el-table-column>
      <el-table-column label="检查状态" align="center" prop="lost">
        <template #default="scope">
          <span v-if="scope.row.jobType === 60">已检查</span>
          <!-- ã€æœ¬éœ€æ±‚】完成被撤销过:该条记录显示「已撤销」;再次完成时后端 undone è‡ªåŠ¨å˜å›ž false -->
          <el-tag v-if="scope.row.undone" type="danger" size="small">已撤销</el-tag>
          <span v-else-if="scope.row.jobType === 60">已检查</span>
          <span v-if="scope.row.jobType === 50">已放弃</span>
        </template>
      </el-table-column>
@@ -218,6 +225,10 @@
  formData.value.seqNum = queueVO?.seqNum
  formData.value.status = queueVO?.status
  formData.value.patSrc = queueVO?.patSrc
  // ã€æœ¬æ¬¡ä¿®å¤ã€‘条码/打印小票依赖 patDetails(含 source/appointmentId),
  // é˜Ÿåˆ—行本就带着它;原先只在 getFreeRentInfo() æŸ¥åˆ°"待检查单"时才赋值,
  // å¯¼è‡´ç»å¤§å¤šæ•°æƒ…况下 patDetails ä¸º undefined â†’ æ¡ç ä¸æ˜¾ç¤ºã€‚
  formData.value.patDetails = queueVO?.patDetails
  formData.value.patMobile = queueVO?.patMobile
  formData.value.patPhone = queueVO?.patPhone
  formData.value.patWardDesc = queueVO?.patWardDesc
@@ -230,7 +241,7 @@
  getFreeRentInfo(formData.value)
  copyBarcode()
}
defineExpose({ setPatient }) // æä¾› setPatient æ–¹æ³•,用于设置患者
defineExpose({ setPatient, getList: () => getList() }) // æä¾› setPatient æ–¹æ³•,用于设置患者
const { t } = useI18n() // å›½é™…化
const message = useMessage() // æ¶ˆæ¯å¼¹çª—
@@ -594,13 +605,16 @@
const getDisplayBarcode = (): string => {
  if (formData.value.checkType == null) return ''
  const displayBarcode: number[] = checkTypeStore.getCheckTypeDispBarCode(formData.value.checkType!)
  if (
    formData.value.patDetails == null ||
    formData.value.patDetails.source == null ||
    !displayBarcode.includes(formData.value.patDetails.source)
  )
    return ''
  // ã€æœ¬æ¬¡ä¿®å¤ã€‘「患者条码显示」未配置(null/空)→ ä¸æ˜¾ç¤ºï¼ˆæŒ‰çŽ°åœºç¡®è®¤å£å¾„ï¼‰
  const displayBarcode: number[] | undefined = checkTypeStore.getCheckTypeDispBarCode(formData.value.checkType!)
  if (displayBarcode == null || displayBarcode.length === 0) return ''
  // ã€æœ¬æ¬¡ä¿®å¤ã€‘来源优先取 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 ''
  return getBarcode()
}
@@ -608,11 +622,12 @@
const getBarcode = (): string => {
  if (formData.value.checkType == null) return ''
  const patSource = formData.value.patDetails?.source
  const patSource = formData.value.patDetails?.source ?? formData.value.patSrc
  if (patSource == null) return ''
  if (1 === patSource || 2 === patSource) return formData.value.applyNo ?? ''
  else if (3 === patSource || 4 === patSource) return formData.value.episodeId ?? ''
  // é—¨è¯Š/急诊 â†’ ç”³è¯·å•号;住院/体检 â†’ å°±è¯Šæµæ°´å·
  if (1 === Number(patSource) || 2 === Number(patSource)) return formData.value.applyNo ?? ''
  else if (3 === Number(patSource) || 4 === Number(patSource)) return formData.value.episodeId ?? ''
  return ''
}
@@ -630,6 +645,28 @@
</script>
<style scoped lang="scss">
/* ã€æœ¬æ¬¡éœ€æ±‚】「已完成列表」标题栏:左侧主色竖线 + åŠ ç²—å°æ ‡é¢˜ï¼Œç´§å‡‘é«˜åº¦ä¸æŒ¤åŽ‹è¡¨æ ¼ */
.records-title {
  display: flex;
  align-items: center;
  flex: none;
  gap: 6px;
  padding: 2px 0 6px;
  &__bar {
    width: 3px;
    height: 14px;
    background-color: var(--el-color-primary);
    border-radius: 2px;
  }
  &__text {
    font-size: 14px;
    font-weight: 600;
    color: var(--el-text-color-primary);
  }
}
/* ==========================================================================
   éœ€æ±‚:表单内容挤占了下方记录表格的空间 â€”— åŽ‹ç¼©è¡Œé—´è·ã€‚
   Element Plus é»˜è®¤ `.el-form-item { margin-bottom: 18px }`;
@@ -681,15 +718,21 @@
.patient-actions {
  display: flex;
  flex: none;
  flex-wrap: nowrap;
  /* æŒ‰é’®å¤šæ—¶å…è®¸æ¢è¡Œï¼Œé¿å…æŒ¤åœ¨ä¸€è¡Œï¼ˆæ£€æŸ¥é¡µæœ€å¤š 6 ä¸ªæŒ‰é’®ï¼‰ */
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
  /* ä¸Šä¸‹ç•™ç™½ + åˆ†éš”线,形成独立功能块 */
  padding: 6px 4px;
  margin-bottom: 6px;
  background-color: var(--el-bg-color);
  border-top: 1px solid var(--el-border-color-lighter);
  border-bottom: 1px solid var(--el-border-color-lighter);
  /*
    ã€æœ¬æ¬¡éœ€æ±‚】操作功能区做一点视觉区分,方便医生一眼定位:
    æžæµ…的主色底 + ç»†è¾¹æ¡† + å·¦ä¾§ 3px ä¸»è‰²ç«–条(与「▎已完成列表」标题条同款语言)。
    åªåš"稍有区分",不改按钮本身的尺寸/颜色/顺序。
  */
  padding: 8px 10px;
  margin: 2px 0 10px;
  background-color: var(--el-color-primary-light-9);
  border: 1px solid var(--el-color-primary-light-7);
  border-left: 3px solid var(--el-color-primary);
  border-radius: 4px;
  /* Element Plus ç»™ç›¸é‚» el-button é»˜è®¤åŠ äº† margin-left:12px,会与 gap å åŠ ï¼Œæ¸…æŽ‰ */
  :deep(.el-button + .el-button) {
src/views/ecg/doctor/index.vue
@@ -17,13 +17,19 @@
const roomStore = useRoomStore();
const roomBedVO: RoomBedVO = {
/**
 * å½“前工位信息。
 *
 * ã€æœ¬æ¬¡éœ€æ±‚】必须是**响应式**对象:切换工位时页面会把它改成新工位的值,
 * è‹¥æ˜¯æ™®é€šå¯¹è±¡ï¼Œå·¥ä½åç§°/检查项目等显示不会重绘(看起来像"没换过来")。
 */
const roomBedVO = reactive<RoomBedVO>({
    roomId: null,
    roomName: null,
    bedNo: null,
    checkTypes: null,
    opType: null
}
})
let onStagePatient: QueueVO = {
  bedNo: "", bookCheckType: 0, bookTimeslot: 0, expired: 0, id: 0, jumpFlag: 0, passed: 0,
@@ -41,6 +47,10 @@
const state = ref<boolean>()
const patientList = ref<QueueVO[]>([])
/** ã€æœ¬è¯Šå®¤æœªåˆ†é…ã€‘本工位检查类型下、还没分配工位的"排队中"患者(可手工拉入) */
const roomWaitingList = ref<QueueVO[]>([])
/** ã€è‡ªåŠ©è¯Šæ–­ã€‘æœ€è¿‘ä¸€æ¬¡å–æ•°çš„è¯´æ˜Žï¼ˆç©ºåˆ—è¡¨æ—¶æ˜¾ç¤ºï¼‰ */
const fetchInfo = ref('')
let timerRunFlag : boolean = false
const bedControlFlag = ref<boolean>(true);
@@ -151,6 +161,8 @@
      checkType: last.bookCheckType
    } as any)
    ElMessage.success(msg || '已撤销')
    // ã€æ’¤é”€æ ‡è¯†ã€‘撤销后立刻刷新「已完成记录」列表,让这条记录显示成「已撤销」
    devReadyPanelRef.value?.getList?.()
    await initLoad()
  } finally {
    undoFlag.value = false
@@ -171,6 +183,64 @@
  }
}
/**
 * ã€æœ¬è¯Šå®¤æœªåˆ†é…ã€‘加载"本工位检查类型下、还没分配工位的排队中患者"。
 * å€™è¯Šåˆ—表按 room_id + bed_no æŸ¥ï¼Œçœ‹ä¸åˆ°"排队中(10)"(bed_no ä¸ºç©ºï¼‰çš„人;
 * è¿™é‡Œå•独取回来展示,工位主人可一键「拉入」。失败只打日志,不影响主流程。
 */
const loadRoomWaitingList = async () => {
  if (roomBedVO.roomId == null) return
  try {
    roomWaitingList.value = (await DoctorApi.getRoomWaitingList(roomBedVO)) || []
  } catch (e) {
    console.warn('[loadRoomWaitingList] å–本诊室未分配患者失败', e)
  }
}
/** ã€Œæ‹‰å…¥ã€ï¼šæŠŠæŽ’队中的患者手工分到本工位(后端校验:必须是本工位主人 + æ£€æŸ¥é¡¹ç›®åŒ¹é…ï¼‰ */
const onEventPullWaiting = async (item: any) => {
  try {
    await ElMessageBox.confirm(
      `把「${item.patName || ''}」拉入本工位(${roomBedVO.roomName || ''} ${roomBedVO.bedNo || ''})?`,
      '拉入排队患者',
      { confirmButtonText: '拉入', cancelButtonText: '取消', type: 'warning' }
    )
  } catch {
    return
  }
  try {
    await DoctorApi.pullWaitingPatient({
      roomId: roomBedVO.roomId,
      bedNo: roomBedVO.bedNo,
      id: item.id
    })
    ElMessage.success(`已把「${item.patName || ''}」拉入本工位`)
  } catch (e) {
    console.warn('[onEventPullWaiting] æ‹‰å…¥å¤±è´¥', e)
  }
  await initLoad()
}
/**
 * ã€Œç«‹å³è¡¥äººã€ï¼šæ‰‹åŠ¨è§¦å‘ä¸€æ¬¡æœ¬å·¥ä½çš„è‡ªåŠ¨åˆ†æµï¼Œå¹¶æŠŠåŽç«¯è¿”å›žçš„"为什么没补/补了几人"展示出来。
 * ç³»ç»Ÿåªåœ¨ã€å¼€è¯Šã€‘、本工位未开【停止候诊】、候诊人数未达上限时才会自动补人。
 */
const onEventHurryUp = async () => {
  try {
    const data: any = await DoctorApi.hurryUpNow(roomBedVO)
    const lines = Object.entries(data || {})
      .map(([k, v]) => `${k}:${v === null || v === undefined || v === '' ? '(空)' : v}`)
      .join('\n')
    await ElMessageBox.alert(lines, '立即补人 Â· è¯Šæ–­ç»“æžœ', {
      confirmButtonText: '知道了',
      type: data?.本次补入 > 0 ? 'success' : 'warning'
    })
  } catch (e: any) {
    ElMessage({ message: '立即补人失败:' + (e?.message || e), type: 'warning', duration: 4000 })
  }
  await initLoad()
}
const initLoad = async () => {
  if (roomBedVO.roomId == null) {
    ElNotification.error({
@@ -181,13 +251,18 @@
    return
  }
  patientList.value = await DoctorApi.getToBeCheckedPatientList(roomBedVO)
  patientList.value = (await DoctorApi.getToBeCheckedPatientList(roomBedVO)) || []
  // ã€è‡ªåŠ©è¯Šæ–­ã€‘è®°å½•æœ¬æ¬¡å–æ•°æ—¶é—´ä¸Žæ¡æ•°ï¼ˆç©ºåˆ—è¡¨æ—¶æ˜¾ç¤ºï¼Œä¾¿äºŽåŒºåˆ†"轮询没跑/后端没数据")
  fetchInfo.value = `最近取数 ${new Date().toLocaleTimeString()} Â· è¿”回 ${patientList.value.length} æ¡`
  // ã€æœ¬è¯Šå®¤æœªåˆ†é…ã€‘本工位检查类型下、还没分配工位的"排队中"患者(可手工拉入)
  loadRoomWaitingList()
  getOnstagePatient()
  patientStat.value = await DoctorApi.getDevReadyStatistic(roomBedVO)
  const queueVO2 = await DoctorApi.bedDoctorGet(roomBedVO)
  if (queueVO2.status === 20)  // å·¥ä½çŠ¶æ€ æœ‰åŒ»ç”Ÿ
  // ã€åŠ å›ºã€‘æŽ¥å£å¯èƒ½è¿”å›ž null,原来直接取 .status ä¼šæŠ›å¼‚常、中断本轮后续步骤
  if (queueVO2 && queueVO2.status === 20)  // å·¥ä½çŠ¶æ€ æœ‰åŒ»ç”Ÿ
    state.value = true
  else if (queueVO2.status === 30)  // å·¥ä½çŠ¶æ€ æš‚停
  else if (queueVO2 && queueVO2.status === 30)  // å·¥ä½çŠ¶æ€ æš‚停
    state.value = false
  // éœ€æ±‚3:同步"停止候诊"开关状态
  await loadStopReady()
@@ -210,10 +285,40 @@
const doctorTimer = () => {
  console.log('doctorTimer ...')
  initLoad()
  // ã€åŠ å›ºã€‘å•è½®å–æ•°å¤±è´¥ä¸èƒ½å˜æˆ unhandled rejection(原来 `initLoad()` ä¸ await ä¸ catch,
  // æŸè½®æŽ¥å£å¼‚常会让页面停在旧数据,现场表现就是"列表一直是空的")。
  initLoad().catch((e) => console.error('[doctorTimer] æœ¬è½®å–数失败(已忽略,下一轮继续)', e))
  if (timerRunFlag)
    setTimeout(doctorTimer, 5000) // five seconds
}
/**
 * ã€ä¸»/围观】跟随 store åˆ‡æ¢"正在查看的工位"(原来只在 onMounted æ‹·ä¸€æ¬¡ï¼Œ
 * åˆ‡åˆ°åˆ«çš„人工位围观后本页数据不变,必须刷新页面才生效)。
 */
watch(
  () => [roomStore.room?.roomId, roomStore.room?.bedNo],
  async () => {
    if (!roomStore.room) return
    roomBedVO.roomId = roomStore.room.roomId
    roomBedVO.roomName = roomStore.room.roomName
    roomBedVO.bedNo = roomStore.room.bedNo
    roomBedVO.checkTypes = roomStore.room.checkTypes
    roomBedVO.opType = roomStore.room.opType
    // ã€æœ¬æ¬¡éœ€æ±‚】换工位后**整页数据都换成新工位的**:
    // â‘  å…ˆæ¸…空左侧"患者信息"(避免还显示上一个工位的患者)
    devReadyPanelRef.value?.setPatient?.(undefined)
    // â‘¡ å¯åЍ/继续取数定时器(候诊列表、统计、当前叫号患者、停止候诊开关都会刷新)
    if (!timerRunFlag && roomBedVO.roomId != null) {
      timerRunFlag = true
      doctorTimer()
    } else if (roomBedVO.roomId != null) {
      await initLoad()
    }
    // â‘¢ åˆ·æ–°ã€Œå·²å®Œæˆåˆ—表」(面板内会按新工位的 checkTypes é‡æ–°è¿‡æ»¤ï¼‰
    devReadyPanelRef.value?.getList?.()
  }
)
/** åˆå§‹åŒ– **/
onMounted(() => {
@@ -275,7 +380,7 @@
    å¸ƒå±€ä¼˜åŒ–:功能按钮固定在页面底部,不再用 position:absolute æµ®åœ¨å†…容上。
    è¯¦è§ components/DoctorPageLayout(与叫号检查/装机页共用同一布局)。
  -->
  <DoctorPageLayout>
  <DoctorPageLayout :bed-switch-enabled="false">
    <template #header>
      <TitlePanel :titleType=1 :room="roomBedVO" :patientStatistic="patientStat" />
    </template>
@@ -290,7 +395,11 @@
    />
    <template #aside>
      <QueuePanel :room="roomBedVO" :queue="patientList" @event_RecallFinish="onEventRecallFinish"/>
      <QueuePanel :room="roomBedVO" :queue="patientList" :waiting-list="roomWaitingList"
                  :fetch-info="fetchInfo"
                  @event_RecallFinish="onEventRecallFinish"
                  @event_PullWaiting="onEventPullWaiting"
                  @event_HurryUp="onEventHurryUp" />
    </template>
src/views/ecg/queue/QueueForm.vue
@@ -47,8 +47,8 @@
      <el-form-item label="诊室编号" prop="roomId">
        <el-input v-model="formData.roomId" placeholder="请输入诊室编号" />
      </el-form-item>
      <el-form-item label="诊疗床编号" prop="bedNo">
        <el-input v-model="formData.bedNo" placeholder="请输入诊疗床编号" />
      <el-form-item label="诊室工位编号" prop="bedNo">
        <el-input v-model="formData.bedNo" placeholder="请输入诊室工位编号" />
      </el-form-item>
    </el-form>
    <template #footer>
src/views/ecg/queue/index.vue
@@ -36,7 +36,7 @@
        <el-input v-model="queryParams.roomId" placeholder="请输入诊室编号" clearable @keyup.enter="handleQuery"
          class="!w-240px" />
      </el-form-item>
      <el-form-item label="诊疗床号" prop="bedNo">
      <el-form-item label="诊室工位" prop="bedNo">
        <el-input v-model="queryParams.bedNo" placeholder="请输入诊疗床号" clearable @keyup.enter="handleQuery"
          class="!w-240px" />
      </el-form-item>
@@ -74,58 +74,31 @@
  <!-- åˆ—表 -->
  <ContentWrap>
    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
      <el-table-column label="操作" align="center" min-width="120px">
        <template #default="scope">
          <!--          <el-button
              link
              type="primary"
              @click="openAppointmentForm('preview', scope.row.patId, scope.row.bookCheckType)"
              v-hasPermi="['ecg:appointment:preview']"
          >
            æŸ¥çœ‹
          </el-button>-->
          <!--
                    <el-button
                      link
                      type="danger"
                      @click="handleDelete(scope.row.id)"
                      v-hasPermi="['ecg:queue:delete']"
                    >
                      åˆ é™¤
                    </el-button>
          -->
          <el-button link type="danger" @click="handleJump(scope.row)" v-if="scope.row.status === 10"
            v-hasPermi="['ecg:queue:jump']">
            {{ scope.row.jumpFlag === 0 ? "加急" : "取消加急" }}
          </el-button>
          <el-button link type="danger" @click="recall(scope.row)"
            v-if="scope.row.status === 3 || scope.row.status === 5 || scope.row.status === 7"
            v-hasPermi="['ecg:queue:recall']">
            å¬å›ž
          </el-button>
          <el-button link type="danger" @click="passWaitingPatient(scope.row)" v-if="scope.row.status === 10"
            v-hasPermi="['ecg:queue:pass']">
            è¿‡å·
          </el-button>
        </template>
      </el-table-column>
    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"
      :row-class-name="queueRowClass">
      <!--      <el-table-column label="id" align="center" prop="id" />-->
      <el-table-column label="申请单号" align="center" prop="applyNo" min-width="110px" />
      <el-table-column label="排队序号" align="center" prop="seqNum" />
      <el-table-column label="预约号" align="center" prop="bookSeqNum" />
      <el-table-column label="过号标记" align="center" prop="passed" />
      <el-table-column label="患者编号" align="center" prop="patId" />
      <el-table-column label="患者姓名" align="center" prop="patName" min-width="90px" /><el-table-column label="检查项目"
        align="center" prop="bookCheckType" min-width="100px">
      <el-table-column label="申请单号" align="center" prop="applyNo" min-width="110px" />
      <el-table-column label="检查项目" align="center" prop="bookCheckType" min-width="150px">
        <template #default="scope">
          {{ getCheckTypeName(scope.row.bookCheckType) }}
        </template>
      </el-table-column>
      <el-table-column label="来源" align="center" width="90px">
      <!--
        æŽ’队号(本需求):只保留 seq_num ä¸€ä¸ªå·ï¼Œæ˜¾ç¤ºä¸ºã€Œæ£€æŸ¥é¡¹ç›®å‰ç¼€ + å·ã€ã€‚
        åŽŸæ¥çš„ã€ŒæŽ’é˜Ÿåºå·(bookSeqNum)」列已删除 â€”— å®ƒå’Œã€Œæ£€æŸ¥åºå·(seqNum)」并存正是号混乱的来源。
        å·æŒ‰æ£€æŸ¥é¡¹ç›®å„自编号,不同检查项目会出现相同数字,故用前缀区分。
      -->
      <el-table-column label="排队序号" align="center" prop="seqNum">
        <template #default="scope">
          <!-- ä¼˜å…ˆç”¨é¢„约单平铺值,回退到 patDetails å¿«ç…§ -->
          <dict-tag :type="DICT_TYPE.ECG_PAT_SOURCE" :value="scope.row.patSrc ?? scope.row.patDetails?.source ?? 0" />
          {{ getCheckTypeSeqPrefix(scope.row.bookCheckType) }}{{ scope.row.seqNum ?? '-' }}
        </template>
      </el-table-column>
      <!-- <el-table-column label="过号标记" align="center" prop="passed" /> -->
      <el-table-column label="患者编号" align="center" prop="patId" />
      <el-table-column label="姓名" align="center" min-width="200px">
        <template #default="scope">
          {{ scope.row.patName }} <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="scope.row.patGender" />{{ formatAge(scope.row.patBirthday) }}
          <dict-tag :type="DICT_TYPE.ECG_PAT_SOURCE" :value="scope.row.patSrc" />
        </template>
      </el-table-column>
      <el-table-column label="预约时段" align="center" prop="bookTimeslot" min-width="100px">
@@ -133,12 +106,36 @@
          <dict-tag :type="DICT_TYPE.ECG_BOOK_TIMESLOT" :value="scope.row.bookTimeslot" />
        </template>
      </el-table-column>
      <el-table-column label="状态" align="center" prop="status">
      <el-table-column label="状态" align="center" prop="status" width="90px">
        <template #default="scope">
          <dict-tag :type="DICT_TYPE.ECG_QUEUE_STATUS" :value="scope.row.status" />
        </template>
      </el-table-column>
      <el-table-column label="多耐药" align="center" prop="tolerance">
        <template #default="scope">
          <!-- éœ€æ±‚6:多耐走字典;NULL æŒ‰"无"显示(Q12) -->
          <dict-tag :type="DICT_TYPE.ECG_TOLERANCE" :value="scope.row.tolerance ?? 0" />
        </template>
      </el-table-column>
      <el-table-column label="VIP" align="center" prop="isVip" />
      <!-- VIP:显示「是/否」中文(原先直接显示 0/1)。字典 infra_boolean_string = true/是、false/否 -->
      <el-table-column label="VIP" align="center" width="80px">
        <template #default="scope">
          <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="Number(scope.row.isVip) === 1" />
        </template>
      </el-table-column>
      <el-table-column label="开单医师" align="center" prop="doctor" min-width="100px">
        <template #default="scope">{{ scope.row.doctor || '-' }}</template>
      </el-table-column>
      <!-- éœ€æ±‚1:医嘱号(HIS observationRequest.id.item[root=2.16.156.10011.1.28])。
           è¯¥èŠ‚ç‚¹åœ¨è§„èŒƒä¸­éžå¿…é¡»é¡¹ï¼ŒåŽ†å²æ•°æ®ï¼ˆæ–°å¢žåˆ—ä¹‹å‰é‡‡é›†çš„ï¼‰ä¸ºç©ºï¼Œæ˜¾ç¤º '-'。 -->
      <el-table-column label="医嘱号" align="center" prop="doctorAdviceNo" min-width="110px">
        <template #default="scope">
          {{ scope.row.doctorAdviceNo || '-' }}
        </template>
      </el-table-column>
      <el-table-column label="诊室编号" align="center" prop="roomId" />
      <el-table-column label="诊室名称" align="center" prop="roomName" />
      <el-table-column label="诊室工位" align="center" prop="bedNo" />
      <el-table-column label="就诊号" align="center" prop="episodeId" min-width="110px" />
      <!-- æœ¬åˆ—标签由「就诊卡号」改为「门诊号」(经确认)。原因(详见《部署清单》5.1):
           HIS åœ¨å¡å·æ§½ä½ä¼ ç©ºï¼Œmedical_card å…¨åº“ 159,116 è¡Œ**无一非空**;
@@ -152,23 +149,6 @@
          </el-tag>
        </template>
      </el-table-column>
      <!-- éœ€æ±‚1:医嘱号(HIS observationRequest.id.item[root=2.16.156.10011.1.28])。
           è¯¥èŠ‚ç‚¹åœ¨è§„èŒƒä¸­éžå¿…é¡»é¡¹ï¼ŒåŽ†å²æ•°æ®ï¼ˆæ–°å¢žåˆ—ä¹‹å‰é‡‡é›†çš„ï¼‰ä¸ºç©ºï¼Œæ˜¾ç¤º '-'。 -->
      <el-table-column label="医嘱号" align="center" prop="doctorAdviceNo" min-width="110px">
        <template #default="scope">
          {{ scope.row.doctorAdviceNo || '-' }}
        </template>
      </el-table-column>
      <el-table-column label="性别" align="center" prop="patGender" width="70px">
        <template #default="scope">{{ genderFormat(scope.row.patGender) }}</template>
      </el-table-column>
      <el-table-column label="出生日期" align="center" min-width="110px">
        <template #default="scope">{{ formatDateOnly(scope.row.patBirthday) }}</template>
      </el-table-column>
      <el-table-column label="年龄" align="center" width="80px">
        <template #default="scope">{{ formatAge(scope.row.patBirthday) }}</template>
      </el-table-column>
      <el-table-column label="病区" align="center" min-width="110px">
        <template #default="scope">{{ scope.row.patWardDesc || scope.row.patDetails?.wardDesc || '-' }}</template>
      </el-table-column>
@@ -179,35 +159,47 @@
          {{ scope.row.patBedNo || scope.row.patDetails?.bedNo || '-' }}
        </template>
      </el-table-column>
      <el-table-column label="出生日期" align="center" min-width="110px">
        <template #default="scope">{{ formatDateOnly(scope.row.patBirthday) }}</template>
      </el-table-column>
      <el-table-column label="患者电话" align="center" min-width="120px">
        <template #default="scope">
          {{ scope.row.patMobile || scope.row.patPhone || scope.row.patDetails?.mobile || '-' }}
        </template>
      </el-table-column>
      <el-table-column label="登记时间" align="center" min-width="150px">
        <template #default="scope">{{ formatDateTime(scope.row.registerDate) }}</template>
      </el-table-column>
      </el-table-column>
      <el-table-column label="开单日期" align="center" min-width="150px">
        <template #default="scope">{{ formatDateTime(scope.row.doctorTime) }}</template>
      </el-table-column>
      <el-table-column label="距今天数" align="center" width="90px">
        <template #default="scope">{{ formatDaysFromOrder(scope.row.doctorTime) }}</template>
      </el-table-column>
      <el-table-column label="开单医师" align="center" prop="doctor" min-width="100px">
        <template #default="scope">{{ scope.row.doctor || '-' }}</template>
      <el-table-column label="过期标记" align="center" prop="expired" />
      <el-table-column label="登记时间" align="center" min-width="150px">
        <template #default="scope">{{ formatDateTime(scope.row.registerDate) }}</template>
      </el-table-column>
      <el-table-column label="多耐药" width="150" align="center" prop="tolerance">
      <el-table-column label="创建时间" align="center" prop="createTime" :formatter="dateFormatter" width="180px" />
      <!-- æ“ä½œåˆ—:固定在列表最后位置(fixed="right" ä¿è¯å§‹ç»ˆè´´åœ¨æœ€å³ä¾§ï¼‰
           æŒ‰é’®é…è‰²ä¸Žã€Œé¢„约记录」模块的操作列保持一致:
             åŠ æ€¥ / è¿‡å· â†’ åŒè¯¥æ¨¡å—「编辑」按钮(type=primary plain)
             å¬å›ž        â†’ åŒè¯¥æ¨¡å—「删除」按钮(type=danger  plain) -->
      <el-table-column label="操作" fixed="right" align="center" width="180px">
        <template #default="scope">
          <!-- éœ€æ±‚6:多耐走字典;NULL æŒ‰"无"显示(Q12) -->
          <dict-tag :type="DICT_TYPE.ECG_TOLERANCE" :value="scope.row.tolerance ?? 0" />
          <el-button type="primary" plain size="small" @click="handleJump(scope.row)" v-if="scope.row.status === 10"
            v-hasPermi="['ecg:queue:jump']">
            {{ scope.row.jumpFlag === 0 ? "加急" : "取消加急" }}
          </el-button>
          <el-button type="primary" plain size="small" @click="passWaitingPatient(scope.row)"
            v-if="scope.row.status === 10" v-hasPermi="['ecg:queue:pass']">
            è¿‡å·
          </el-button>
          <el-button type="danger" plain size="small" @click="recall(scope.row)"
            v-if="scope.row.status === 3 || scope.row.status === 5 || scope.row.status === 7"
            v-hasPermi="['ecg:queue:recall']">
            å¬å›ž
          </el-button>
        </template>
      </el-table-column>
      <el-table-column label="过期标记" align="center" prop="expired" />
      <el-table-column label="诊室编号" align="center" prop="roomId" />
      <el-table-column label="诊室名称" align="center" prop="roomName" />
      <el-table-column label="诊疗床号" align="center" prop="bedNo" />
      <el-table-column label="创建时间" align="center" prop="createTime" :formatter="dateFormatter" width="180px" />
    </el-table>
    <!-- åˆ†é¡µ -->
    <Pagination :total="total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize"
@@ -230,7 +222,7 @@
import { useCheckTypeStore } from "@/store/modules/checkType";
import AppointmentForm from "@/views/ecg/appointment/AppointmentForm.vue";
import { genderFormat } from "@/utils/stringUtil";
import { getCheckTypeName } from "../../../utils/checkTypeFormatter";
import { getCheckTypeName, getCheckTypeSeqPrefix } from "../../../utils/checkTypeFormatter";
// éœ€æ±‚1:患者信息 18 é¡¹çš„公共格式化(年龄、距今天数等)
import { formatAge, formatDaysFromOrder, formatDateTime, formatDateOnly, resolveCardNo } from '@/utils/patientInfo'
@@ -385,4 +377,42 @@
  console.info("queue onActivated...")
  handleQuery()
})
/**
 * é˜Ÿåˆ—行样式:把「已过号」的记录整行标红,方便医生一眼识别。
 *
 * ä¸ºä»€ä¹ˆæŒ‰è¡Œé«˜äº®è€Œä¸æ˜¯åªç»™çŠ¶æ€æ ‡ç­¾ä¸Šè‰²ï¼š
 *   Â· å­—å…¸ ecg_queue_status é‡Œ 13 ä¸ªçŠ¶æ€**都没有配 color_type**,
 *     æ‰€ä»¥ dict-tag æ¸²æŸ“出来全是默认灰色标签,彼此没有区分度;
 *   Â· è¿‡å·æ˜¯æœ€éœ€è¦è¢«æ³¨æ„çš„状态(患者错过了叫号),整行标色比小标签更醒目。
 *
 * å·²è¿‡å·çŠ¶æ€å€¼ï¼ˆå­—å…¸ ecg_queue_status):
 *   3 = å·²è¿‡å·-排队、5 = å·²è¿‡å·ã€7 = å·²è¿‡å·-安装
 */
const PASSED_STATUSES = [3, 5, 7]
const queueRowClass = ({ row }: { row: QueueVO }) => {
  return PASSED_STATUSES.includes(Number(row.status)) ? 'queue-row-passed' : ''
}
</script>
<style scoped lang="scss">
/* å·²è¿‡å·ï¼šæ•´è¡Œæµ…红底 + å·¦ä¾§çº¢è‰²ç«–条,扫一眼就能定位 */
:deep(.el-table .queue-row-passed) {
  /* el-table çš„行背景取自该变量,覆写它可让行底色整体变红 */
  --el-table-tr-bg-color: var(--el-color-danger-light-9);
  td.el-table__cell {
    background-color: var(--el-color-danger-light-9);
  }
  /* å·¦ä¾§ç«–条用 border-left è€Œéž box-shadow:与 el-table çš„单元格边框模型更兼容 */
  td.el-table__cell:first-child {
    border-left: 4px solid var(--el-color-danger);
  }
  /* é¼ æ ‡æ‚¬åœæ—¶ä¿æŒçº¢è‰²åŸºè°ƒï¼Œé¿å…è¢« el-table çš„ hover èƒŒæ™¯è¦†ç›– */
  &:hover>td.el-table__cell {
    background-color: var(--el-color-danger-light-8) !important;
  }
}
</style>
src/views/ecg/room/RoomForm.vue
@@ -17,11 +17,11 @@
          />
        </el-select>
      </el-form-item>
      <el-form-item label="诊疗床编号" prop="bedNo">
        <el-input v-model="formData.bedNo" placeholder="请输入诊疗床编号" />
      <el-form-item label="工位编号" prop="bedNo">
        <el-input v-model="formData.bedNo" placeholder="请输入工位编号" />
      </el-form-item>
      <el-form-item label="诊室IP" prop="bedNo">
        <el-input v-model="formData.ip" placeholder="请输入诊室IP" />
      <el-form-item label="工位绑定IP" prop="bedNo">
        <el-input v-model="formData.ip" placeholder="请输入工位绑定的IP" />
      </el-form-item>
      <el-form-item label="检查项目" prop="checkTypes">
@@ -35,6 +35,7 @@
          <el-radio :value="0" > æ£€æŸ¥ </el-radio>
          <el-radio :value="1" > é¢†ç”¨ </el-radio>
          <el-radio :value="2" > è£…机 </el-radio>
          <el-radio :value="3" > åˆ†è¯Š </el-radio>
        </el-radio-group>
      </el-form-item>
src/views/ecg/room/RoomLoginSelect.vue
@@ -1,5 +1,35 @@
<template>
  <div class="room-select">
    <!--
      æœ¬é¡µæ˜¯**顶层路由**(不在 Layout é‡Œï¼‰ï¼Œå…¨å±ç‹¬å ã€æ²¡æœ‰ä¾§è¾¹èœå•,
      æ‰€ä»¥å¿…须自己提供出口(本次需求:原来进来后出不去)。
    -->
    <div class="room-select__topbar">
      <span class="room-select__title">选择诊室工位</span>
      <div class="room-select__topbar-right">
        <el-button size="default" @click="goBack">
          <Icon icon="ep:back" class="mr-4px" />返回
        </el-button>
        <el-button size="default" @click="showIpDebug">
          <Icon icon="ep:monitor" class="mr-4px" />IP è¯Šæ–­
        </el-button>
        <el-button size="default" type="danger" plain @click="doLogout">
          <Icon icon="ep:switch-button" class="mr-4px" />退出登录
        </el-button>
      </div>
    </div>
    <!-- é€‰æ‹©å·¥ä½æ¨¡å¼æç¤ºåŒºï¼šå½“前电脑IP + æœªè‡ªåŠ¨åŒ¹é…åˆ°çš„åŽŸå› ï¼ˆæ¨¡å¼ 1/3) -->
    <div class="room-select__hint" v-if="hintText">
      <el-alert :title="hintText" :type="hintType" :closable="false" show-icon />
      <div class="room-select__ip">
        <span>当前电脑IP:<b>{{ clientIp || '未识别' }}</b></span>
        <el-button link type="primary" @click="loadClientIp">重新获取</el-button>
        <el-tag v-if="(mode === 2 || mode === 3) && canBindIp" size="small" type="success" effect="plain">入座后会把该IP绑定到所选工位</el-tag>
        <el-tag v-else-if="(mode === 2 || mode === 3) && isIpv6" size="small" type="warning" effect="plain">IPv6 è®¿é—®ï¼Œæœ¬æ¬¡ä¸å†™å…¥IP绑定(仅采用 IPv4)</el-tag>
        <el-tag v-else-if="(mode === 2 || mode === 3) && isLoopback" size="small" type="warning" effect="plain">本机访问(回环地址),本次不会写入IP绑定</el-tag>
      </div>
    </div>
    <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}`">
@@ -12,18 +42,27 @@
      <el-button type="primary" size="large" @click="confirmCurSel">确认</el-button>
      <el-button size="large" @click="resetPage">重置</el-button>
    </div>
    <!-- IP è¯Šæ–­ç»“果(排查"工位绑定的IP不对") -->
    <el-dialog v-model="ipDebugVisible" title="IP è¯Šæ–­ï¼ˆåŽç«¯å®žé™…识别到的客户端地址)" width="640px">
      <pre class="room-select__ip-debug">{{ ipDebugText }}</pre>
      <template #footer>
        <el-button @click="showIpDebug">重新诊断</el-button>
        <el-button type="primary" @click="ipDebugVisible = false">关闭</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup lang="ts">
import {RoomBedSelect} from "@/components/RoomBedSelect"
import { RoomApi, RoomVO } from '@/api/ecg/room'
import { RoomApi, RoomVO, isUsableSeatIp } from '@/api/ecg/room'
import {useUserStore} from "@/store/modules/user";
import {ElMessage, ElMessageBox} from "element-plus";
import {cloneDeep} from "lodash-es";
import {DoctorApi} from "@/api/ecg/doctor";
import {useRoomStore} from "@/store/modules/room";
const {  push } = useRouter()
const { push, back } = useRouter()
defineOptions({ name: 'RoomLoginSelect' })
@@ -32,6 +71,185 @@
const userStore = useUserStore()
const roomStore = useRoomStore()
const curUser = userStore.getUser
/* ==================== é€‰æ‹©å·¥ä½æ¨¡å¼ï¼ˆ0/1/2/3)相关 ==================== */
/** é€‰æ‹©å·¥ä½æ¨¡å¼ï¼š0 å¼ºåˆ¶é€‰æ‹© / 1 IP匹配 / 2 æ‰‹åŠ¨é€‰æ‹©å¹¶å†™IP / 3 ç³»ç»Ÿé»˜è®¤ */
const mode = ref<number>(-1)
/** åŽç«¯è¯†åˆ«åˆ°çš„æœ¬æœºIP */
const clientIp = ref<string>('')
/** è¯¥IP是否是本机回环(127.x / ::1)——说明浏览器和服务在同一台机器,不能作为工位绑定标识 */
const isLoopback = ref<boolean>(false)
/** è¯¥IP是否是 IPv6(本系统工位绑定仅采用 IPv4) */
const isIpv6 = ref<boolean>(false)
/** è¢«å¼ºåˆ¶è·³æ¥æ—¶çš„原因:force/nomatch/occupied/error */
const reason = ref<string>(new URLSearchParams(location.search).get('reason') || '')
/** å·¥ä½è¢«å ç”¨æ—¶çš„占用人 */
const occupiedBy = ref<string>(new URLSearchParams(location.search).get('who') || '')
const hintText = computed(() => {
  if (reason.value === 'multi') {
    return `本机IP(${clientIp.value || '未识别'})对应多个工位,无法自动确定,请点选你所在的工作位置`
  }
  if (reason.value === 'occupied') {
    return `未自动匹配到可用工位:该IP对应的工位已被${occupiedBy.value || '其他医生'}占用,请手动选择工位`
  }
  if (reason.value === 'ip_is_server') {
    return `识别到的是【服务器自身地址】(${clientIp.value || '未识别'}):说明当前是在服务器上/远程桌面里访问,或代理把客户端地址写成了服务器地址 â€”— ä¸èƒ½ä½œä¸ºå·¥ä½æ ‡è¯†ï¼Œè¯·åœ¨åŒ»ç”Ÿç”µè„‘上、用服务器的 IPv4 åœ°å€è®¿é—®ï¼›å¯ç‚¹å³ä¸Šã€ŒIP è¯Šæ–­ã€æŸ¥çœ‹è¯¦æƒ…`
  }
  if (reason.value === 'ip_invalid') {
    return `本机IP不可用(${clientIp.value || '未识别'}):回环地址或 IPv6 ä¸èƒ½ä½œä¸ºå·¥ä½æ ‡è¯†ï¼Œè¯·æ”¹ç”¨æœåŠ¡å™¨çš„ IPv4 åœ°å€è®¿é—®ï¼›ä¹Ÿå¯ç›´æŽ¥æ‰‹åŠ¨é€‰æ‹©å·¥ä½`
  }
  if (reason.value === 'nomatch') {
    return `未自动匹配到工位(本机IP:${clientIp.value || '未识别'}),请手动选择工位`
  }
  if (reason.value === 'error') {
    return '自动匹配工位失败,请手动选择工位'
  }
  if (mode.value === 2 || mode.value === 3) {
    return '请选择你的工位:确认后会把本机IP绑定到该工位,以后登录将按IP自动匹配'
  }
  return ''
})
const hintType = computed(() => (reason.value === 'occupied' ? 'warning' : 'info'))
/** å–本机IP(后端识别),用于提示与模式2/3的绑定 */
const loadClientIp = async () => {
  try {
    const resp: any = await RoomApi.getIpBed()
    clientIp.value = resp?.clientIp || ''
    isLoopback.value = !!resp?.ipLoopback
    isIpv6.value = !!resp?.ipv6
  } catch (e) {
    clientIp.value = ''
    isLoopback.value = false
    isIpv6.value = false
  }
}
/* ==================== æœ¬é¡µå‡ºå£ï¼ˆæœ¬æ¬¡éœ€æ±‚:原来进来后出不去) ==================== */
/**
 * è¿”回上一页;没有历史时回首页。
 *
 * è¯´æ˜Žï¼šæœ¬é¡µæ˜¯**顶层路由**、全屏独占(不在 Layout é‡Œï¼‰ï¼Œå› æ­¤æ²¡æœ‰ä¾§è¾¹èœå•可用。
 * éžåŒ»ç”Ÿè§’色(如 admin)点这里即可正常离开;**未入座的医生**若"返回"到工作台页,
 * ä¼šè¢«è·¯ç”±å®ˆå«å†æ¬¡é€å›žæœ¬é¡µï¼ˆè¿™æ˜¯"强制选工位"的既定行为)—— é‚£ç§æƒ…况请用「退出登录」。
 */
const goBack = () => {
  if (window.history.length > 1) {
    back()
  } else {
    push({ path: '/' })
  }
}
/** é€€å‡ºç™»å½•:任何情况都能离开本页(兜底出口) */
const doLogout = async () => {
  try {
    await ElMessageBox.confirm('确定退出登录吗?', '提示', {
      confirmButtonText: '确定退出',
      cancelButtonText: '取消',
      type: 'warning'
    })
  } catch {
    return // ç”¨æˆ·å–消
  }
  await userStore.loginOut()
}
/* ==================== IP è¯Šæ–­ï¼ˆæŽ’查"工位绑定的IP不对") ==================== */
const ipDebugVisible = ref(false)
const ipDebugText = ref('')
/** è°ƒåŽç«¯è¯Šæ–­æŽ¥å£ï¼ŒæŠŠ"后端到底把谁当成了客户端"逐项摊开 */
const showIpDebug = async () => {
  try {
    const data: any = await RoomApi.ipDebug()
    ipDebugText.value = Object.entries(data || {})
      .map(([k, v]) => {
        let text: string
        if (Array.isArray(v)) {
          text = v.length ? v.join('、') : '(无)'
        } else if (v === null || v === undefined || v === '') {
          text = '(空)'
        } else {
          text = String(v)
        }
        return `${k}:${text}`
      })
      .join('\n')
    ipDebugVisible.value = true
  } catch (e: any) {
    ElMessage({
      message: 'IP è¯Šæ–­å¤±è´¥ï¼š' + (e?.message || e),
      type: 'warning',
      duration: 4000
    })
  }
}
/** æœ¬æœºIP是否可用于绑定(回环/未指定/未识别 â†’ ä¸èƒ½ç»‘定) */
const canBindIp = computed(() => isUsableSeatIp(clientIp.value) && !isLoopback.value)
/**
 * æ¨¡å¼ 2/3:把本机IP写到所选工位。
 * è‹¥è¯¥IP已绑在别的工位,先弹确认,用户同意后带 force=true è¦†ç›–。
 * @returns æ˜¯å¦å¯ä»¥ç»§ç»­å…¥åº§
 */
const bindIpIfNeeded = async (bed: RoomVO): Promise<boolean> => {
  if (mode.value !== 2 && mode.value !== 3) {
    return true // æ¨¡å¼ 0/1 ä¸å†™IP
  }
  if (bed.id == null) {
    return true
  }
  // å›žçŽ¯ï¼ˆ127.x / ::1)说明浏览器和服务在同一台机器,无法作为工位绑定标识
  if (isLoopback.value || !canBindIp.value) {
    let msg: string
    if (isLoopback.value) {
      msg =
        '当前访问来自本机(回环地址 ' +
        clientIp.value +
        '),本次不写入IP绑定;请在实际医生的电脑上访问后再绑定'
    } else if (isIpv6.value) {
      msg =
        '当前访问使用的是 IPv6 åœ°å€ï¼ˆ' +
        clientIp.value +
        '),本系统工位绑定仅采用 IPv4,本次不写入IP绑定;请改用服务器的 IPv4 åœ°å€è®¿é—®'
    } else {
      msg = '未能识别可用的本机IPv4地址(' + (clientIp.value || '空') + '),本次不写入IP绑定'
    }
    ElMessage({ message: msg, type: 'warning', duration: 5000 })
    return true
  }
  try {
    let resp: any = await roomStore.bindCurrentIp(bed.id as number, false)
    if (resp?.code === 409 || resp?.conflict) {
      const target = `「${bed.roomName || ''} / ${bed.bedNo || ''}」`
      await ElMessageBox.confirm(
        `${resp.msg},是否改绑到${target}?(改绑后原工位将不再自动匹配本机)`,
        '提示',
        { confirmButtonText: '确定改绑', cancelButtonText: '取消', type: 'warning' }
      )
      resp = await roomStore.bindCurrentIp(bed.id as number, true)
    }
    if (resp?.code !== 200) {
      ElMessage({ message: resp?.msg || '绑定本机IP失败', type: 'warning', duration: 3000 })
      return false
    }
    clientIp.value = resp.clientIp || clientIp.value
    return true
  } catch (err: any) {
    if (err === 'cancel') {
      return false
    }
    console.error('[bindIpIfNeeded] ç»‘定本机IP异常', err)
    ElMessage({ message: '绑定本机IP失败:' + (err?.message || err), type: 'warning', duration: 3000 })
    return false
  }
}
const isEmptyOpeningBed = ref<boolean>(true);
const bedMap = ref() // åˆ—表的数据
@@ -65,8 +283,11 @@
})
/** åˆå§‹åŒ– **/
onMounted(() => {
onMounted(async () => {
  console.info( curUser.id + " onMounted")
    // è¯»å–「选择工位模式」并取本机IP(模式 2/3 éœ€è¦åœ¨å…¥åº§æ—¶æŠŠIP写到工位)
    mode.value = await roomStore.loadRoomSelectMode()
    loadClientIp()
    getList()
})
@@ -95,15 +316,58 @@
    if (roomStore.getIsSetRoom) {
      const roomVOArray = data[key] as RoomVO[];
      roomVOArray.forEach((roomVO) => {
        if (roomVO.docId === curUser.id
        // ç”¨å­—符串比较,避免 docId(number) ä¸Ž userId(string) ç±»åž‹ä¸åŒå¯¼è‡´åŒ¹é…ä¸ä¸Š
        if (String(roomVO.docId) === String(curUser.id)
            && roomVO.roomId === roomStore.room!.roomId
            && roomVO.bedNo === roomStore.room!.bedNo) {
          curSel.value = roomVO
          // âš ï¸ å¿…é¡»**拷贝**:curSel è‹¥ä¸Žåˆ—表项共用同一个对象,
          // åŽç»­ resetCurSel() å°±åœ°æ¸…零会把列表里那张卡片的诊室名/工位号一起清掉
          // ï¼ˆçŽ°åœºåé¦ˆçš„"切换工位后原工位编号/名称有时不显示"就是这个原因)。
          curSel.value = { ...roomVO }
          roomStore.setRoomInfoAction(curSel.value)
        }
      })
    }
  }
}
/** å·¥ä½ä½œä¸šç±»åž‹ â†’ å·¥ä½œå°è·¯ç”±ï¼ˆ0检查 1领用 2装机) */
const workbenchPath = (opType?: number | null) => {
  if (opType === 0) return '/ecg/doc/routine'
  if (opType === 1) return '/ecg/doc/dev-ready'
  if (opType === 2) return '/ecg/doc/dev-install'
  return ''
}
/**
 * å…¥åº§/切换工位**之后**的去向。
 *
 * ã€æœ¬æ¬¡éœ€æ±‚】不再"选完工位就自动打开叫号检查页" â€”— ç³»ç»Ÿä¸çŸ¥é“用户接下来是要做
 * å«å·æ£€æŸ¥ã€è®¾å¤‡é¢†ç”¨ã€è£…机还是拆机,替他决定反而要多点一次返回。规则:
 *  â‘  è‹¥æ˜¯**被路由守卫踢过来**的(带 redirect / redirectedFrom)→ å›žåˆ°ä»–原本想去的那一页;
 *  â‘¡ `forceEnter=true`(用户主动点了顶部「确认」按钮)→ æŒ‰å·¥ä½ä½œä¸šç±»åž‹è¿›å…¥å¯¹åº”工作台
 *     ï¼ˆè¿™æ˜¯ç”¨æˆ·æ˜Žç¡®è¡¨è¾¾çš„"我要进去",不算替他决定);
 *  â‘¢ å…¶ä½™ï¼ˆè‡ªå·±ç‚¹é¡¶æ è¿›æ¥æ¢å·¥ä½ï¼‰â†’ **停留在本页**,由用户自己从菜单选目标页。
 */
const gotoAfterSeat = (opType?: number | null, forceEnter = false) => {
  const redirect = (route.query.redirect as string) || route.redirectedFrom?.fullPath || ''
  if (redirect && redirect !== '/roomselect') {
    push({ path: redirect })
    return
  }
  if (forceEnter) {
    const path = workbenchPath(opType)
    if (path) {
      push({ path })
      return
    }
  }
  const seat = roomStore.getCurrentSeat
  ElMessage({
    message: `已入座:${seat?.roomName || ''} ${seat?.bedNo || ''}(请从左侧菜单进入需要的工作台)`,
    type: 'success',
    duration: 4000
  })
}
const confirmCurSel = () => {
@@ -115,104 +379,65 @@
    });
    return
  }
  // curSel.value.opType 0 1 2
  if (curSel.value.opType === 0) {
    push({path: "/ecg/doc/routine"})
  }
  else if (curSel.value.opType === 1) {
    push({path: "/ecg/doc/dev-ready"})
  }
  else if (curSel.value.opType === 2) {
    push({path: "/ecg/doc/dev-install"})
  }
  else {
    push({path: route.redirectedFrom?.fullPath})
  }
/*
  if (isStringEmpty(route.redirectedFrom?.fullPath))
    push({ path: "/ecg/doc/dev-ready"})
  else if(route.redirectedFrom?.fullPath === "/roomselect" )
    push({ path: "/ecg/doc/dev-ready"})
  else if(route.redirectedFrom?.fullPath === "/ecg/roomselect" )
    push({ path: "/ecg/doc/dev-ready"})
  else
    push({ path: route.redirectedFrom?.fullPath})
*/
  // ç”¨æˆ·ä¸»åŠ¨ç‚¹ã€Œç¡®è®¤ã€= æ˜Žç¡®è¦è¿›å…¥å·¥ä½œå° â†’ æŒ‰å·¥ä½ä½œä¸šç±»åž‹è¿›å…¥ï¼ˆè‹¥æœ¬æ¬¡æ˜¯è¢«è¸¢è¿‡æ¥çš„,优先回原目标页)
  gotoAfterSeat(curSel.value.opType, true)
}
const haveSeatConfirm = async (newRoomVO: RoomVO) => {
  if (newRoomVO !== curSel.value) {
    let data;
/*  ä¿ç•™è¿™å—逻辑
    if (curSel.value.docId !== null) {
      data = await DoctorApi.bedDoctorOff(curSel.value)
      if (data !== 0) {
        ElMessage({
          message: '内部错误!' + data,
          type: 'info',
          duration: 3000 // è‡ªåŠ¨å…³é—­æ—¶é—´ï¼Œé»˜è®¤ä¸º3000ms
        });
        return
      }
    // æ¨¡å¼ 2/3:先把本机IP绑定到所选工位(冲突时由 bindIpIfNeeded å¼¹ç¡®è®¤ï¼‰
    const canProceed = await bindIpIfNeeded(newRoomVO)
    if (!canProceed) {
      return
    }
*/
    resetCurSel()
    await roomStore.clearRoomInfoAction()
    const tempRoomVO = cloneDeep(newRoomVO)
    tempRoomVO.docId = curUser.id
    tempRoomVO.docName = curUser.nickname
    data = await DoctorApi.bedDoctorOn(tempRoomVO)
    if (data !== 0) {
      ElMessage({
        message: '内部错误!' + data,
        type: 'info',
        duration: 3000 // è‡ªåŠ¨å…³é—­æ—¶é—´ï¼Œé»˜è®¤ä¸º3000ms
      });
    // ã€æœ¬æ¬¡éœ€æ±‚】走 store çš„入座动作:服务端入座成功后会**自动释放我这个账号的其它工位**
    // ï¼ˆä¸€äººä¸€å·¥ä½ï¼‰ï¼Œå¹¶æŠŠå·¥ä½ä¿¡æ¯å†™è¿›ä¼šè¯ç¼“存。
    const seated = await roomStore.seatRoom(tempRoomVO)
    if (!seated) {
      // å¤±è´¥æ—¶è‡ªåŠ¨è°ƒè¯Šæ–­æŽ¥å£ï¼ŒæŠŠåº“é‡ŒçœŸå®žè¡Œä¸Žç»“è®ºä¸€èµ·ç»™å‡ºï¼ˆä¾¿äºŽçŽ°åœºä¸€é”®å®šä½ï¼‰
      await reportSeatFailure(tempRoomVO)
      getList()
      return
    }
    await roomStore.setRoomInfoAction(tempRoomVO)
    newRoomVO.docId = curUser.id
    newRoomVO.docName = curUser.nickname
    curSel.value = newRoomVO;
    // é¡¶æ "当前工位"标签刷新(后端为准)
    await roomStore.fetchMySeat()
    // âš ï¸ ç”¨**拷贝**更新"当前工位":newRoomVO æ˜¯åˆ—表里的对象,
    // ç›´æŽ¥æ”¹å®ƒè™½ç„¶èƒ½è®©å¡ç‰‡æ˜¾ç¤º"我的工位",但后续 resetCurSel() ä¼šè¿žå¸¦æ¸…掉卡片的
    // è¯Šå®¤å/工位号(现场反馈的现象)。改为拷贝后,列表数据只由 getList() ç»Ÿä¸€åˆ·æ–°ã€‚
    curSel.value = { ...newRoomVO, docId: curUser.id, docName: curUser.nickname }
    // ã€æœ¬æ¬¡ä¿®å¤ã€‘入座成功后**重新拉一次列表**:否则卡片还是旧的 docId=null,
    // ä¼šä¸€ç›´æ˜¾ç¤º"空闲"(现场反馈"入座后的工位还是显示空闲")。
    await getList()
  }
  // curSel.value.opType 0 1 2
  if (newRoomVO.opType === 0) {
    push({path: "/ecg/doc/routine"})
  }
  else if (newRoomVO.opType === 1) {
    push({path: "/ecg/doc/dev-ready"})
  }
  else if (newRoomVO.opType === 2) {
    push({path: "/ecg/doc/dev-install"})
  }
  else {
    push({path: route.redirectedFrom?.fullPath})
  }
/*
  // curSel.value.opType 0 1 2
  if (isStringEmpty(route.redirectedFrom?.fullPath)) {
    push({path: "/ecg/doc/dev-ready"})
  }
  else if(route.redirectedFrom?.fullPath === "/roomselect" ) {
    push({path: "/ecg/doc/dev-ready"})
  }
  else if(route.redirectedFrom?.fullPath === "/ecg/roomselect" ) {
    push({path: "/ecg/doc/dev-ready"})
  }
  else {
    push({path: route.redirectedFrom?.fullPath})
  }
*/
  // ã€æœ¬æ¬¡éœ€æ±‚】入座/切换成功后不再自动打开叫号检查页:
  // è¢«å®ˆå«è¸¢è¿‡æ¥çš„回原目标页,自己点进来换工位的则停在本页(见 gotoAfterSeat è¯´æ˜Žï¼‰
  gotoAfterSeat(newRoomVO.opType, false)
}
const leaveSeatConfirm = async (roomVO: RoomVO) => {
  // ã€æœ¬æ¬¡éœ€æ±‚】离座只能清**自己的** doc_id(服务端 setBedDoctorOff å·²åŠ  doc_id æ¡ä»¶ï¼‰ï¼Œ
  // è¿™é‡Œå…ˆæ‹¦ä¸€é“,避免"旁观/其他医生"点离座把正在操作的人踢掉。
  // ç”¨å­—符串比较,避免 number/string ç±»åž‹å·®å¼‚导致误判。
  if (roomVO.docId != null && String(roomVO.docId) !== String(curUser.id)) {
    ElMessage({
      message: `该工位由「${roomVO.docName || '其他医生'}」操作中,您不能让他离座`,
      type: 'warning',
      duration: 4000
    })
    return
  }
  if (roomVO.docId !== null) {
      let data = await DoctorApi.bedDoctorOff(roomVO)
      if (data !== 0) {
@@ -230,31 +455,133 @@
  getList()
}
const haveSeat = async (newRoomVO: RoomVO) => {
  // 1. æå–不同的提示语
  const message = newRoomVO.docId != null
    ? '已经有人了, æ˜¯å¦ç¡®å®šè¦ç»§ç»­å…¥åº§?'
    : '入座, æ˜¯å¦ç»§ç»­?';
/**
 * æŒ‰ roomId + bedNo å–该工位**最新**的数据。
 *
 * ä¸ºä»€ä¹ˆå…¥åº§å‰è¦å†æ‹‰ä¸€æ¬¡ï¼šé¡µé¢ä¸Šçš„列表是进入本页时拉的,医生可能停留很久,
 * æœŸé—´åˆ«äººæ­£å¥½æŠŠæŸä¸ªå·¥ä½åä¸Šäº† â€”— è¿™æ—¶æŒ‰æ—§æ•°æ®åˆ¤æ–­"空闲"就会发出入座请求,
 * è¢«åŽç«¯æ‹’绝(现场反馈的"选了别人已入座的工位报错"正是这种情况)。
 */
const findLatestBed = async (bed: RoomVO): Promise<RoomVO | null> => {
  try {
    const map: any = await RoomApi.getOpeningBedMap()
    const all: RoomVO[] = []
    for (const key in map || {}) {
      const arr = map[key]
      if (Array.isArray(arr)) {
        all.push(...arr)
      }
    }
    return (
      all.find(
        (b) => String(b.roomId) === String(bed.roomId) && String(b.bedNo) === String(bed.bedNo)
      ) || null
    )
  } catch (e) {
    console.warn('[findLatestBed] åˆ·æ–°å·¥ä½æœ€æ–°çŠ¶æ€å¤±è´¥ï¼ŒæŒ‰é¡µé¢çŽ°æœ‰æ•°æ®ç»§ç»­', e)
    return null
  }
}
/**
 * å…¥åº§å¤±è´¥æ—¶çš„自助诊断(本次新增)。
 *
 * çŽ°åœºå‡ºçŽ°è¿‡"工位在库里明明空闲,入座却报状态异常",光靠弹一句错误提示无法定位。
 * è¿™é‡Œåœ¨å¤±è´¥æ—¶è‡ªåŠ¨è°ƒ `GET /clinic/room/bed-state`,把库里**原始行**与结论一起弹给用户,
 * ç”¨æˆ·æŠŠå¼¹çª—内容发回即可定位(结尾空格 / çŠ¶æ€ä¸å¯¹ / å·²è¢«åˆ é™¤ / å·²è¢«åˆ«äººå ç”¨ï¼‰ã€‚
 */
const reportSeatFailure = async (bed: RoomVO) => {
  let detail = ''
  try {
    const data: any = await RoomApi.bedState({ id: bed.id, roomId: bed.roomId, bedNo: bed.bedNo })
    const row = Array.isArray(data?.行明细) && data.行明细.length ? data.行明细[0] : null
    detail =
      (data?.诊断结论 ? `\n\n诊断:${data.诊断结论}` : '') +
      (row
        ? `\n\n库内该行:id=${row.id} room_id=${row.room_id} bed_no=[${row.bed_no}](len=${row.bed_no_len}, HEX=${row.bed_no_hex}) status=${row.status} doc_id=${row.doc_id} doc_name=${row.doc_name} deleted=${row.deleted}`
        : '')
    console.warn('[reportSeatFailure] å·¥ä½çŠ¶æ€è¯Šæ–­', data)
  } catch (e) {
    console.warn('[reportSeatFailure] è¯Šæ–­æŽ¥å£è°ƒç”¨å¤±è´¥', e)
  }
  await ElMessageBox.alert(
    `入座未成功:该工位可能已被他人占用,或状态不允许(已关闭)。${detail}`,
    '入座失败',
    { confirmButtonText: '知道了', type: 'warning' }
  )
}
/**
 * ç‚¹ã€Œå…¥åº§ã€æŒ‰é’®çš„入口。
 *
 * ã€æœ¬æ¬¡éœ€æ±‚】两条规则:
 *  â‘  **不挤掉别人**:工位已被他人操作时不能入座(原来会先把对方"离座"再抢占,
 *     æœåŠ¡ç«¯å·²æ”¹ä¸ºåªå…è®¸æ¸…è‡ªå·±çš„ doc_id,这里给出明确提示而不是让接口报错);
 *  â‘¡ **一人一工位**:我已经在别的工位时,弹确认后切换,原工位由服务端自动释放。
 *
 * å¦ï¼šå…¥åº§å‰ä¼š**重新拉一次最新状态**,避免拿几分钟前的数据去入座(别人可能刚坐上)。
 */
const haveSeat = async (bedFromPage: RoomVO) => {
  // â“ª å…ˆç”¨æœ€æ–°æ•°æ®æ ¸å¯¹ï¼ˆè¿™ä¸€æ­¥æ˜¯æœ¬æ¬¡çŽ°åœº"选了别人已入座的工位→报错"的对症修复)
  const latest = await findLatestBed(bedFromPage)
  if (!latest) {
    ElMessage({
      message: '该工位已不可用(可能已被关闭),列表已刷新,请重新选择',
      type: 'warning',
      duration: 4000
    })
    getList()
    return
  }
  const newRoomVO = latest
  // â‘  å·²è¢«**别人**占用 â†’ ä¸æŠ¢ï¼Œå¹¶åˆ·æ–°åˆ—表让卡片显示"已入座:X"
  if (newRoomVO.docId != null && String(newRoomVO.docId) !== String(curUser.id)) {
    ElMessage({
      message: `该工位已由「${newRoomVO.docName || '其他医生'}」操作中,不能入座(不挤掉正在操作的人)`,
      type: 'warning',
      duration: 5000
    })
    getList()
    return
  }
  // â‘¡ å°±æ˜¯æˆ‘当前这个工位 â†’ ä¸å¿…重复入座(避免多弹一次确认)
  const mySeat = roomStore.getCurrentSeat
  const alreadyMine = newRoomVO.docId != null && String(newRoomVO.docId) === String(curUser.id)
  const sameAsMySeat =
    !!mySeat?.seated &&
    String(mySeat.roomId) === String(newRoomVO.roomId) &&
    mySeat.bedNo === newRoomVO.bedNo
  if (alreadyMine && sameAsMySeat) {
    ElMessage({
      message: `您已入座「${newRoomVO.roomName || ''} / ${newRoomVO.bedNo || ''}」,无需重复入座`,
      type: 'info',
      duration: 3000
    })
    await roomStore.fetchMySeat()
    return
  }
  // â‘¢ æˆ‘已入座其它工位 â†’ ç¡®è®¤åŽåˆ‡æ¢ï¼ˆæœåŠ¡ç«¯å…¥åº§æ—¶ä¼šè‡ªåŠ¨é€€å‡ºåŽŸå·¥ä½ï¼‰
  const switching =
    !!mySeat?.seated &&
    (String(mySeat.roomId) !== String(newRoomVO.roomId) || mySeat.bedNo !== newRoomVO.bedNo)
  const message = switching
    ? `您已在「${mySeat?.roomName || ''} / ${mySeat?.bedNo || ''}」入座,确定切换到「${newRoomVO.roomName || ''} / ${newRoomVO.bedNo || ''}」吗?(原工位会自动退出)`
    : '入座, æ˜¯å¦ç»§ç»­?'
  try {
    // 2. ç­‰å¾…用户点击确认
    await ElMessageBox.confirm(message, '提示', {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'warning'
    });
    if (newRoomVO.docId != null) {
      await leaveSeatConfirm(newRoomVO);
    }
    // 4. ç¦»åº§æˆåŠŸï¼ˆæˆ–åŽŸæœ¬å°±æ²¡äººï¼‰ï¼Œæœ€åŽæ‰§è¡Œå…¥åº§
    await haveSeatConfirm(newRoomVO);
    })
    await haveSeatConfirm(newRoomVO)
  } catch (err) {
    // åªæœ‰ç”¨æˆ·ä¸»åŠ¨ç‚¹å‡»å–æ¶ˆæ—¶æ‰å¿½ç•¥ï¼Œå…¶ä»–æŠ¥é”™å¯ä»¥æ‰“å°å‡ºæ¥æ–¹ä¾¿æŽ’æŸ¥
    if (err !== 'cancel') {
      console.error('入座流程异常:', err);
      console.error('入座流程异常:', err)
    }
  }
}
@@ -276,32 +603,39 @@
// åŒæ—¶åäº†å¤šä¸ªå·¥ä½æ—¶ï¼Œè®¾å®šå½“前会话的工位
const setCurSeat = (roomVO: RoomVO) => {
  roomStore.setRoomInfoAction( roomVO )
  curSel.value = roomVO
  // æ‹·è´ä¸€ä»½å†æ”¾è¿› curSel,避免与列表项共用对象(见 getList é‡Œçš„说明)
  const seat = { ...roomVO }
  roomStore.setRoomInfoAction( seat )
  curSel.value = seat
  // é¡¶æ æ ‡ç­¾åŒæ­¥ï¼ˆä»¥æœåŠ¡ç«¯ä¸ºå‡†ï¼‰
  roomStore.fetchMySeat()
  // curSel.value.opType 0 1 2
  if (roomVO.opType === 0) {
    push({path: "/ecg/doc/routine"})
  }
  else if (roomVO.opType === 1) {
    push({path: "/ecg/doc/dev-ready"})
  }
  else if (roomVO.opType === 2) {
    push({path: "/ecg/doc/dev-install"})
  }
  else {
    push({path: route.redirectedFrom?.fullPath})
  }
  // ã€æœ¬æ¬¡éœ€æ±‚】不自动打开工作台页(见 gotoAfterSeat è¯´æ˜Žï¼‰ï¼š
  // è‹¥æœ¬æ¬¡æ˜¯è¢«å®ˆå«è¸¢è¿‡æ¥çš„会回原目标页,否则停留在选工位页
  gotoAfterSeat(roomVO.opType, false)
}
/**
 * é‡ç½®"当前工位"。
 *
 * âš ï¸ã€æœ¬æ¬¡ä¿®å¤ã€‘å¿…é¡»**赋一个新对象**,不能就地清空 curSel.value çš„字段:
 * åŽŸå®žçŽ°ï¼ˆcurSel.value.bedNo = null ä¹‹ç±»ï¼‰åœ¨ curSel ä¸Žåˆ—表项共用对象时,
 * ä¼šæŠŠé¡µé¢å¡ç‰‡ä¸Šçš„「工位编号 / è¯Šå®¤åç§°ã€ä¸€èµ·æ¸…掉 â€”— ä¹Ÿå°±æ˜¯çŽ°åœºåé¦ˆçš„
 * "切换到其他工位后,原工位有时工位编号没显示,有时诊室名称不显示"。
 */
const resetCurSel = () => {
    curSel.value.id = null
    curSel.value.roomId = null
    curSel.value.roomName = null
    curSel.value.bedNo = null
    curSel.value.status = null
    curSel.value.docId = null
    curSel.value.docName = null
  curSel.value = {
    id: null,
    roomId: null,
    roomName: null,
    bedNo: null,
    ip: null,
    status: null,
    docId: null,
    docName: null,
    checkTypes: [],
    opType: 0
  } as RoomVO
}
</script>
@@ -315,6 +649,22 @@
  height: 100vh;
  padding: 15px;
  overflow: hidden;
  /* é€‰æ‹©å·¥ä½æ¨¡å¼æç¤ºåŒºï¼šè¯´æ˜Žä¸ºä»€ä¹ˆè¢«è¦æ±‚手动选工位 */
  &__hint {
    flex: none;
    margin-bottom: 10px;
  }
  &__ip {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 10px;
    margin-top: 8px;
    font-size: 13px;
    color: #606266;
  }
  // é«˜åº¦éšå¡ç‰‡æ•°é‡è‡ªé€‚应;卡片多到装不下时才内部滚动(页面本身始终不滚动)
  &__scroll {
@@ -345,5 +695,45 @@
    padding-top: 12px;
    text-align: center;
  }
  /*
    é¡¶éƒ¨æ¡ï¼ˆæœ¬æ¬¡éœ€æ±‚):本页是顶层路由、没有侧边菜单,出口只能自己给。
    å·¦æ ‡é¢˜ + å³ã€Œè¿”回 / IP è¯Šæ–­ / é€€å‡ºç™»å½•」。
  */
  &__topbar {
    flex: none;
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 4px 0 10px;
    border-bottom: 1px solid var(--el-border-color-lighter);
    margin-bottom: 8px;
  }
  &__title {
    font-size: 16px;
    font-weight: 600;
    color: #303133;
  }
  &__topbar-right {
    display: flex;
    align-items: center;
    gap: 8px;
  }
  /* IP è¯Šæ–­ç»“果:保留原样空格与换行,便于逐项核对 */
  &__ip-debug {
    margin: 0;
    max-height: 60vh;
    overflow: auto;
    padding: 10px 12px;
    background: var(--el-fill-color-light);
    border-radius: 4px;
    font-size: 13px;
    line-height: 1.8;
    white-space: pre-wrap;
    word-break: break-all;
  }
}
</style>
src/views/ecg/room/RoomSetting.vue
@@ -13,9 +13,34 @@
        <el-button size="small" @click="resetSchedule" > é‡ç½®å®šæ—¶ä»»åŠ¡ </el-button>
        <el-button size="small" @click="refresh" > åˆ·æ–° </el-button>
      </div>
      <div class="mode-area">
        <span class="mode-label">选择工位模式:</span>
        <el-select v-model="roomSelectMode" size="small" style="width: 210px" @change="saveRoomSelectMode">
          <el-option :value="0" label="0 - æŒ‰éœ€è¦å¼ºåˆ¶é€‰æ‹©å·¥ä½" />
          <el-option :value="1" label="1 - ç™»å½•IP匹配工位" />
          <el-option :value="2" label="2 - ç™»å½•时手动选择工位(写入本机IP)" />
          <el-option :value="3" label="3 - æŒ‰ç³»ç»Ÿé»˜è®¤å¤„理" />
        </el-select>
        <span class="mode-tip">
          0=每次都要选;1=按IP自动匹配,匹配不到才让选;2=每次都要选并把本机IP写到该工位;
          3=该IP首次选一次并记住,之后按1自动匹配
        </span>
        <span class="mode-warn" v-if="unboundIpBeds.length > 0">
          ä»¥ä¸‹å·¥ä½æœªç»‘定有效的 IPv4 åœ°å€ï¼ˆæœ¬ç³»ç»Ÿå·¥ä½ç»‘定仅采用 IPv4),模式1、3将无法自动匹配到它们:{{ unboundIpBeds.join('、') }}
        </span>
      </div>
    </el-main>
    <el-aside width="210px" class="monitor-aside">
      <div>开诊时间:{{ openingPeriod }}  {{ monitorInfo?.openingFlag }}  </div>
      <div>
        å¼€è¯Šæ—¶é—´ï¼š{{ openingPeriod }}
        <!--
          ã€æœ¬æ¬¡è¯´æ˜Žã€‘开诊状态是"自动补人"的总门闸:
          å·²é—­è¯Šæ—¶ï¼Œ**排队中的患者不会被自动搬进各工位的候诊区**(界面上不会有报错,只表现为"人不进来")。
        -->
        <el-tag :type="monitorInfo?.openingFlag === 1 ? 'success' : 'info'" size="small">
          {{ monitorInfo?.openingFlag === 1 ? '开诊中(会自动补人)' : '已闭诊(不会自动补人)' }}
        </el-tag>
      </div>
      <div>工位概况: {{monitorInfo?.openingBedNum}} {{monitorInfo?.docBedNum}} {{monitorInfo?.routingBedNum}} </div>
      <div v-for="(value, key) in monitorInfo.checkTypeBedInfo" :key="key">
        {{ getCheckTypeName(Number(key)) }}  {{ value }}
@@ -28,17 +53,23 @@
<script setup lang="ts">
import {RoomStatus} from "@/components/RoomStatus"
import { RoomApi, RoomVO, MonitorInfo } from '@/api/ecg/room'
import { RoomApi, RoomVO, MonitorInfo, isUsableSeatIp } from '@/api/ecg/room'
import { QueueApi } from '@/api/ecg/queue'
import { getConfigKey } from '@/api/infra/config'
import {useCheckTypeStore} from "@/store/modules/checkType";
import {getCheckTypeName} from "../../../utils/checkTypeFormatter";
import { getConfigKey, getConfigPage, createConfig, updateConfig } from '@/api/infra/config'
import { useCheckTypeStore } from "@/store/modules/checkType";
import { getCheckTypeName } from "../../../utils/checkTypeFormatter";
defineOptions({ name: 'RoomSetting' })
const bedMap = ref() // åˆ—表的数据
const openingPeriod = ref<string>('')
/**
 * ã€Œé€‰æ‹©å·¥ä½æ¨¡å¼ã€å‚数(ecg.room.select.mode):
 * 0=按需要强制选择工位(默认,原行为) 1=登录IP匹配工位 2=登录时手动选择工位(写入本机IP) 3=按系统默认处理
 */
const roomSelectMode = ref<number>(0)
const roomSelectModeConfigId = ref<number | undefined>(undefined)
const monitorInfo = ref<MonitorInfo>({
  openingBedNum: 0,
  docBedNum: 0,
@@ -49,9 +80,71 @@
const checkTypeStore = useCheckTypeStore();
/** è¯¥IP能否用于自动匹配工位(支持 IPv4/IPv6;空、0.0.0.0、::、127.x、::1 éƒ½æ˜¯æ— æ•ˆå ä½/回环值) */
const isIpUsable = (ip?: string | null) => isUsableSeatIp(ip)
/** æœªç»‘定有效IP的工位(模式 1、3 åŒ¹é…ä¸åˆ°å®ƒä»¬ï¼Œç®¡ç†é¡µç»™å‡ºæé†’) */
const unboundIpBeds = computed<string[]>(() => {
  const map: any = bedMap.value || {}
  const list: string[] = []
  Object.keys(map).forEach((roomName) => {
    ;(map[roomName] as RoomVO[]).forEach((bed) => {
      if (!isIpUsable(bed.ip)) {
        list.push(`${roomName}/${bed.bedNo ?? ''}`)
      }
    })
  })
  return list
})
const getOpeningPeriod = async () => {
  const data = await getConfigKey('ecg.openingtime')
  openingPeriod.value = data
}
/** è¯»å–「选择工位模式」参数(参数不存在时保持 0) */
const ROOM_SELECT_MODE_KEY = 'ecg.room.select.mode'
const getRoomSelectMode = async () => {
  try {
    const page: any = await getConfigPage({ key: ROOM_SELECT_MODE_KEY, pageNo: 1, pageSize: 10 })
    const hit = (page?.list || []).find((c: any) => c.key === ROOM_SELECT_MODE_KEY)
    if (hit) {
      roomSelectModeConfigId.value = hit.id
      const n = Number(hit.value)
      roomSelectMode.value = [0, 1, 2, 3].includes(n) ? n : 0
    } else {
      // å‚数还没建:按缺省 0 æ˜¾ç¤ºï¼Œä¿å­˜æ—¶å†åˆ›å»º
      roomSelectMode.value = 0
      roomSelectModeConfigId.value = undefined
    }
  } catch (e) {
    console.warn('[getRoomSelectMode] è¯»å–选择工位模式失败,按 0 å¤„理', e)
  }
}
/** ä¿å­˜ã€Œé€‰æ‹©å·¥ä½æ¨¡å¼ã€ï¼šå·²æœ‰å‚数则更新,没有则创建(ConfigSaveReqVO æ²¡æœ‰ type å­—段,别传) */
const saveRoomSelectMode = async () => {
  const payload: any = {
    category: 'biz',
    name: '选择工位模式',
    key: ROOM_SELECT_MODE_KEY,
    value: String(roomSelectMode.value),
    visible: true,
    remark: '0=按需要强制选择工位;1=登录IP匹配工位(匹配不到按0);2=登录时手动选择工位并写入本机IP;3=按系统默认处理(首次选并记IP,之后按1)'
  }
  try {
    if (roomSelectModeConfigId.value) {
      payload.id = roomSelectModeConfigId.value
      await updateConfig(payload)
    } else {
      const id: any = await createConfig(payload)
      roomSelectModeConfigId.value = id ?? undefined
      await getRoomSelectMode()
    }
    ElMessage.success('选择工位模式已保存为 ' + roomSelectMode.value)
  } catch (e: any) {
    ElMessage.error('保存失败:' + (e?.message || e))
  }
}
const getMonitorInfo = async () => {
@@ -94,6 +187,7 @@
onMounted(() => {
  getList()
  getOpeningPeriod()
  getRoomSelectMode()
  getMonitorInfo()
})
@@ -127,6 +221,32 @@
  margin-bottom: 8px;
}
/* é€‰æ‹©å·¥ä½æ¨¡å¼è®¾ç½®åŒº */
.mode-area {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  padding: 6px 8px;
  font-size: 12px;
  color: #606266;
}
.mode-label {
  font-weight: 600;
}
.mode-tip {
  color: #909399;
  max-width: 640px;
  line-height: 16px;
}
.mode-warn {
  flex: 1 0 100%;
  color: #e6a23c;
}
/* å³ä¾§ç›‘控信息:字号收紧,避免换行撑高 */
.monitor-aside {
  padding: 8px 10px;
src/views/ecg/room/index.vue
@@ -17,10 +17,10 @@
          class="!w-240px"
        />
      </el-form-item>
      <el-form-item label="诊疗床号" prop="bedNo">
      <el-form-item label="诊室工位" prop="bedNo">
        <el-input
          v-model="queryParams.bedNo"
          placeholder="请输入诊疗床编号"
          placeholder="请输入诊室工位编号"
          clearable
          @keyup.enter="handleQuery"
          class="!w-240px"
@@ -66,8 +66,13 @@
    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
      <el-table-column label="诊室编号" align="center" prop="roomId" />
      <el-table-column label="诊室名称" align="center" prop="roomName" />
      <el-table-column label="诊疗床编号" align="center" prop="bedNo" />
      <el-table-column label="诊室IP" align="center" prop="ip" />
      <el-table-column label="诊室工位" align="center" prop="bedNo" />
      <el-table-column label="工位绑定IP" align="center" prop="ip" />
      <el-table-column label="操作类型" align="center" prop="opType">
        <template #default="scope">
          {{ opTypeText(scope.row.opType) }}
        </template>
      </el-table-column>
      <el-table-column label="检查项目" align="center" prop="checkTypes" width="180px">
        <template #default="scope">
          <div v-for="(checkType, subIndex) in scope.row.checkTypes" :key="subIndex">
@@ -144,6 +149,21 @@
const queryFormRef = ref() // æœç´¢çš„表单
const exportLoading = ref(false) // å¯¼å‡ºçš„加载中
/**
 * æ“ä½œç±»åž‹ä¸­æ–‡ï¼ˆæœ¬éœ€æ±‚:列表不再显示数字)。
 *
 * å–值与后端 `BedOpTypeEnum`、以及本模块 RoomForm çš„「作业类型」单选项保持一致:
 *   0=检查  1=领用  2=装机
 * åŽ†å²è„æ•°æ®ï¼ˆnull / å…¶å®ƒå€¼ï¼‰æ˜¾ç¤º '-',不猜。
 */
const opTypeText = (opType?: number | null) => {
  if (opType === 0) return '检查'
  if (opType === 1) return '领用'
  if (opType === 2) return '装机'
  if (opType === 3) return '分诊'
  return '-'
}
/** æŸ¥è¯¢åˆ—表 */
const getList = async () => {
  loading.value = true
vite.config.ts
@@ -34,6 +34,21 @@
          target: 'http://localhost:48080',
          ws: false,
          changeOrigin: true,
          // ã€é‡è¦ã€‘xfwd=true:代理转发时把**真实客户端IP**写入 X-Forwarded-For。
          //
          // ä¸ºä»€ä¹ˆå¿…须开:后端用 Hutool çš„ ServletUtil.getClientIP(),它优先读
          // X-Forwarded-For;没有该头时退回 request.getRemoteAddr()。而这里是 Node ä»£ç†åŽ»è¿ž
          // `http://localhost:48080`,Node åœ¨ Windows ä¸ŠæŠŠ localhost ä¼˜å…ˆè§£æžä¸º IPv6,
          // äºŽæ˜¯åŽç«¯æ‹¿åˆ°çš„æ˜¯å›žçŽ¯åœ°å€ `0:0:0:0:0:0:0:1`(纯 IPv4 æœºå™¨ä¸Šæ˜¯ 127.0.0.1)。
          // å®žæµ‹è¡¨çŽ°ï¼šå³ä½¿ç”¨ http://192.168.3.15/ è®¿é—®ï¼Œsystem_login_log.user_ip ä»è®°å½•
          // `0:0:0:0:0:0:0:1`,使"按IP自动匹配诊室工位"(ecg.room.select.mode=1/3)永远匹配不到,
          // ä¹Ÿæ— æ³•把本机IP写入 clinic_room.ip。
          //
          // å¼€å¯åŽï¼šç™»å½•日志、按IP取诊室(CallController / CallingScreenController)、
          // é€‰æ‹©å·¥ä½æ¨¡å¼ç­‰éƒ½èƒ½æ‹¿åˆ°çœŸå®žå®¢æˆ·ç«¯ IPv4 åœ°å€ã€‚
          // æ³¨æ„ï¼šX-Forwarded-For å¯è¢«ä¼ªé€ ï¼Œä»…适用于内网/开发环境;
          // ç”Ÿäº§çŽ¯å¢ƒç”±å‰ç½®åå‘ä»£ç†ï¼ˆnginx)直接透传该头即可,无需依赖本配置。
          xfwd: true,
          // rewrite: (path) => path.replace(new RegExp(`^/admin-api`), ''),
        },
      },