yxh
2026-10-18 1446994d504819c367e1ff667360eadb314f9ac6
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 || {}
    }
  }
})