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

@@ -21,7 +21,7 @@ public final class ServiceException extends RuntimeException {
/** /**
* 错误明细,内部调试错误 * 错误明细,内部调试错误
* *
* 和 {@link CommonResult#getDetailMessage()} 一致的设计 * 和
*/ */
private String detailMessage; private String detailMessage;

View File

@@ -103,6 +103,9 @@ public class SecurityConfig {
// 静态资源,可匿名访问 // 静态资源,可匿名访问
.antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**") .antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**")
.permitAll() .permitAll()
// WebSocket 握手请求允许匿名访问
.antMatchers("/ws/**", "/test-ws")
.permitAll()
.antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**") .antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**")
.permitAll() .permitAll()
.antMatchers("/patientmanage/information/**") .antMatchers("/patientmanage/information/**")

View File

@@ -69,6 +69,13 @@
<groupId>org.apache.velocity</groupId> <groupId>org.apache.velocity</groupId>
<artifactId>velocity-engine-core</artifactId> <artifactId>velocity-engine-core</artifactId>
</dependency> </dependency>
<!-- WebSocket 支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- rabbitMQ --> <!-- rabbitMQ -->
<!-- <dependency> <!-- <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>

View File

@@ -1,6 +1,7 @@
package com.openhis.web.triageandqueuemanage.appservice; package com.openhis.web.triageandqueuemanage.appservice;
import com.core.common.core.domain.R; import com.core.common.core.domain.R;
import com.openhis.web.triageandqueuemanage.dto.CallNumberDisplayResp;
import com.openhis.web.triageandqueuemanage.dto.TriageQueueActionReq; import com.openhis.web.triageandqueuemanage.dto.TriageQueueActionReq;
import com.openhis.web.triageandqueuemanage.dto.TriageQueueAddReq; import com.openhis.web.triageandqueuemanage.dto.TriageQueueAddReq;
import com.openhis.web.triageandqueuemanage.dto.TriageQueueAdjustReq; import com.openhis.web.triageandqueuemanage.dto.TriageQueueAdjustReq;
@@ -22,6 +23,9 @@ public interface TriageQueueAppService {
R<?> skip(TriageQueueActionReq req); R<?> skip(TriageQueueActionReq req);
/** 下一患者:当前叫号中 -> 完成,下一位等待 -> 叫号中 */ /** 下一患者:当前叫号中 -> 完成,下一位等待 -> 叫号中 */
R<?> next(TriageQueueActionReq req); R<?> next(TriageQueueActionReq req);
/** 叫号显示屏:获取当前叫号和等候队列信息 */
CallNumberDisplayResp getDisplayData(Long organizationId, LocalDate date, Integer tenantId);
} }

View File

