Files
his/openhis-ui-vue3/src/views/triageandqueuemanage/callnumberdisplay/index.vue
weixin_45799331 b0f2eabf6b sse实时开发
2026-01-27 13:31:03 +08:00

845 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="call-number-display" ref="screenContainer">
<!-- 头部区域 -->
<div class="header">
<h1>{{ departmentName }}</h1>
<div class="header-right">
<button class="fullscreen-btn" @click="toggleFullscreen">
{{ isFullscreen ? '退出全屏' : '全屏' }}
</button>
<div class="time">{{ currentTime }}</div>
</div>
</div>
<!-- 当前呼叫区 -->
<div class="current-call">
<div class="call-box">
<div class="call-text">
<span class="highlight">{{ currentCall?.number || '-' }}</span>
<span class="highlight">{{ currentCall?.name || '-' }}</span>
<span class="highlight">{{ currentCall?.room || '-' }}</span> 诊室就诊
</div>
</div>
</div>
<!-- 候诊信息区 -->
<div class="waiting-area">
<h2 class="section-title">候诊信息</h2>
<div class="table-container" ref="tableContainer">
<table class="waiting-table">
<thead style="position: sticky; top: 0; background: #f0f7ff; z-index: 1;">
<tr>
<th>序号</th>
<th>患者</th>
<th>诊室</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<template v-for="doctorName in paginatedDoctors">
<template v-if="groupedPatients[doctorName]">
<!-- 医生分组标题 -->
<tr class="doctor-header" :key="`doctor-${doctorName}`">
<td colspan="4">{{ doctorName }} 医生 (诊室: {{ getDoctorRoom(doctorName) }})</td>
</tr>
<!-- 患者列表 -->
<tr v-for="(patient, index) in groupedPatients[doctorName]" :key="`${doctorName}-${patient.id}`">
<td>{{ index + 1 }}</td>
<td>{{ patient.name }}</td>
<td>{{ getDoctorRoom(doctorName) }}</td>
<td :style="{ color: patient.status === 'CALLING' ? '#e74c3c' : '#27ae60' }">
{{ patient.status === 'CALLING' ? '就诊中' : '等待' }}
</td>
</tr>
</template>
</template>
</tbody>
</table>
</div>
<!-- 分页控制 -->
<div class="pagination-controls">
<button
id="prevPage"
@click="previousPage"
:disabled="currentPage === 1 || loading"
>上一页</button>
<span id="pageInfo">{{ currentPage }}/{{ totalPages }}</span>
<button
id="nextPage"
@click="nextPage"
:disabled="currentPage === totalPages || loading"
>下一页</button>
</div>
</div>
<!-- 辅助信息区 -->
<div class="info-bar">
<div class="info-item">
<span class="icon"></span>
<span>当前时间: {{ currentTime }}</span>
</div>
<div class="info-item">
<span class="icon">🔢</span>
<span>当前号: {{ currentCall?.number || '-' }}</span>
</div>
<div class="info-item">
<span class="icon">👥</span>
<span>等待人数: {{ waitingCount }}</span>
</div>
</div>
</div>
</template>
<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'
// SSE 地址(走后端 API 代理)
const SSE_URL = computed(() => {
const baseApi = import.meta.env.VITE_APP_BASE_API || ''
const orgId = ORGANIZATION_ID.value
const tenantId = TENANT_ID.value
return `${baseApi}${API_BASE_URL}/display/stream?organizationId=${encodeURIComponent(orgId)}&tenantId=${tenantId}`
})
// 响应式数据
const currentTime = ref('')
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 sseConnection = ref(null) // SSE 连接
const timeInterval = ref(null)
const isFullscreen = ref(false)
const screenContainer = ref(null)
let tableContainer = null
// 科室名称
const departmentName = ref('叫号显示屏幕')
const applyDefaultDepartmentName = () => {
if (userStore.orgName) {
departmentName.value = `${userStore.orgName} 叫号显示屏`
}
}
// 等待总人数(从后端返回)
const waitingCount = ref(0)
// 计算属性:按医生分组的患者列表
const groupedPatients = computed(() => {
const grouped = {}
patients.value.forEach(doctorGroup => {
grouped[doctorGroup.doctorName] = doctorGroup.patients || []
})
return grouped
})
// 获取排序后的医生列表
const sortedDoctors = computed(() => {
return patients.value.map(group => group.doctorName)
})
// 按医生分组的分页逻辑
const paginatedDoctors = computed(() => {
const startIndex = (currentPage.value - 1) * 1 // 每页显示1个医生组
const endIndex = startIndex + 1
return sortedDoctors.value.slice(startIndex, endIndex)
})
// 获取当前页的患者
const currentPatients = computed(() => {
const result = {}
paginatedDoctors.value.forEach(doctor => {
result[doctor] = groupedPatients.value[doctor]
})
return result
})
const totalPages = computed(() => {
return Math.ceil(sortedDoctors.value.length) || 1
})
// 方法
const updateTime = () => {
const now = dayjs()
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 '-'
return name.charAt(0) + '*' + name.slice(-1)
}
const getDoctorRoom = (doctorName) => {
// 从后端数据中查找医生的诊室号
const doctorGroup = patients.value.find(group => group.doctorName === doctorName)
return doctorGroup?.roomNo || '1号'
}
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
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 || '获取数据失败')
}
} catch (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
}
}
const previousPage = () => {
if (currentPage.value > 1) {
currentPage.value--
scrollToTop()
}
}
const nextPage = () => {
if (currentPage.value < totalPages.value) {
currentPage.value++
scrollToTop()
}
}
const scrollToTop = () => {
nextTick(() => {
const container = document.querySelector('.table-container')
if (container && container.scrollTo) {
container.scrollTo({
top: 0,
behavior: 'smooth'
})
}
})
}
const startAutoScroll = () => {
stopAutoScroll()
autoScrollInterval.value = setInterval(() => {
if (currentPage.value < totalPages.value) {
currentPage.value++
} else {
currentPage.value = 1
}
scrollToTop()
}, scrollInterval)
}
const stopAutoScroll = () => {
if (autoScrollInterval.value) {
clearInterval(autoScrollInterval.value)
autoScrollInterval.value = null
}
}
/**
* 初始化 SSE 连接
*/
const initSse = () => {
try {
if (!ORGANIZATION_ID.value) {
console.warn('未获取到科室ID跳过 SSE 连接')
return
}
if (sseConnection.value) {
sseConnection.value.close()
}
console.log('正在连接 SSE:', SSE_URL.value)
sseConnection.value = new EventSource(SSE_URL.value)
sseConnection.value.onopen = () => {
console.log('SSE 连接成功')
ElMessage.success('实时连接已建立')
}
sseConnection.value.onmessage = (event) => {
try {
const message = JSON.parse(event.data)
console.log('收到 SSE 消息:', message)
if (message.type === 'init') {
handleSseUpdate(message.data)
} else if (message.type === 'update') {
handleSseUpdate(message.data)
}
} catch (error) {
console.error('解析 SSE 消息失败:', error)
}
}
sseConnection.value.onerror = (error) => {
console.error('SSE 错误:', error)
ElMessage.error('实时连接出现错误')
}
} catch (error) {
console.error('初始化 SSE 失败:', error)
}
}
/**
* 处理 SSE 推送的更新数据
*/
const handleSseUpdate = (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('显示屏数据已更新(来自 SSE')
// 播放语音(如果有新的叫号)
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)
}
}
/**
* 关闭 SSE 连接
*/
const closeSse = () => {
if (sseConnection.value) {
sseConnection.value.close()
sseConnection.value = null
console.log('SSE 连接已关闭')
}
}
// 生命周期钩子
onMounted(async () => {
document.addEventListener('fullscreenchange', updateFullscreenState)
await ensureUserInfo()
// 初始化时间
updateTime()
// 每分钟更新时间
timeInterval.value = setInterval(updateTime, 60000)
// ✅ 获取初始数据(从后端 API
await fetchDisplayData()
// ✅ 初始化 SSE 连接(实时推送)
initSse()
// 启动自动滚动
startAutoScroll()
// 鼠标悬停时暂停自动滚动
tableContainer = document.querySelector('.table-container')
if (tableContainer) {
tableContainer.addEventListener('mouseenter', stopAutoScroll)
tableContainer.addEventListener('mouseleave', startAutoScroll)
}
})
onUnmounted(() => {
// 组件卸载时的清理工作
if (timeInterval.value) {
clearInterval(timeInterval.value)
timeInterval.value = null
}
stopAutoScroll()
closeSse() // ✅ 关闭 SSE 连接
if (tableContainer) {
tableContainer.removeEventListener('mouseenter', stopAutoScroll)
tableContainer.removeEventListener('mouseleave', startAutoScroll)
tableContainer = null
}
document.removeEventListener('fullscreenchange', updateFullscreenState)
document.body.classList.remove('call-screen-fullscreen')
})
// 监听页面变化,重置滚动位置
watchEffect(() => {
scrollToTop()
})
</script>
<style lang="scss" scoped>
.call-number-display {
width: 100%;
max-width: 1200px;
background-color: #fff;
border-radius: 16px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.05);
overflow: hidden;
border: 1px solid #eaeaea;
margin: 0 auto;
padding: 20px;
min-height: 100vh;
display: flex;
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);
color: white;
padding: 20px 30px;
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 12px;
margin-bottom: 20px;
h1 {
font-size: 2rem;
font-weight: 600;
letter-spacing: 1px;
margin: 0;
}
.time {
font-size: 1.5rem;
font-weight: 500;
background: rgba(255, 255, 255, 0.2);
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);
}
}
/* 当前呼叫区 */
.current-call {
text-align: center;
background: #f8f9fa;
padding: 20px;
border-radius: 12px;
margin-bottom: 20px;
.call-box {
max-width: 800px;
margin: 0 auto;
padding: 20px;
background: white;
border-radius: 12px;
box-shadow: 0 5px 20px rgba(74, 144, 226, 0.15);
border: 1px solid #e0e7ff;
animation: pulse 2s infinite;
.call-text {
font-size: 2.2rem;
font-weight: 700;
color: #4a90e2;
letter-spacing: 2px;
}
}
}
/* 候诊信息区 */
.waiting-area {
flex: 1;
padding: 0;
margin-bottom: 20px;
.section-title {
font-size: 1.4rem;
color: #555;
margin-bottom: 20px;
padding-left: 10px;
border-left: 4px solid #4a90e2;
font-weight: 600;
}
.table-container {
max-height: 400px;
overflow-y: auto;
scroll-behavior: smooth;
border-radius: 10px;
border: 1px solid #eaeaea;
}
.waiting-table {
width: 100%;
border-collapse: collapse;
background: white;
th,
.doctor-header td {
background-color: #f0f7ff;
color: #4a90e2;
font-weight: 600;
text-align: left;
padding: 15px 20px;
font-size: 1.1rem;
}
td {
padding: 15px 20px;
border-bottom: 1px solid #f0f0f0;
font-size: 1.05rem;
}
tr:nth-child(even) {
background-color: #fafcff;
}
tr:hover {
background-color: #f0f7ff;
}
.doctor-header {
background-color: #f0f7ff !important;
font-weight: bold;
}
}
.pagination-controls {
display: flex;
justify-content: center;
margin-top: 15px;
gap: 10px;
button {
padding: 8px 16px;
border: 1px solid #eaeaea;
border-radius: 4px;
background: #f8f9fa;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
&:hover:not(:disabled) {
background: #e9ecef;
border-color: #4a90e2;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
span {
padding: 8px 16px;
background: #f8f9fa;
border-radius: 4px;
font-weight: 500;
}
}
}
/* 辅助信息区 */
.info-bar {
background: #2c3e50;
color: white;
padding: 15px 20px;
display: flex;
justify-content: space-between;
border-radius: 12px;
.info-item {
display: flex;
align-items: center;
font-size: 1.1rem;
.icon {
margin-right: 8px;
}
span {
margin-left: 10px;
font-weight: 500;
}
}
}
/* 动画效果 */
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(74, 144, 226, 0.4);
}
70% {
box-shadow: 0 0 0 15px rgba(74, 144, 226, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(74, 144, 226, 0);
}
}
.highlight {
color: #e74c3c;
font-weight: bold;
}
/* 响应式设计 */
@media (max-width: 768px) {
.call-number-display {
padding: 10px;
margin: 0;
}
.header {
flex-direction: column;
text-align: center;
gap: 10px;
padding: 15px 20px;
h1 {
font-size: 1.5rem;
}
.time {
font-size: 1.2rem;
}
}
.current-call {
padding: 15px;
.call-box {
padding: 15px;
.call-text {
font-size: 1.8rem;
}
}
}
.info-bar {
flex-direction: column;
gap: 10px;
text-align: center;
.info-item {
justify-content: center;
}
}
.waiting-table {
th, td {
padding: 10px 15px;
font-size: 0.9rem;
}
}
}
@media (max-width: 480px) {
.header h1 {
font-size: 1.2rem;
}
.current-call .call-box .call-text {
font-size: 1.5rem;
letter-spacing: 1px;
}
.waiting-table th,
.waiting-table td {
padding: 8px 12px;
font-size: 0.8rem;
}
}
</style>