| | |
| | | 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 => ({ |
| | |
| | | docName: null, |
| | | checkTypes: null, |
| | | opType: null |
| | | } |
| | | }, |
| | | // 选择工位模式:-1 = 尚未从后端取到 |
| | | roomSelectMode: -1, |
| | | // 当前入座的工位(顶栏显示;未入座为 null) |
| | | currentSeat: null, |
| | | // 正在查看的工位的主人姓名(围观提示用) |
| | | viewOwnerName: null, |
| | | // 正在查看的工位现在是否无人操作(主人已离座 → 只提示,不自动接管) |
| | | viewTargetVacant: false |
| | | }), |
| | | getters: { |
| | | // 医生诊室选择 |
| | |
| | | }, |
| | | 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: { |
| | |
| | | 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)。 |
| | | // |
| | |
| | | // 才启动取数定时器 ⇒ **页面一片空白,看起来像"没加载排队病人"**; |
| | | // · 角色为 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() { |
| | |
| | | this.room.docName = null |
| | | |
| | | this.isSetRoom = false |
| | | // 顶栏标签同步清空(离座后显示"未入座") |
| | | this.currentSeat = null |
| | | this.viewOwnerName = null |
| | | this.viewTargetVacant = false |
| | | |
| | | // 清缓存(与上面的写入对应,避免下次启动把已离座的诊室恢复出来) |
| | | wsCache.delete(CACHE_KEY.ROOM) |
| | |
| | | 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 || {} |
| | | } |
| | | } |
| | | }) |
| | | |