@@ -10,19 +10,20 @@ import com.openhis.triageandqueuemanage.domain.TriageCandidateExclusion;
import com.openhis.triageandqueuemanage.service.TriageQueueItemService; import com.openhis.triageandqueuemanage.service.TriageQueueItemService;
import com.openhis.triageandqueuemanage.service.TriageCandidateExclusionService; import com.openhis.triageandqueuemanage.service.TriageCandidateExclusionService;
import com.openhis.web.triageandqueuemanage.appservice.TriageQueueAppService; import com.openhis.web.triageandqueuemanage.appservice.TriageQueueAppService;
import com.openhis.web.triageandqueuemanage.dto.CallNumberDisplayResp;
import com.openhis.web.triageandqueuemanage.dto.TriageQueueActionReq; import com.openhis.web.triageandqueuemanage.dto.TriageQueueActionReq;
import com.openhis.web.triageandqueuemanage.dto.TriageQueueAddReq; import com.openhis.web.triageandqueuemanage.dto.TriageQueueAddReq;
import com.openhis.web.triageandqueuemanage.dto.TriageQueueAdjustReq; import com.openhis.web.triageandqueuemanage.dto.TriageQueueAdjustReq;
import com.openhis.web.triageandqueuemanage.dto.TriageQueueEncounterItem; import com.openhis.web.triageandqueuemanage.dto.TriageQueueEncounterItem;
import com.openhis.web.triageandqueuemanage.websocket.CallNumberWebSocket;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Comparator; import java.util.*;
import java.util.List; import java.util.stream.Collectors;
import java.util.Objects;
@Service @Service
public class TriageQueueAppServiceImpl implements TriageQueueAppService { public class TriageQueueAppServiceImpl implements TriageQueueAppService {
@@ -121,6 +122,8 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
.setPatientName(it.getPatientName()) .setPatientName(it.getPatientName())
.setHealthcareName(it.getHealthcareName()) .setHealthcareName(it.getHealthcareName())
.setPractitionerName(it.getPractitionerName()) .setPractitionerName(it.getPractitionerName())
.setPractitionerId(it.getPractitionerId()) // ✅ 新增字段(可选)
.setRoomNo(it.getRoomNo()) // ✅ 新增字段(可选)
.setStatus(STATUS_WAITING) .setStatus(STATUS_WAITING)
.setQueueOrder(++maxOrder) .setQueueOrder(++maxOrder)
.setDeleteFlag("0") .setDeleteFlag("0")
@@ -238,6 +241,10 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
if (STATUS_WAITING.equals(selected.getStatus())) { if (STATUS_WAITING.equals(selected.getStatus())) {
selected.setStatus(STATUS_CALLING).setUpdateTime(LocalDateTime.now()); selected.setStatus(STATUS_CALLING).setUpdateTime(LocalDateTime.now());
triageQueueItemService.updateById(selected); triageQueueItemService.updateById(selected);
// ✅ 叫号后推送 WebSocket 消息
pushDisplayUpdate(selected.getOrganizationId(), selected.getQueueDate(), selected.getTenantId());
return R.ok(true); return R.ok(true);
} else if (STATUS_CALLING.equals(selected.getStatus())) { } else if (STATUS_CALLING.equals(selected.getStatus())) {
// 如果已经是"叫号中"状态,直接返回成功(不做任何操作) // 如果已经是"叫号中"状态,直接返回成功(不做任何操作)
@@ -321,6 +328,10 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
} }
recalcOrders(actualOrgId, null); recalcOrders(actualOrgId, null);
// ✅ 完成后推送 WebSocket 消息
pushDisplayUpdate(actualOrgId, calling.getQueueDate(), tenantId);
return R.ok(true); return R.ok(true);
} }
@@ -413,6 +424,10 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
triageQueueItemService.updateById(next); triageQueueItemService.updateById(next);
recalcOrders(actualOrgId, null); recalcOrders(actualOrgId, null);
// ✅ 过号重排后推送 WebSocket 消息
pushDisplayUpdate(actualOrgId, calling.getQueueDate(), tenantId);
return R.ok(true); return R.ok(true);
} }
@@ -551,6 +566,179 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
i++; i++;
} }
} }
/**
* 获取叫号显示屏数据
* @param organizationId 科室ID
* @param date 日期
* @param tenantId 租户ID
* @return 显示屏数据
*/
@Override
public CallNumberDisplayResp getDisplayData(Long organizationId, LocalDate date, Integer tenantId) {
// 如果没有传入租户ID尝试从登录用户获取否则默认为1
if (tenantId == null) {
try {
tenantId = SecurityUtils.getLoginUser().getTenantId();
} catch (Exception e) {
tenantId = 1; // 默认租户ID
}
}
LocalDate qd = date != null ? date : LocalDate.now();
/**
* 查询所有队列项WAITING 和 CALLING 状态)某天的某个科室的某个状态
*
*/
List<TriageQueueItem> allItems = triageQueueItemService.list(
new LambdaQueryWrapper<TriageQueueItem>()
.eq(TriageQueueItem::getQueueDate, qd)
.eq(TriageQueueItem::getOrganizationId, organizationId)
.eq(TriageQueueItem::getTenantId, tenantId)
.in(TriageQueueItem::getStatus, STATUS_WAITING, STATUS_CALLING)
.eq(TriageQueueItem::getDeleteFlag, "0")
.orderByAsc(TriageQueueItem::getQueueOrder)
);
CallNumberDisplayResp resp = new CallNumberDisplayResp();
// 1. 获取科室名称(从第一条数据中取)
if (!allItems.isEmpty()) {
resp.setDepartmentName(allItems.get(0).getOrganizationName() + " 叫号显示屏");
} else {
resp.setDepartmentName("叫号显示屏");
}
// 2. 查找当前叫号中的患者CALLING 状态)
TriageQueueItem callingItem = allItems.stream()
.filter(item -> STATUS_CALLING.equals(item.getStatus()))
.findFirst()
.orElse(null);
if (callingItem != null) {
CallNumberDisplayResp.CurrentCallInfo currentCall = new CallNumberDisplayResp.CurrentCallInfo();
currentCall.setNumber(callingItem.getQueueOrder());
currentCall.setName(maskPatientName(callingItem.getPatientName()));
currentCall.setRoom(callingItem.getRoomNo() != null ? callingItem.getRoomNo() : "1号");
currentCall.setDoctor(callingItem.getPractitionerName());
resp.setCurrentCall(currentCall);
} else {
// 没有叫号中的患者,返回默认值
CallNumberDisplayResp.CurrentCallInfo currentCall = new CallNumberDisplayResp.CurrentCallInfo();
currentCall.setNumber(null);
currentCall.setName("-");
currentCall.setRoom("-");
currentCall.setDoctor("-");
resp.setCurrentCall(currentCall);
}
// 3. 按医生分组(包括 CALLING 和 WAITING 状态)
Map<Long, List<TriageQueueItem>> groupedByDoctor = allItems.stream()
// 严格按医生分组:仅保留有 practitionerId 的记录
.filter(item -> item.getPractitionerId() != null)
.collect(Collectors.groupingBy(TriageQueueItem::getPractitionerId));
// 每个医生的等待队列
List<CallNumberDisplayResp.DoctorGroup> waitingList = new ArrayList<>();
int totalWaiting = 0;
for (Map.Entry<Long, List<TriageQueueItem>> entry : groupedByDoctor.entrySet()) {
List<TriageQueueItem> doctorItems = entry.getValue();
String doctorName = doctorItems.get(0).getPractitionerName();
if (doctorName == null || doctorName.isEmpty()) {
doctorName = "未分配";
}
// 按排队顺序排序
doctorItems.sort(Comparator.comparing(TriageQueueItem::getQueueOrder));
// 该医生 下边的患者列表 和 诊室号
CallNumberDisplayResp.DoctorGroup doctorGroup = new CallNumberDisplayResp.DoctorGroup();
doctorGroup.setDoctorName(doctorName);
// 获取诊室号(从该医生的任一患者中取)
String roomNo = doctorItems.stream()
.map(TriageQueueItem::getRoomNo)
.filter(Objects::nonNull)
.findFirst()
.orElse("1号");
doctorGroup.setRoomNo(roomNo);
// 转换患者列表
List<CallNumberDisplayResp.PatientInfo> patients = new ArrayList<>();
for (TriageQueueItem item : doctorItems) {
CallNumberDisplayResp.PatientInfo patient = new CallNumberDisplayResp.PatientInfo();
patient.setId(item.getId());
patient.setName(maskPatientName(item.getPatientName()));
patient.setStatus(item.getStatus());
patient.setQueueOrder(item.getQueueOrder());
patients.add(patient);
// 统计等待人数(不包括 CALLING 状态)
if (STATUS_WAITING.equals(item.getStatus())) {
totalWaiting++;
}
}
doctorGroup.setPatients(patients);
waitingList.add(doctorGroup);
}
// 按医生名称排序
waitingList.sort(Comparator.comparing(CallNumberDisplayResp.DoctorGroup::getDoctorName));
resp.setWaitingList(waitingList);
resp.setWaitingCount(totalWaiting);
return resp;
}
/**
* 患者姓名脱敏处理
* @param name 原始姓名
* @return 脱敏后的姓名(如:张*三)
*/
private String maskPatientName(String name) {
if (name == null || name.isEmpty()) {
return "-";
}
if (name.length() == 1) {
return name;
}
if (name.length() == 2) {
return name.charAt(0) + "*";
}
// 3个字及以上保留首尾中间用*代替
return name.charAt(0) + "*" + name.charAt(name.length() - 1);
}
/**
* 推送显示屏更新消息到 WebSocket
* @param organizationId 科室ID
* @param queueDate 队列日期
* @param tenantId 租户ID
*/
private void pushDisplayUpdate(Long organizationId, LocalDate queueDate, Integer tenantId) {
try {
// 获取最新的显示屏数据
CallNumberDisplayResp displayData = getDisplayData(organizationId, queueDate, tenantId);
// 构造推送消息
Map<String, Object> message = new HashMap<>();
message.put("type", "update");
message.put("action", "queue_changed");
message.put("data", displayData);
message.put("timestamp", System.currentTimeMillis());
// 推送到该科室的所有 WebSocket 连接
CallNumberWebSocket.pushToOrganization(organizationId, message);
} catch (Exception e) {
// WebSocket 推送失败不应该影响业务逻辑
System.err.println("推送显示屏更新失败:" + e.getMessage());
}
}
} }

