88 分诊排队管理-》科室叫号显示屏 表triage_queue_item中添加了联合索引queue_date,organization_id,tenant_id,queue_order。添加了room_no,practitioner_id字段。

This commit is contained in:
weixin_45799331
2026-01-27 11:09:00 +08:00
parent 41494ebf7c
commit c4c3073be0
9 changed files with 644 additions and 91 deletions

View File

@@ -1,9 +1,14 @@
<template>
<div class="call-number-display">
<div class="call-number-display" ref="screenContainer">
<!-- 头部区域 -->
<div class="header">
<h1>{{ departmentName }}</h1>
<div class="time">{{ currentTime }}</div>
<div class="header-right">
<button class="fullscreen-btn" @click="toggleFullscreen">
{{ isFullscreen ? '退出全屏' : '全屏' }}
</button>
<div class="time">{{ currentTime }}</div>
</div>
</div>
<!-- 当前呼叫区 -->
@@ -31,19 +36,19 @@
</tr>
</thead>
<tbody>
<template v-for="doctorName in paginatedDoctors" :key="doctorName">
<template v-for="doctorName in paginatedDoctors">
<template v-if="groupedPatients[doctorName]">
<!-- 医生分组标题 -->
<tr class="doctor-header">
<tr class="doctor-header" :key="`doctor-${doctorName}`">
<td colspan="4">{{ doctorName }} 医生 (诊室: {{ getDoctorRoom(doctorName) }})</td>
</tr>
<!-- 患者列表 -->
<tr v-for="(patient, index) in groupedPatients[doctorName]" :key="patient.id">
<tr v-for="(patient, index) in groupedPatients[doctorName]" :key="`${doctorName}-${patient.id}`">
<td>{{ index + 1 }}</td>
<td>{{ formatPatientName(patient.name) }}</td>
<td>{{ patient.name }}</td>
<td>{{ getDoctorRoom(doctorName) }}</td>
<td :style="{ color: index === 0 ? '#e74c3c' : '#27ae60' }">
{{ index === 0 ? '就诊中' : '等待' }}
<td :style="{ color: patient.status === 'CALLING' ? '#e74c3c' : '#27ae60' }">
{{ patient.status === 'CALLING' ? '就诊中' : '等待' }}
</td>
</tr>
</template>
@@ -88,49 +93,64 @@
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted, nextTick, watchEffect } from 'vue'
import { storeToRefs } from 'pinia'
import { ElMessage } from 'element-plus'
import dayjs from 'dayjs'
import request from '@/utils/request'
import useUserStore from '@/store/modules/user'
// ========== 配置参数 ==========
const userStore = useUserStore()
const { orgId: userOrgId, tenantId: userTenantId } = storeToRefs(userStore)
// 从登录用户获取科室ID避免硬编码后端已确保 orgId 以字符串返回)
const ORGANIZATION_ID = computed(() => (userOrgId.value ? String(userOrgId.value) : ''))
const TENANT_ID = computed(() => (userTenantId.value ? Number(userTenantId.value) : 1))
const API_BASE_URL = '/triage/queue'
// WebSocket 地址(通过 Nginx 代理,路径需要加 /openhis 前缀)
const WS_URL = computed(
() => `ws://${window.location.hostname}:18080/openhis/ws/call-number-display/${ORGANIZATION_ID.value}`
)
// 响应式数据
const currentTime = ref('')
const currentCall = ref({
number: '1',
name: '李*四',
room: '3号'
})
const currentCall = ref(null)
const patients = ref([])
const loading = ref(false)
const currentPage = ref(1)
const patientsPerPage = 5
const autoScrollInterval = ref(null)
const scrollInterval = 5000 // 5秒自动翻页
const wsConnection = ref(null) // WebSocket 连接
const timeInterval = ref(null)
const isFullscreen = ref(false)
const screenContainer = ref(null)
let tableContainer = null
// 科室名称
const departmentName = ref('心内科叫号显示屏幕')
const departmentName = ref('叫号显示屏幕')
// 计算属性
const applyDefaultDepartmentName = () => {
if (userStore.orgName) {
departmentName.value = `${userStore.orgName} 叫号显示屏`
}
}
// 等待总人数(从后端返回)
const waitingCount = ref(0)
// 计算属性:按医生分组的患者列表
const groupedPatients = computed(() => {
const grouped = {}
patients.value.forEach(patient => {
if (!grouped[patient.doctor]) {
grouped[patient.doctor] = []
}
grouped[patient.doctor].push(patient)
patients.value.forEach(doctorGroup => {
grouped[doctorGroup.doctorName] = doctorGroup.patients || []
})
return grouped
})
const waitingCount = computed(() => {
let count = 0
Object.values(groupedPatients.value).forEach(group => {
count += Math.max(0, group.length - 1) // 排除每个医生组中第一个就诊中的患者
})
return count
})
// 获取排序后的医生列表
const sortedDoctors = computed(() => {
return Object.keys(groupedPatients.value).sort()
return patients.value.map(group => group.doctorName)
})
// 按医生分组的分页逻辑
@@ -159,6 +179,24 @@ const updateTime = () => {
currentTime.value = now.format('YYYY-MM-DD HH:mm')
}
const updateFullscreenState = () => {
const isActive = !!document.fullscreenElement
isFullscreen.value = isActive
document.body.classList.toggle('call-screen-fullscreen', isActive)
}
const toggleFullscreen = async () => {
try {
if (document.fullscreenElement) {
await document.exitFullscreen()
} else if (screenContainer.value && screenContainer.value.requestFullscreen) {
await screenContainer.value.requestFullscreen()
}
} catch (error) {
console.error('切换全屏失败:', error)
}
}
const formatPatientName = (name) => {
if (!name || typeof name !== 'string') return '-'
if (name.length === 0) return '-'
@@ -166,49 +204,98 @@ const formatPatientName = (name) => {
}
const getDoctorRoom = (doctorName) => {
// 根据医生获取固定诊室
const doctorRooms = {
'张医生': '3号',
'李医生': '1号',
'王医生': '2号'
}
return doctorRooms[doctorName] || '1号'
// 从后端数据中查找医生的诊室
const doctorGroup = patients.value.find(group => group.doctorName === doctorName)
return doctorGroup?.roomNo || '1号'
}
const generateWaitingData = async () => {
const ensureUserInfo = async () => {
if (!userStore.orgId) {
try {
await userStore.getInfo()
} catch (error) {
console.error('获取用户信息失败:', error)
}
}
applyDefaultDepartmentName()
}
/**
* 获取显示屏数据从后端API
*/
const fetchDisplayData = async () => {
try {
if (!ORGANIZATION_ID.value) {
ElMessage.warning('未获取到登录用户科室信息')
return
}
loading.value = true
// 确保数组已正确初始化
if (!Array.isArray(patients.value)) {
patients.value = []
console.log('正在获取显示屏数据...', {
url: `${API_BASE_URL}/display`,
organizationId: ORGANIZATION_ID.value,
tenantId: TENANT_ID.value
})
const response = await request({
url: `${API_BASE_URL}/display`,
method: 'get',
params: {
organizationId: ORGANIZATION_ID.value,
tenantId: TENANT_ID.value
}
})
console.log('后端响应:', response)
if (response.code === 200 && response.data) {
const data = response.data
// 更新科室名称
if (data.departmentName && data.departmentName !== '叫号显示屏') {
departmentName.value = data.departmentName
} else {
applyDefaultDepartmentName()
}
// 更新当前叫号信息
if (data.currentCall) {
currentCall.value = data.currentCall
} else {
currentCall.value = {
number: null,
name: '-',
room: '-',
doctor: '-'
}
}
// 更新等候队列(按医生分组)
if (data.waitingList && Array.isArray(data.waitingList)) {
patients.value = data.waitingList
console.log('等候队列数据:', data.waitingList)
} else {
patients.value = []
console.log('等候队列为空')
}
// 更新等待人数
waitingCount.value = data.waitingCount || 0
console.log('显示屏数据更新成功', data)
ElMessage.success('数据加载成功')
} else {
throw new Error(response.msg || '获取数据失败')
}
// 模拟API调用获取候诊数据
// 实际项目中这里应该调用真实API
const mockData = [
{ id: 13, name: '李四', type: '专家', doctor: '张医生', status: '就诊中' },
{ id: 14, name: '王五', type: '普通', doctor: '李医生', status: '候诊中' },
{ id: 15, name: '赵六', type: '专家', doctor: '张医生', status: '候诊中' },
{ id: 16, name: '钱七', type: '普通', doctor: '王医生', status: '候诊中' },
{ id: 17, name: '孙八', type: '专家', doctor: '李医生', status: '候诊中' },
{ id: 18, name: '周九', type: '普通', doctor: '王医生', status: '候诊中' },
{ id: 19, name: '吴十', type: '专家', doctor: '张医生', status: '候诊中' },
{ id: 20, name: '郑一', type: '普通', doctor: '李医生', status: '候诊中' },
{ id: 21, name: '王二', type: '专家', doctor: '王医生', status: '候诊中' },
{ id: 22, name: '李三', type: '普通', doctor: '张医生', status: '候诊中' },
{ id: 23, name: '赵四', type: '专家', doctor: '李医生', status: '候诊中' },
{ id: 24, name: '钱五', type: '普通', doctor: '王医生', status: '候诊中' }
]
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 500))
patients.value = mockData
} catch (error) {
console.error('获取候诊数据失败:', error)
ElMessage.error('获取候诊数据失败')
// 出错时设置为空数组
console.error('获取显示屏数据失败:', error)
ElMessage.error('获取显示屏数据失败' + (error.message || '未知错误'))
// 出错时设置默认值
patients.value = []
currentCall.value = { number: null, name: '-', room: '-', doctor: '-' }
waitingCount.value = 0
applyDefaultDepartmentName()
} finally {
loading.value = false
}
@@ -259,40 +346,177 @@ const stopAutoScroll = () => {
}
}
/**
* 初始化 WebSocket 连接
*/
const initWebSocket = () => {
try {
if (!ORGANIZATION_ID.value) {
console.warn('未获取到科室ID跳过 WebSocket 连接')
return
}
console.log('正在连接 WebSocket:', WS_URL.value)
wsConnection.value = new WebSocket(WS_URL.value)
wsConnection.value.onopen = () => {
console.log('WebSocket 连接成功')
ElMessage.success('实时连接已建立')
}
wsConnection.value.onmessage = (event) => {
try {
const message = JSON.parse(event.data)
console.log('收到 WebSocket 消息:', message)
if (message.type === 'connected') {
console.log('WebSocket 连接确认:', message.message)
} else if (message.type === 'update') {
// 收到更新消息,刷新显示屏数据
console.log('收到更新通知,刷新显示屏数据')
handleWebSocketUpdate(message.data)
} else if (message.type === 'pong') {
// 心跳响应
console.log('心跳响应')
}
} catch (error) {
console.error('解析 WebSocket 消息失败:', error)
}
}
wsConnection.value.onerror = (error) => {
console.error('WebSocket 错误:', error)
ElMessage.error('实时连接出现错误')
}
wsConnection.value.onclose = () => {
console.log('WebSocket 连接关闭5秒后重连')
setTimeout(() => {
if (!wsConnection.value || wsConnection.value.readyState === WebSocket.CLOSED) {
initWebSocket()
}
}, 5000)
}
// 心跳检测每30秒发送一次 ping
setInterval(() => {
if (wsConnection.value && wsConnection.value.readyState === WebSocket.OPEN) {
wsConnection.value.send('ping')
}
}, 30000)
} catch (error) {
console.error('初始化 WebSocket 失败:', error)
}
}
/**
* 处理 WebSocket 推送的更新数据
*/
const handleWebSocketUpdate = (data) => {
if (!data) return
// 更新科室名称
if (data.departmentName) {
departmentName.value = data.departmentName
}
// 更新当前叫号信息
if (data.currentCall) {
currentCall.value = data.currentCall
}
// 更新等候队列
if (data.waitingList && Array.isArray(data.waitingList)) {
patients.value = data.waitingList
}
// 更新等待人数
if (data.waitingCount !== undefined) {
waitingCount.value = data.waitingCount
}
console.log('显示屏数据已更新(来自 WebSocket')
// 播放语音(如果有新的叫号)
if (data.currentCall && data.currentCall.number) {
playVoiceNotification(data.currentCall)
}
}
/**
* 播放语音通知
*/
const playVoiceNotification = (callInfo) => {
if (!callInfo || !callInfo.number) return
try {
// 使用 Web Speech API 播放语音
const utterance = new SpeechSynthesisUtterance(
`${callInfo.number}${callInfo.name}${callInfo.room}诊室就诊`
)
utterance.lang = 'zh-CN'
utterance.rate = 0.9 // 语速
utterance.pitch = 1.0 // 音调
utterance.volume = 1.0 // 音量
window.speechSynthesis.speak(utterance)
} catch (error) {
console.error('语音播放失败:', error)
}
}
/**
* 关闭 WebSocket 连接
*/
const closeWebSocket = () => {
if (wsConnection.value) {
wsConnection.value.close()
wsConnection.value = null
console.log('WebSocket 连接已关闭')
}
}
// 生命周期钩子
onMounted(async () => {
document.addEventListener('fullscreenchange', updateFullscreenState)
await ensureUserInfo()
// 初始化时间
updateTime()
// 每分钟更新时间
const timeInterval = setInterval(updateTime, 60000)
timeInterval.value = setInterval(updateTime, 60000)
// 获取候诊数据
await generateWaitingData()
// ✅ 获取初始数据(从后端 API
await fetchDisplayData()
// ✅ 初始化 WebSocket 连接(实时推送)
initWebSocket()
// 启动自动滚动
startAutoScroll()
// 鼠标悬停时暂停自动滚动
const tableContainer = document.querySelector('.table-container')
tableContainer = document.querySelector('.table-container')
if (tableContainer) {
tableContainer.addEventListener('mouseenter', stopAutoScroll)
tableContainer.addEventListener('mouseleave', startAutoScroll)
}
// 组件卸载时清理
onUnmounted(() => {
clearInterval(timeInterval)
stopAutoScroll()
if (tableContainer) {
tableContainer.removeEventListener('mouseenter', stopAutoScroll)
tableContainer.removeEventListener('mouseleave', startAutoScroll)
}
})
})
onUnmounted(() => {
// 组件卸载时的清理工作
if (timeInterval.value) {
clearInterval(timeInterval.value)
timeInterval.value = null
}
stopAutoScroll()
closeWebSocket() // ✅ 关闭 WebSocket 连接
if (tableContainer) {
tableContainer.removeEventListener('mouseenter', stopAutoScroll)
tableContainer.removeEventListener('mouseleave', startAutoScroll)
tableContainer = null
}
document.removeEventListener('fullscreenchange', updateFullscreenState)
document.body.classList.remove('call-screen-fullscreen')
})
// 监听页面变化,重置滚动位置
@@ -317,6 +541,43 @@ watchEffect(() => {
flex-direction: column;
}
:global(body.call-screen-fullscreen) {
overflow: hidden;
background: #f5f7fa;
}
:global(body.call-screen-fullscreen .sidebar-wrapper),
:global(body.call-screen-fullscreen .navbar),
:global(body.call-screen-fullscreen .tags-view-container),
:global(body.call-screen-fullscreen #tags-view-container),
:global(body.call-screen-fullscreen .drawer-bg) {
display: none !important;
}
:global(body.call-screen-fullscreen .app-wrapper),
:global(body.call-screen-fullscreen .main-wrapper),
:global(body.call-screen-fullscreen .content-wrapper),
:global(body.call-screen-fullscreen .app-main) {
width: 100% !important;
height: 100% !important;
margin: 0 !important;
padding: 0 !important;
}
:global(body.call-screen-fullscreen .app-wrapper) {
height: 100vh !important;
}
:global(body.call-screen-fullscreen .call-number-display) {
max-width: none;
width: 100vw;
height: 100vh;
min-height: 100vh;
margin: 0;
border-radius: 0;
box-shadow: none;
}
/* 头部样式 */
.header {
background: linear-gradient(135deg, #4a90e2, #5fa3e8);
@@ -342,6 +603,28 @@ watchEffect(() => {
padding: 5px 15px;
border-radius: 30px;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.fullscreen-btn {
border: 1px solid rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.15);
color: #fff;
padding: 6px 14px;
border-radius: 20px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease;
}
.fullscreen-btn:hover {
background: rgba(255, 255, 255, 0.28);
border-color: rgba(255, 255, 255, 0.9);
}
}
/* 当前呼叫区 */