1. 项目背景与需求分析
在医疗信息化系统中,患者叫号窗口是一个典型的高频交互场景。传统的叫号系统往往采用静态页面或简单的DOM操作,存在以下痛点:
- 状态同步困难:多个终端(如医生工作站、候诊区大屏、移动端)需要实时显示相同叫号信息
- 交互体验生硬:叫号时的动画效果、语音提示等难以与页面元素状态保持同步
- 业务耦合度高:叫号逻辑与显示逻辑混杂,难以适应不同科室的个性化需求
基于Vue的组件化方案能有效解决这些问题。我们设计的动态患者叫号窗口需要实现:
- 多终端实时状态同步(WebSocket)
- 可配置的叫号规则(普通号、急诊号、复诊号优先级策略)
- 响应式布局适配不同显示设备
- 完整的叫号生命周期管理(等待、呼叫、过号、重呼、完成)
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心组件架构设计
2.1 组件分层模型
采用"容器组件+展示组件"的经典模式:
code复制CallingSystem (容器组件)
├── QueueManager (队列管理)
├── DisplayBoard (主显示板)
│ ├── CurrentNumber (当前号码)
│ ├── WaitingList (等候队列)
│ └── Announcement (语音播报)
└── Controller (控制面板)
2.2 状态管理方案
使用Vuex管理核心状态:
javascript复制// store/modules/calling.js
const state = {
current: null, // 当前就诊患者
queue: [], // 等候队列
history: [], // 已就诊记录
settings: { // 叫号规则
emergencyFirst: true,
recallInterval: 300000 // 重呼间隔(5分钟)
}
}
const mutations = {
ADD_TO_QUEUE(state, patient) {
// 根据急诊标志自动排序
if(patient.isEmergency) {
state.queue.unshift(patient)
} else {
state.queue.push(patient)
}
},
CALL_NEXT(state) {
const next = state.queue.shift()
state.current = next
state.history.push(next)
}
}
2.3 实时通信实现
采用Socket.IO实现多终端同步:
javascript复制// src/utils/socket.js
import io from 'socket.io-client'
const socket = io(process.env.VUE_APP_SOCKET_ENDPOINT, {
reconnectionAttempts: 5,
transports: ['websocket']
})
export const initSocket = (store) => {
socket.on('queue_update', (data) => {
store.commit('calling/UPDATE_QUEUE', data)
})
socket.on('call_next', (patient) => {
store.commit('calling/SET_CURRENT', patient)
// 触发语音播报
store.dispatch('announce/call', patient)
})
}
3. 关键功能实现细节
3.1 动态队列排序算法
实现智能叫号策略的核心逻辑:
javascript复制// src/utils/queueSorter.js
export function sortQueue(queue) {
return [...queue].sort((a, b) => {
// 急诊优先
if(a.isEmergency !== b.isEmergency) {
return a.isEmergency ? -1 : 1
}
// VIP次优先
if(a.isVip !== b.isVip) {
return a.isVip ? -1 : 1
}
// 最后按挂号时间
return new Date(a.registerTime) - new Date(b.registerTime)
})
}
3.2 叫号动画实现
使用Vue的过渡系统+CSS动画:
vue复制<template>
<transition name="call" @after-enter="onEnter">
<div v-if="current" class="number-display">
<span class="number">{{ current.number }}</span>
<span class="name">{{ current.name }}</span>
</div>
</transition>
</template>
<style>
.call-enter-active {
animation: bounce-in 0.5s;
}
.call-leave-active {
animation: bounce-out 0.5s;
}
@keyframes bounce-in {
0% { transform: scale(0.9); opacity: 0; }
50% { transform: scale(1.05); }
100% { transform: scale(1); opacity: 1; }
}
</style>
3.3 多端同步策略
解决网络不稳定的同步机制:
- 状态快照:每次队列变更时广播完整队列快照
- 操作确认:关键操作(如叫号)需要收到确认回执
- 本地缓存:使用localStorage保存最后已知状态
javascript复制// 在Vuex action中
async callNext({ commit, state }) {
try {
const res = await socket.emitWithAck('request_call_next')
if(res.success) {
commit('CALL_NEXT')
}
} catch(e) {
// 降级处理:显示离线提示但仍允许本地叫号
commit('SET_OFFLINE_MODE', true)
commit('CALL_NEXT')
}
}
4. 性能优化实践
4.1 虚拟滚动长列表
对于候诊人数多的科室,采用虚拟滚动技术:
vue复制<template>
<RecycleScroller
class="waiting-list"
:items="sortedQueue"
:item-size="54"
key-field="id"
>
<template v-slot="{ item }">
<div class="patient-item">
<span :class="['number', { emergency: item.isEmergency }]">
{{ item.number }}
</span>
<span class="name">{{ item.name }}</span>
</div>
</template>
</RecycleScroller>
</template>
4.2 Web Worker语音合成
将语音播报移入Web Worker避免阻塞主线程:
javascript复制// public/tts.worker.js
self.onmessage = (e) => {
const { text, voice } = e.data
const utterance = new SpeechSynthesisUtterance(text)
utterance.voice = speechSynthesis.getVoices().find(v => v.name === voice)
speechSynthesis.speak(utterance)
}
4.3 内存泄漏防护
在组件销毁时清理资源:
javascript复制export default {
// ...
beforeDestroy() {
this.unsubscribe()
window.removeEventListener('beforeunload', this.saveState)
this.ttsWorker.terminate()
},
methods: {
initSocket() {
this.unsubscribe = store.subscribeAction((action) => {
if(action.type === 'calling/callNext') {
this.playSound()
}
})
}
}
}
5. 实际部署经验
5.1 不同环境适配
-
大屏显示:增加字体大小检测逻辑
javascript复制const isLargeScreen = window.matchMedia('(min-width: 1920px)').matches document.documentElement.style.fontSize = isLargeScreen ? '20px' : '16px' -
打印模式:提供打印友好样式
css复制@media print { .no-print { display: none; } .print-header { display: block; } }
5.2 监控与调试
-
Vue DevTools定制面板:
javascript复制// 在开发环境注入 if(process.env.NODE_ENV === 'development') { window.__VUE_DEVTOOLS_GLOBAL_HOOK__.emit('init', Vue) } -
性能标记:
javascript复制export function markPerf(name) { if(window.performance && performance.mark) { performance.mark(`start_${name}`) return () => { performance.mark(`end_${name}`) performance.measure(name, `start_${name}`, `end_${name}`) } } return () => {} }
5.3 安全注意事项
-
患者信息脱敏:
javascript复制export function anonymize(patient) { return { ...patient, name: patient.name.charAt(0) + '**', idCard: patient.idCard.replace(/^(.{4}).*(.{4})$/, '$1****$2') } } -
API请求加密:
javascript复制import CryptoJS from 'crypto-js' export const encryptData = (data, secret) => { return CryptoJS.AES.encrypt( JSON.stringify(data), secret ).toString() }
6. 扩展功能实现
6.1 多语言支持
基于Vue I18n的动态切换:
javascript复制// locales/en.js
export default {
calling: {
nowCalling: 'Now calling',
pleaseGoTo: 'Please go to',
room: 'Room'
}
}
// 在组件中
<template>
<div class="announcement">
{{ $t('calling.nowCalling') }}: {{ current.number }}
{{ $t('calling.pleaseGoTo') }} {{ $t('calling.room') }} {{ current.room }}
</div>
</template>
6.2 智能预测等待时间
基于历史数据的算法:
javascript复制export function estimateWaitTime(queue, history) {
const avgTimes = history
.slice(-20) // 取最近20条记录
.map(p => p.endTime - p.startTime)
const avgDuration = avgTimes.reduce((sum, t) => sum + t, 0) / avgTimes.length
return queue.map((patient, index) => ({
...patient,
waitTime: Math.round(index * avgDuration / 60000) // 转为分钟
}))
}
6.3 三维可视化效果
使用Three.js集成:
javascript复制import * as THREE from 'three'
export function init3DBoard(canvas) {
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(75, canvas.width/canvas.height, 0.1, 1000)
const renderer = new THREE.WebGLRenderer({ canvas })
renderer.setSize(canvas.width, canvas.height)
const geometry = new THREE.BoxGeometry()
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 })
const cube = new THREE.Mesh(geometry, material)
scene.add(cube)
camera.position.z = 5
function animate() {
requestAnimationFrame(animate)
cube.rotation.x += 0.01
cube.rotation.y += 0.01
renderer.render(scene, camera)
}
animate()
}
关键提示:在医疗场景中使用三维效果需谨慎评估硬件性能,建议仅在高端设备上启用此功能
7. 测试策略与质量保障
7.1 单元测试重点
-
队列排序逻辑:
javascript复制test('should prioritize emergency cases', () => { const queue = [ { id: 1, isEmergency: false }, { id: 2, isEmergency: true } ] const sorted = sortQueue(queue) expect(sorted[0].id).toBe(2) }) -
状态变更测试:
javascript复制test('should add patient to queue', () => { const state = { queue: [] } mutations.ADD_TO_QUEUE(state, { id: 101 }) expect(state.queue).toHaveLength(1) })
7.2 E2E测试场景
使用Cypress模拟完整流程:
javascript复制describe('Calling System', () => {
it('should complete calling flow', () => {
cy.visit('/')
cy.get('[data-test="add-patient"]').click()
cy.get('[data-test="patient-name"]').type('John Doe')
cy.get('[data-test="submit"]').click()
cy.get('[data-test="call-next"]').click()
cy.get('[data-test="current-number"]').should('contain', 'John Doe')
})
})
7.3 压力测试方案
使用Artillery模拟高并发:
yaml复制config:
target: "http://localhost:8080"
phases:
- duration: 60
arrivalRate: 50
scenarios:
- flow:
- get:
url: "/"
- post:
url: "/api/call"
json:
patientId: "{{ $random.uuid }}"
8. 项目部署与持续集成
8.1 Docker化部署
dockerfile复制# Dockerfile
FROM node:16 as builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
8.2 CI/CD配置示例
GitLab CI配置:
yaml复制# .gitlab-ci.yml
stages:
- test
- build
- deploy
unit-test:
stage: test
image: node:16
script:
- npm ci
- npm test
build-production:
stage: build
image: node:16
script:
- npm ci
- npm run build
artifacts:
paths:
- dist/
deploy-prod:
stage: deploy
image: alpine
needs: ["build-production"]
script:
- apk add rsync openssh
- rsync -avz dist/ user@prod-server:/var/www/calling-system/
8.3 灰度发布策略
基于cookie的流量分配:
javascript复制// router.js
router.beforeEach((to, from, next) => {
if(process.env.VUE_APP_FEATURE_TOGGLE === 'true') {
const cookie = document.cookie.match(/version=([^;]+)/)
const version = cookie ? cookie[1] : 'v1'
if(version === 'v2' && to.path === '/new-calling') {
next()
} else {
next('/legacy-calling')
}
} else {
next()
}
})
9. 项目演进与反思
在实际部署过程中,我们遇到了几个关键挑战:
-
急诊插队时的状态同步:最初的设计在急诊患者插入时会导致部分终端显示不同步。解决方案是引入操作日志和版本号机制,每个状态变更都附带递增版本号,终端发现版本不一致时主动请求全量同步。
-
语音播报延迟问题:在低端设备上,语音合成会导致界面卡顿。最终采用Web Worker方案,并将语音文件预加载到内存中。
-
长时间运行的性能衰减:大屏设备需要7x24小时运行,通过以下措施保持稳定:
- 定时强制刷新关键组件
- 内存使用监控和自动清理
- 增加硬件看门狗机制
组件设计中的几个成功决策:
-
将叫号规则抽象为策略模式:允许不同科室通过配置使用不同的排序算法,例如:
javascript复制// 儿科策略:优先儿童患者 export const pediatricStrategy = (a, b) => { if(a.isChild !== b.isChild) { return a.isChild ? -1 : 1 } return defaultStrategy(a, b) } -
采用事件溯源架构:所有状态变更都通过事件触发,便于:
- 调试时重现问题
- 实现撤销/重做功能
- 生成完整的操作审计日志
-
设计响应式尺寸系统:使用CSS变量和rem单位,使同一套代码能完美适配从手机到100寸大屏的各种设备:
css复制:root { --base-font-size: calc(10px + 0.3vw); --card-width: calc(120px + 10vw); }
对于未来迭代,计划增加:
- 基于人脸识别的患者到达检测
- 智能排队推荐算法(根据历史等待时间预测最佳就诊时段)
- AR导航指引系统(通过手机摄像头引导患者到诊室)