View File

@@ -1,12 +1,16 @@
package com.openhis.web.triageandqueuemanage.controller; package com.openhis.web.triageandqueuemanage.controller;
import com.core.common.annotation.Anonymous;
import com.core.common.core.domain.R; import com.core.common.core.domain.R;
import com.core.common.utils.SecurityUtils;
import com.openhis.web.triageandqueuemanage.appservice.TriageQueueAppService; import com.openhis.web.triageandqueuemanage.appservice.TriageQueueAppService;
import com.openhis.web.triageandqueuemanage.dto.CallNumberDisplayResp;
import com.openhis.web.triageandqueuemanage.dto.TriageQueueActionReq; import com.openhis.web.triageandqueuemanage.dto.TriageQueueActionReq;
import com.openhis.web.triageandqueuemanage.dto.TriageQueueAddReq; import com.openhis.web.triageandqueuemanage.dto.TriageQueueAddReq;
import com.openhis.web.triageandqueuemanage.dto.TriageQueueAdjustReq; import com.openhis.web.triageandqueuemanage.dto.TriageQueueAdjustReq;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource; import javax.annotation.Resource;
@@ -65,6 +69,62 @@ public class TriageQueueController {
public R<?> next(@RequestBody(required = false) TriageQueueActionReq req) { public R<?> next(@RequestBody(required = false) TriageQueueActionReq req) {
return triageQueueAppService.next(req); return triageQueueAppService.next(req);
} }
/**
* 叫号显示屏:获取当前叫号和等候队列信息
* @param organizationId 科室ID
* @param date 日期(可选,默认今天)
* @param tenantId 租户ID可选默认1
* @return 显示屏数据
*/
@Anonymous // 显示屏不需要登录
@GetMapping("/display")
public R<CallNumberDisplayResp> getDisplayData(
@RequestParam(required = false) String organizationId,
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date,
@RequestParam(required = false) Integer tenantId
) {
try {
Long orgId = resolveOrganizationId(organizationId);
if (orgId == null) {
return R.fail("organizationId参数不合法或未获取到登录用户科室");
}
Integer actualTenantId = resolveTenantId(tenantId);
CallNumberDisplayResp data = triageQueueAppService.getDisplayData(orgId, date, actualTenantId);
return R.ok(data);
} catch (Exception e) {
log.error("获取显示屏数据失败", e);
return R.fail("获取显示屏数据失败:" + e.getMessage());
}
}
private Long resolveOrganizationId(String organizationId) {
if (!StringUtils.hasText(organizationId)) {
try {
return SecurityUtils.getLoginUser().getOrgId();
} catch (Exception e) {
return null;
}
}
try {
return Long.parseLong(organizationId.trim());
} catch (NumberFormatException e) {
log.warn("非法organizationId: {}", organizationId);
return null;
}
}
private Integer resolveTenantId(Integer tenantId) {
if (tenantId != null) {
return tenantId;
}
try {
Integer loginTenantId = SecurityUtils.getLoginUser().getTenantId();
return loginTenantId != null ? loginTenantId : 1;
} catch (Exception e) {
return 1;
}
}
} }

View File

@@ -9,6 +9,12 @@ public class TriageQueueEncounterItem {
private String patientName; private String patientName;
private String healthcareName; private String healthcareName;
private String practitionerName; private String practitionerName;
// ========== 新增字段(可选,用于叫号显示屏)==========
/** 医生ID可选 */
private Long practitionerId;
/** 诊室号(可选) */
private String roomNo;
} }

View File

@@ -18,21 +18,23 @@ public class TriageQueueItem {
@TableId(type = IdType.AUTO) @TableId(type = IdType.AUTO)
private Long id; private Long id;
private Integer tenantId; private Integer tenantId; // 租户ID
private LocalDate queueDate; private LocalDate queueDate; // 队列日期
private Long organizationId; private Long organizationId; // 科室ID
private String organizationName; private String organizationName; // 科室名称
private Long encounterId; private Long encounterId;
private Long patientId; private Long patientId; // 患者ID
private String patientName; private String patientName; // 患者姓名(脱敏)
private String healthcareName; private String healthcareName; // 挂号类型(普通/专家)
private String practitionerName; private String practitionerName; // 医生姓名
private Long practitionerId; // 医生ID新增字段
private String roomNo; // 诊室号(新增字段)
/** WAITING / CALLING / SKIPPED / COMPLETED */ /** WAITING / CALLING / SKIPPED / COMPLETED */
private String status; private String status;
private Integer queueOrder; private Integer queueOrder; //“排队序号”,也就是患者在当前科室、当天队列里的 顺序号(从 1 开始递增)。
private LocalDateTime createTime; private LocalDateTime createTime;
private LocalDateTime updateTime; private LocalDateTime updateTime;

View File

@@ -1,9 +1,14 @@
<template> <template>
<div class="call-number-display"> <div class="call-number-display" ref="screenContainer">
<!-- 头部区域 --> <!-- 头部区域 -->
<div class="header"> <div class="header">
<h1>{{ departmentName }}</h1> <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> </div>
<!-- 当前呼叫区 --> <!-- 当前呼叫区 -->
@@ -31,19 +36,19 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<template v-for="doctorName in paginatedDoctors" :key="doctorName"> <template v-for="doctorName in paginatedDoctors">
<template v-if="groupedPatients[doctorName]"> <template v-if="groupedPatients[doctorName]">
<!-- 医生分组标题 --> <!-- 医生分组标题 -->
<tr class="doctor-header"> <tr class="doctor-header" :key="`doctor-${doctorName}`">
<td colspan="4">{{ doctorName }} 医生 (诊室: {{ getDoctorRoom(doctorName) }})</td> <td colspan="4">{{ doctorName }} 医生 (诊室: {{ getDoctorRoom(doctorName) }})</td>
</tr> </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>{{ index + 1 }}</td>
<td>{{ formatPatientName(patient.name) }}</td> <td>{{ patient.name }}</td>
<td>{{ getDoctorRoom(doctorName) }}</td> <td>{{ getDoctorRoom(doctorName) }}</td>
<td :style="{ color: index === 0 ? '#e74c3c' : '#27ae60' }"> <td :style="{ color: patient.status === 'CALLING' ? '#e74c3c' : '#27ae60' }">
{{ index === 0 ? '就诊中' : '等待' }} {{ patient.status === 'CALLING' ? '就诊中' : '等待' }}
</td> </td>
</tr> </tr>
</template> </template>
@@ -88,49 +93,64 @@
<script setup> <script setup>
import { ref, reactive, computed, onMounted, onUnmounted, nextTick, watchEffect } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted, nextTick, watchEffect } from 'vue'
import { storeToRefs } from 'pinia'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import dayjs from 'dayjs' 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 currentTime = ref('')
const currentCall = ref({ const currentCall = ref(null)
number: '1',
name: '李*四',
room: '3号'
})
const patients = ref([]) const patients = ref([])
const loading = ref(false) const loading = ref(false)
const currentPage = ref(1) const currentPage = ref(1)
const patientsPerPage = 5 const patientsPerPage = 5
const autoScrollInterval = ref(null) const autoScrollInterval = ref(null)
const scrollInterval = 5000 // 5秒自动翻页 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 groupedPatients = computed(() => {
const grouped = {} const grouped = {}
patients.value.forEach(patient => { patients.value.forEach(doctorGroup => {
if (!grouped[patient.doctor]) { grouped[doctorGroup.doctorName] = doctorGroup.patients || []
grouped[patient.doctor] = []
}
grouped[patient.doctor].push(patient)
}) })
return grouped 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(() => { 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') 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) => { const formatPatientName = (name) => {
if (!name || typeof name !== 'string') return '-' if (!name || typeof name !== 'string') return '-'
if (name.length === 0) return '-' if (name.length === 0) return '-'
@@ -166,49 +204,98 @@ const formatPatientName = (name) => {
} }
const getDoctorRoom = (doctorName) => { const getDoctorRoom = (doctorName) => {
// 根据医生获取固定诊室 // 从后端数据中查找医生的诊室
const doctorRooms = { const doctorGroup = patients.value.find(group => group.doctorName === doctorName)
'张医生': '3号', return doctorGroup?.roomNo || '1号'
'李医生': '1号',
'王医生': '2号'
}
return doctorRooms[doctorName] || '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 { try {
if (!ORGANIZATION_ID.value) {
ElMessage.warning('未获取到登录用户科室信息')
return
}
loading.value = true loading.value = true
// 确保数组已正确初始化 console.log('正在获取显示屏数据...', {
if (!Array.isArray(patients.value)) { url: `${API_BASE_URL}/display`,
patients.value = [] 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) { } catch (error) {
console.error('获取候诊数据失败:', error) console.error('获取显示屏数据失败:', error)
ElMessage.error('获取候诊数据失败') ElMessage.error('获取显示屏数据失败' + (error.message || '未知错误'))
// 出错时设置为空数组
// 出错时设置默认值
patients.value = [] patients.value = []
currentCall.value = { number: null, name: '-', room: '-', doctor: '-' }
waitingCount.value = 0
applyDefaultDepartmentName()
} finally { } finally {
loading.value = false 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 () => { onMounted(async () => {
document.addEventListener('fullscreenchange', updateFullscreenState)
await ensureUserInfo()
// 初始化时间 // 初始化时间
updateTime() updateTime()
// 每分钟更新时间 // 每分钟更新时间
const timeInterval = setInterval(updateTime, 60000) timeInterval.value = setInterval(updateTime, 60000)
// 获取候诊数据 // ✅ 获取初始数据(从后端 API
await generateWaitingData() await fetchDisplayData()
// ✅ 初始化 WebSocket 连接(实时推送)
initWebSocket()
// 启动自动滚动 // 启动自动滚动
startAutoScroll() startAutoScroll()
// 鼠标悬停时暂停自动滚动 // 鼠标悬停时暂停自动滚动
const tableContainer = document.querySelector('.table-container') tableContainer = document.querySelector('.table-container')
if (tableContainer) { if (tableContainer) {
tableContainer.addEventListener('mouseenter', stopAutoScroll) tableContainer.addEventListener('mouseenter', stopAutoScroll)
tableContainer.addEventListener('mouseleave', startAutoScroll) tableContainer.addEventListener('mouseleave', startAutoScroll)
} }
// 组件卸载时清理
onUnmounted(() => {
clearInterval(timeInterval)
stopAutoScroll()
if (tableContainer) {
tableContainer.removeEventListener('mouseenter', stopAutoScroll)
tableContainer.removeEventListener('mouseleave', startAutoScroll)
}
})
}) })
onUnmounted(() => { onUnmounted(() => {
// 组件卸载时的清理工作 // 组件卸载时的清理工作
if (timeInterval.value) {
clearInterval(timeInterval.value)
timeInterval.value = null
}
stopAutoScroll() 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; 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 { .header {
background: linear-gradient(135deg, #4a90e2, #5fa3e8); background: linear-gradient(135deg, #4a90e2, #5fa3e8);
@@ -342,6 +603,28 @@ watchEffect(() => {
padding: 5px 15px; padding: 5px 15px;
border-radius: 30px; 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);
}
} }
/* 当前呼叫区 */ /* 当前呼叫区 */