Files
his/openhis-ui-vue3/src/views/triageandqueuemanage/cardiology/index.vue

2231 lines
70 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="smart-triage-queue">
<!-- 顶部标题栏 -->
<div class="header-section">
<div class="header-left">
<span class="title">智能分诊排队管理 - 心内科</span>
</div>
<div class="header-right">
<el-button type="primary" @click="handleRefresh">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
<el-button @click="handleExit">退出</el-button>
<el-button @click="handleConfig">后台配置</el-button>
</div>
</div>
<!-- 主要内容区域 -->
<div class="main-content">
<!-- 左侧智能候选池 -->
<div class="left-panel">
<div class="panel-header">
<span class="panel-title"> 智能候选池 (已签到未入队)</span>
</div>
<div class="table-container">
<el-table
:data="filteredCandidatePoolList"
stripe
border
height="100%"
style="width: 100%"
@selection-change="handleCandidateSelectionChange"
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column prop="sequenceNo" label="序号" width="80" align="center" />
<el-table-column prop="patientName" label="患者" width="100" align="center" />
<el-table-column prop="age" label="年龄" width="80" align="center" />
<el-table-column prop="appointmentType" label="号别" width="100" align="center" />
<el-table-column prop="room" label="诊室" width="120" align="center" />
<el-table-column prop="doctor" label="医生" width="120" align="center" />
<el-table-column prop="matchingRule" label="命中规则" min-width="150" align="center" />
</el-table>
</div>
<div class="candidate-actions">
<el-button
type="primary"
@click="handleAddToQueue"
:disabled="selectedCandidates.length === 0"
>
加入队列 >>
</el-button>
<el-button
type="primary"
@click="handleAddAllToQueue"
:disabled="filteredCandidatePoolList.length === 0"
>
一键加入队列
</el-button>
</div>
</div>
<!-- 右侧智能队列 -->
<div class="right-panel">
<div class="panel-header">
<span class="panel-title"> 智能队列 (全科)</span>
</div>
<div class="table-container">
<el-table
:data="filteredQueueList"
stripe
border
height="100%"
style="width: 100%"
:row-class-name="getRowClassName"
highlight-current-row
@row-click="handleQueueRowClick"
>
<el-table-column prop="queueOrder" label="队序" width="80" align="center" />
<el-table-column prop="patientName" label="患者" width="100" align="center" />
<el-table-column prop="appointmentType" label="号别" width="100" align="center" />
<el-table-column prop="room" label="诊室" width="120" align="center" />
<el-table-column prop="doctor" label="医生" width="120" align="center" />
<el-table-column prop="waitingTime" label="等待" width="100" align="center" />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="scope">
<el-tag :type="getStatusTagType(scope.row.status)">
{{ scope.row.status }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<div class="display-options">
<div class="queue-actions-left">
<el-button
type="danger"
@click="handleRemoveFromQueue"
:disabled="!selectedQueueRow"
size="small"
>
<< 移出队列
</el-button>
<el-button
type="info"
@click="handleMoveUp"
:disabled="!selectedQueueRow || !canMoveUp"
size="small"
>
</el-button>
<el-button
type="info"
@click="handleMoveDown"
:disabled="!selectedQueueRow || !canMoveDown"
size="small"
>
</el-button>
</div>
<div class="queue-actions-right">
<el-button
:type="showOnlyWaiting ? 'primary' : ''"
@click="showOnlyWaiting = true"
size="small"
>
只显示等待
</el-button>
<el-button
:type="!showOnlyWaiting ? 'primary' : ''"
@click="showOnlyWaiting = false"
size="small"
>
显示全部状态
</el-button>
</div>
</div>
</div>
</div>
<!-- 底部控制面板 -->
<div class="footer-section">
<!-- 就诊科室快速过滤栏 -->
<div class="filter-section">
<div class="filter-label"> 就诊科室快速过滤栏</div>
<div class="filter-select-wrapper">
<el-select
v-model="selectedDept"
placeholder="请选择就诊科室"
clearable
filterable
style="width: 100%"
size="default"
>
<el-option
label="全部"
value="all"
/>
<el-option
v-for="dept in departmentList"
:key="dept.id"
:label="dept.name"
:value="dept.id"
/>
</el-select>
</div>
</div>
<!-- 叫号控制板 -->
<div class="call-control-section">
<div class="call-control-label"> 叫号控制板</div>
<div class="call-control-content">
<div class="current-call-display">
当前呼叫: {{ currentCall.number }} {{ currentCall.name }} 诊室: {{ currentCall.room }}
</div>
<div class="control-buttons">
<el-button type="primary" @click="handleSelectCall">选呼</el-button>
<el-button type="success" @click="handleNextPatient">下一患者</el-button>
<el-button type="warning" @click="handleSkip">跳过</el-button>
<el-button type="primary" @click="handleComplete">完成</el-button>
<el-button type="info" @click="handleRequeue">过号重排</el-button>
</div>
</div>
</div>
<!-- LED显示 -->
<div class="led-section">
<div class="led-label"> LED:</div>
<div class="led-display">
[{{ currentCall.number }}]{{ currentCall.name }}请到{{ currentCall.room }}({{ callType }})
</div>
</div>
<!-- 语音提示 -->
<div class="voice-section">
<span class="voice-label">语音:</span>
<span class="voice-text">{{ currentCall.number }}{{ currentCall.name }}{{ currentCall.room }}({{ callType }})</span>
</div>
</div>
<!-- 后台配置弹窗 -->
<el-dialog
v-model="configDialogVisible"
width="90%"
top="5vh"
>
<template #header>
<div class="config-dialog-header">
<div class="config-dialog-title">智能分诊规则引擎配置 - 心内科</div>
<div class="config-topbar-actions">
<el-button type="primary" @click="handleAddRule">新增规则</el-button>
<el-button type="primary" @click="handleSaveAllRules">保存全部</el-button>
<el-button @click="handleTestRule">测试规则</el-button>
</div>
</div>
</template>
<div class="config-container">
<div class="config-left">
<el-scrollbar height="560px">
<div
v-for="(item, idx) in rules"
:key="idx"
class="rule-card"
:class="{ active: idx === editingIndex }"
@click="handleSelectRule(idx)"
>
<div class="rule-title">规则{{ idx + 1 }}</div>
<div class="rule-sub">prio={{ item.priority }}</div>
<div class="rule-sub">{{ item.name }}</div>
<div class="rule-actions">
<el-button size="small" @click.stop="handleSelectRule(idx)">编辑</el-button>
<el-button size="small" @click.stop="handleDeleteRule(idx)">删除</el-button>
<el-button size="small" @click.stop="handleRuleMoveUp(idx)" :disabled="idx === 0"></el-button>
<el-button size="small" @click.stop="handleRuleMoveDown(idx)" :disabled="idx === rules.length - 1"></el-button>
</div>
</div>
</el-scrollbar>
</div>
<div class="config-right">
<el-form label-width="110px" class="config-form">
<el-form-item label="规则名称:" required>
<el-input v-model="ruleForm.name" placeholder="请输入规则名称" />
</el-form-item>
<el-form-item label="科室:">
<el-select v-model="ruleForm.dept" class="config-fullwidth" disabled>
<el-option label="心内科" value="心内科" />
</el-select>
</el-form-item>
<el-form-item label="规则描述:">
<el-input v-model="ruleForm.desc" placeholder="请输入规则描述" />
</el-form-item>
<el-form-item label="优先级:" required>
<el-input v-model="ruleForm.priority" />
</el-form-item>
<el-form-item label="周几生效:">
<el-checkbox-group v-model="ruleForm.weeks">
<el-checkbox v-for="w in weekOptions" :key="w.value" :label="w.value">
{{ w.label }}
</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="条件表达式(JSON):">
<el-input
v-model="ruleForm.expr"
type="textarea"
:rows="8"
placeholder='{"age":">=60","regType":"专家"}'
/>
</el-form-item>
<div class="config-inline-actions">
<el-button @click="handleQuickGenerate">快速生成器</el-button>
<el-button @click="handleValidateRule">语法检查</el-button>
</div>
</el-form>
</div>
</div>
<template #footer>
<div class="config-footer">
<el-button type="primary" @click="handleSaveCurrentRule">保存</el-button>
<el-button @click="configDialogVisible = false">取消</el-button>
</div>
</template>
</el-dialog>
<!-- 快速生成器对话框 -->
<el-dialog
v-model="quickGeneratorDialogVisible"
title="快速生成器"
width="600px"
:close-on-click-modal="false"
>
<el-form :model="quickGeneratorForm" label-width="120px">
<el-form-item label="年龄条件:">
<div style="display: flex; align-items: center; gap: 10px;">
<el-select v-model="quickGeneratorForm.ageOperator" style="width: 100px;">
<el-option label=">=" value=">=" />
<el-option label="<=" value="<=" />
<el-option label="=" value="=" />
<el-option label=">" value=">" />
<el-option label="<" value="<" />
</el-select>
<el-input-number
v-model="quickGeneratorForm.ageValue"
:min="0"
:max="150"
:precision="0"
placeholder="年龄"
style="width: 150px;"
/>
<el-button
type="text"
@click="quickGeneratorForm.ageOperator = null; quickGeneratorForm.ageValue = null"
>
清除
</el-button>
</div>
</el-form-item>
<el-form-item label="号别/类型:">
<el-select
v-model="quickGeneratorForm.regType"
placeholder="请选择号别"
clearable
style="width: 100%"
>
<el-option label="专家" value="专家" />
<el-option label="普通" value="普通" />
<el-option label="特需" value="特需" />
<el-option label="急诊" value="急诊" />
</el-select>
</el-form-item>
<el-form-item label="科室:">
<el-select
v-model="quickGeneratorForm.dept"
placeholder="请选择科室"
clearable
style="width: 100%"
>
<el-option label="心内科" value="心内科" />
<el-option label="心外科" value="心外科" />
<el-option label="神经内科" value="神经内科" />
</el-select>
</el-form-item>
<el-form-item label="医生:">
<el-input
v-model="quickGeneratorForm.doctor"
placeholder="请输入医生姓名(支持模糊匹配)"
clearable
/>
</el-form-item>
<el-form-item label="自定义条件:">
<el-input
v-model="quickGeneratorForm.customKey"
placeholder="字段名gender"
style="width: 150px; margin-right: 10px;"
/>
<el-input
v-model="quickGeneratorForm.customValue"
placeholder="字段值(如:男)"
style="width: 200px;"
/>
<el-button
type="text"
@click="quickGeneratorForm.customKey = ''; quickGeneratorForm.customValue = ''"
>
清除
</el-button>
</el-form-item>
<el-form-item label="预览JSON:">
<el-input
:value="previewJson"
type="textarea"
:rows="4"
readonly
style="font-family: monospace;"
/>
</el-form-item>
</el-form>
<template #footer>
<div style="text-align: right;">
<el-button @click="quickGeneratorDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleApplyQuickGenerate">应用</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import {
getCandidatePool,
getLocationTree,
getTriageQueueList,
addToQueue,
removeFromQueue,
adjustQueueOrder,
callPatient,
skipPatient,
completeCall,
requeuePatient,
nextPatient
} from '../api'
// 当前日期 & 统计信息(总已签到/在队列中)
const currentDate = ref('2025/12/22 上午')
const totalSignedIn = ref(0)
const totalInQueue = ref(0)
// 当前呼叫信息
const currentCall = ref({
number: '1',
name: '郑华',
room: '4号诊室'
})
// 当前选中的队列行(用于选呼)
const selectedQueueRow = ref(null)
// 选中的候选池患者(多选)
const selectedCandidates = ref([])
// 显示选项
const showOnlyWaiting = ref(false)
// 科室过滤(改为使用就诊科室)
const selectedDept = ref('all')
// 就诊科室列表
const departmentList = ref([])
// 后台配置对话框
const configDialogVisible = ref(false)
const rules = ref([
{
name: '老年专家优先',
dept: '心内科',
desc: '老年专家优先',
priority: 10,
weeks: ['1', '2', '3', '4', '5'],
expr: '{"age":">=60","regType":"专家"}'
},
{
name: '少年优先',
dept: '心内科',
desc: '少年优先',
priority: 20,
weeks: ['1', '2', '3', '4', '5'],
expr: '{"age":"<18"}'
},
{
name: '默认',
dept: '心内科',
desc: '默认',
priority: 50,
weeks: ['1', '2', '3', '4', '5', '6', '0'],
expr: '{}'
}
])
const editingIndex = ref(0)
const ruleForm = reactive({
name: '',
dept: '心内科',
desc: '',
priority: 10,
weeks: ['1', '2', '3', '4', '5'],
expr: ''
})
// 快速生成器对话框
const quickGeneratorDialogVisible = ref(false)
const quickGeneratorForm = reactive({
ageOperator: null, // >=, <=, =, >, <
ageValue: null,
regType: null, // 号别
dept: null, // 科室
doctor: null, // 医生
customKey: '', // 自定义字段名
customValue: '' // 自定义字段值
})
const weekOptions = [
{ label: '一', value: '1' },
{ label: '二', value: '2' },
{ label: '三', value: '3' },
{ label: '四', value: '4' },
{ label: '五', value: '5' },
{ label: '六', value: '6' },
{ label: '日', value: '0' }
]
// 叫号类型
const callType = ref('选呼')
// ============ 数据初始化/刷新(前端模拟) ============
const getInitialCandidatePoolList = () => ([
{
sequenceNo: 12,
patientName: '陈明',
age: 65,
appointmentType: '专家',
room: '3号诊室',
doctor: '张医生',
matchingRule: '年龄≥60'
},
{
sequenceNo: 13,
patientName: '刘芳',
age: 58,
appointmentType: '普通',
room: '4号诊室',
doctor: '李医生',
matchingRule: '-'
},
{
sequenceNo: 14,
patientName: '周强',
age: 45,
appointmentType: '普通',
room: '5号诊室',
doctor: '王医生',
matchingRule: '-'
},
{
sequenceNo: 15,
patientName: '吴伟',
age: 72,
appointmentType: '专家',
room: '3号诊室',
doctor: '张医生',
matchingRule: '年龄≥70'
}
])
const getInitialQueueList = () => ([
{
queueOrder: 1,
patientName: '林静',
appointmentType: '专家',
room: '3号诊室',
doctor: '张医生',
waitingTime: '05:00',
status: '等待'
},
{
queueOrder: 2,
patientName: '郑华',
appointmentType: '普通',
room: '4号诊室',
doctor: '李医生',
waitingTime: '00:00',
status: '叫号中'
},
{
queueOrder: 3,
patientName: '王丽',
appointmentType: '普通',
room: '5号诊室',
doctor: '王医生',
waitingTime: '08:00',
status: '等待'
},
{
queueOrder: 4,
patientName: '张伟',
appointmentType: '专家',
room: '3号诊室',
doctor: '张医生',
waitingTime: '12:00',
status: '等待'
}
])
const syncCurrentCallFromQueue = () => {
const calling = originalQueueList.value.find((i) => i.status === '叫号中')
if (!calling) {
currentCall.value = { number: '-', name: '-', room: '-' }
selectedQueueRow.value = null
return
}
currentCall.value = {
number: String(calling.queueOrder),
name: calling.patientName,
room: calling.room
}
selectedQueueRow.value = calling
}
// ============ 与后端接口联动 ============
// 注意:后端未实现或出错时会自动退回本地假数据
// 将分钟数转换为 MM:SS 格式
const formatMinutesToMmSs = (minutes) => {
if (minutes == null || minutes < 0) return '00:00'
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
const hoursStr = String(hours).padStart(2, '0')
const minsStr = String(mins).padStart(2, '0')
return `${hoursStr}:${minsStr}`
}
// 将年龄字符串转换为数字(处理 "20岁"、"1小时" 等格式)
const parseAge = (ageStr) => {
if (!ageStr) return 0
const match = ageStr.match(/(\d+)/)
return match ? parseInt(match[1], 10) : 0
}
// 后端队列状态 -> 前端展示状态
const mapBackendStatusToFrontend = (status) => {
if (!status) {
console.warn('【心内科】状态映射:收到空状态值')
return '等待'
}
// 转换为大写并去除空格,确保匹配
const normalizedStatus = String(status).trim().toUpperCase()
if (normalizedStatus === 'CALLING') return '叫号中'
if (normalizedStatus === 'WAITING') return '等待'
if (normalizedStatus === 'SKIPPED') return '跳过'
if (normalizedStatus === 'COMPLETED') return '已完成'
console.warn('【心内科】状态映射:未知状态值', status, '-> 默认返回"等待"')
return '等待'
}
// 前端状态 -> 后端状态(目前仅展示用)
const mapFrontendStatusToBackend = (status) => {
if (!status) return 'WAITING'
if (status === '叫号中') return 'CALLING'
if (status === '等待') return 'WAITING'
if (status === '跳过') return 'SKIPPED'
if (status === '已完成') return 'COMPLETED'
return 'WAITING'
}
// 从数据库加载队列
const loadQueueFromDb = async () => {
try {
// 如果选择了具体科室,就按科室加载;否则加载当前登录人科室(后端默认)
const organizationId = selectedDept.value !== 'all' ? selectedDept.value : undefined
// 只查询今天的患者
const today = new Date()
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
console.log('【心内科】loadQueueFromDb 开始organizationId=', organizationId, 'date=', todayStr, 'selectedDept=', selectedDept.value)
const res = await getTriageQueueList({ organizationId, date: todayStr }).catch((err) => {
console.error('【心内科】loadQueueFromDb 请求异常:', err)
return { code: 500, msg: err?.message || '请求失败', data: null }
})
console.log('【心内科】loadQueueFromDb 后端响应:', res)
// 检查响应是否成功
if (res?.code !== 200 && res?.code !== undefined) {
console.error('【心内科】loadQueueFromDb 后端返回错误:', res?.code, res?.msg)
// 即使后端返回错误,也不清空队列,避免误删数据
// originalQueueList.value 保持原值
return
}
const list = Array.isArray(res?.data) ? res.data : []
console.log('【心内科】loadQueueFromDb 从后端获取到', list.length, '条队列数据')
if (list.length === 0) {
console.log('【心内科】提示:队列数据为空(可能是今天还没有患者加入队列)')
}
originalQueueList.value = list
.map((it) => {
const frontendStatus = mapBackendStatusToFrontend(it.status)
// 调试日志:检查状态映射
if (list.length <= 5) {
console.log('【心内科】状态映射:后端状态=', it.status, '-> 前端状态=', frontendStatus, '患者=', it.patientName)
}
// 计算等待时间基于创建时间createTime
let waitingTime = '00:00'
if (it.createTime) {
try {
const createTime = new Date(it.createTime)
// 检查日期是否有效
if (!isNaN(createTime.getTime())) {
const now = new Date()
const diffSeconds = Math.floor((now - createTime) / 1000)
waitingTime = formatSecondsToMmSs(Math.max(0, diffSeconds))
} else {
console.warn('【心内科】无效的创建时间:', it.createTime, '患者:', it.patientName)
}
} catch (e) {
console.error('【心内科】解析创建时间失败:', it.createTime, '患者:', it.patientName, e)
}
}
return {
id: it.id,
queueOrder: it.queueOrder,
patientName: it.patientName ?? '-',
appointmentType: it.healthcareName ?? '普通',
room: it.organizationName ?? '-',
doctor: it.practitionerName ?? '-',
waitingTime: waitingTime,
createTime: it.createTime, // 保存创建时间,用于定时器计算
status: frontendStatus,
// 关联字段(用于去重/追溯)
encounterId: it.encounterId,
organizationId: it.organizationId
}
})
.filter((item) => {
// 过滤掉"已完成"状态的患者,不显示在队列中
if (item.status === '已完成') {
console.log('【心内科】过滤掉已完成状态的患者:', item.patientName)
return false
}
return true
})
// 调试日志:检查查找结果
const callingCount = originalQueueList.value.filter(i => i.status === '叫号中').length
const waitingCount = originalQueueList.value.filter(i => i.status === '等待').length
console.log('【心内科】队列状态统计:总数=', originalQueueList.value.length, '叫号中=', callingCount, '等待=', waitingCount)
if (originalQueueList.value.length > 0) {
console.log('【心内科】队列中的 encounterIds:', originalQueueList.value.map(q => ({ name: q.patientName, encounterId: q.encounterId })))
}
// 统计
totalInQueue.value = originalQueueList.value.length
syncCurrentCallFromQueue()
} catch (e) {
console.error('【心内科】loadQueueFromDb 异常:', e)
// 不回退本地假数据,避免“看起来有数据但实际没落库”的误导
originalQueueList.value = []
totalInQueue.value = 0
syncCurrentCallFromQueue()
}
}
// 根据状态枚举文本转换为前端状态
const mapStatusToFrontend = (statusEnumText) => {
if (!statusEnumText) return '等待'
// 状态映射:就诊中 -> 叫号中,其他 -> 等待
if (statusEnumText.includes('就诊中') || statusEnumText.includes('在诊')) {
return '叫号中'
} else if (statusEnumText.includes('已完成') || statusEnumText.includes('已出院')) {
return '已完成'
} else if (statusEnumText.includes('已取消') || statusEnumText.includes('退号')) {
return '已取消'
}
return '等待'
}
// 判断日期是否是今天
const isToday = (date) => {
if (!date) return false
const targetDate = new Date(date)
const today = new Date()
return targetDate.getFullYear() === today.getFullYear() &&
targetDate.getMonth() === today.getMonth() &&
targetDate.getDate() === today.getDate()
}
// 过滤今天的数据
const filterTodayData = (data) => {
if (!Array.isArray(data)) return []
const today = new Date()
today.setHours(0, 0, 0, 0)
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
return data.filter(item => {
if (!item.registerTime) return false
const registerDate = new Date(item.registerTime)
return registerDate >= today && registerDate < tomorrow
})
}
const loadDataFromApi = async () => {
// 明确打日志,方便你在浏览器 Console 里看到是否调用到了这里
console.log('【心内科】loadDataFromApi 被调用了:候选池=门诊挂号接口,队列=数据库接口')
try {
const baseParams = {}
// 1) 候选池(使用门诊挂号当日已挂号接口)
const candidateRes = await getCandidatePool(baseParams).catch((err) => {
return null
})
if (candidateRes && candidateRes.data != null) {
// 门诊挂号接口返回的是分页数据 { records: [], total: 0 }
const records = candidateRes.data.records || candidateRes.data
const data = Array.isArray(records) ? records : (Array.isArray(candidateRes.data) ? candidateRes.data : [])
console.log('【心内科】候选池数据:', data, '类型:', typeof data, '是否为数组:', Array.isArray(data), '数量:', data.length)
if (Array.isArray(data) && data.length > 0) {
// 过滤今天的数据
const todayData = filterTodayData(data)
console.log('【心内科】过滤后的今天数据:', todayData.length, '条(原始:', data.length, '条)')
// 门诊挂号 CurrentDayEncounterDto → 候选池
originalCandidatePoolList.value = todayData.map((item, idx) => ({
sequenceNo: idx + 1,
encounterId: item.encounterId,
patientId: item.patientId,
organizationId: item.organizationId,
organizationName: item.organizationName,
patientName: item.patientName ?? '-',
age: parseAge(item.age),
appointmentType: item.healthcareName ?? '普通',
room: item.organizationName ? `${item.organizationName}` : '-',
doctor: item.practitionerName ?? '-',
matchingRule: '-' // 这里先不做智能规则匹配
}))
console.log('【心内科】候选池已加载', originalCandidatePoolList.value.length, '条今天的数据')
} else {
console.log('【心内科】候选池数据为空数组或非数组,使用默认数据')
originalCandidatePoolList.value = getInitialCandidatePoolList()
}
} else {
console.log('【心内科】候选池响应为空或格式错误,使用默认数据')
originalCandidatePoolList.value = getInitialCandidatePoolList()
}
// 2) 队列列表:从数据库读取(可刷新、可恢复)
await loadQueueFromDb()
// 3) 候选池去重:已经在队列里的患者(按 encounterId不再出现在候选池
// 注意:确保 encounterId 类型一致(统一转换为字符串进行比较,避免数字和字符串不匹配)
const queueEncounterIds = new Set(
originalQueueList.value
.map((q) => q.encounterId)
.filter((id) => id != null && id !== undefined && id !== '')
.map((id) => String(id)) // 统一转换为字符串
)
console.log('【心内科】去重前:候选池', originalCandidatePoolList.value.length, '条,队列', originalQueueList.value.length, '条队列encounterIds=', Array.from(queueEncounterIds))
if (queueEncounterIds.size > 0) {
const beforeCount = originalCandidatePoolList.value.length
originalCandidatePoolList.value = originalCandidatePoolList.value.filter((c) => {
const candidateId = c.encounterId != null ? String(c.encounterId) : null
return candidateId == null || !queueEncounterIds.has(candidateId)
})
console.log('【心内科】去重后:候选池', originalCandidatePoolList.value.length, '条(移除了', beforeCount - originalCandidatePoolList.value.length, '条)')
// 如果去重后数量没有变化,可能是类型不匹配问题
if (beforeCount === originalCandidatePoolList.value.length && beforeCount > 0) {
console.warn('【心内科】警告:去重前后数量未变化,可能是 encounterId 类型不匹配!')
console.warn(' 候选池中的 encounterIds:', originalCandidatePoolList.value.slice(0, 3).map(c => ({ name: c.patientName, id: c.encounterId, type: typeof c.encounterId })))
console.warn(' 队列中的 encounterIds:', originalQueueList.value.slice(0, 3).map(q => ({ name: q.patientName, id: q.encounterId, type: typeof q.encounterId })))
}
} else {
console.warn('【心内科】警告:队列中没有 encounterId无法进行去重')
if (originalQueueList.value.length > 0) {
console.warn(' 队列数据详情:', originalQueueList.value.slice(0, 3).map(q => ({ name: q.patientName, encounterId: q.encounterId })))
}
}
// 4) 统计信息(只统计候选池和队列的数据)
totalSignedIn.value = originalCandidatePoolList.value.length
console.log('【心内科】统计信息:候选池', totalSignedIn.value, '条,队列', totalInQueue.value, '条')
// 当前日期展示(简单用本机时间)
const now = new Date()
currentDate.value = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`
// 同步当前呼叫(队列从 DB 加载后已同步;这里再兜底一次)
syncCurrentCallFromQueue()
console.log('【心内科】数据加载完成:候选池', originalCandidatePoolList.value.length, '条,队列', originalQueueList.value.length, '条')
ElMessage.success('【心内科】已从门诊挂号接口加载数据')
} catch (e) {
console.error('【心内科】loadDataFromApi 执行异常,使用本地假数据:', e)
// 任何异常:回退本地假数据
originalCandidatePoolList.value = getInitialCandidatePoolList()
// 队列不再回退假数据,避免误导
originalQueueList.value = []
totalSignedIn.value = originalCandidatePoolList.value.length
totalInQueue.value = originalQueueList.value.length
syncCurrentCallFromQueue()
}
}
// 原始数据存储(用于过滤)
const originalCandidatePoolList = ref(getInitialCandidatePoolList())
// 辅助函数:扁平化科室树形结构
const flattenDepartmentTree = (tree, result = []) => {
if (!Array.isArray(tree)) return result
tree.forEach(node => {
if (node.id && node.name) {
result.push({ id: node.id, name: node.name })
}
if (node.children && Array.isArray(node.children)) {
flattenDepartmentTree(node.children, result)
}
})
return result
}
// 加载就诊科室列表
const loadDepartmentList = async () => {
try {
const response = await getLocationTree()
if (response && response.data) {
// 扁平化树形结构
departmentList.value = flattenDepartmentTree(response.data)
console.log('【心内科】已加载就诊科室列表:', departmentList.value.length, '个科室')
}
} catch (error) {
console.error('【心内科】加载就诊科室列表失败:', error)
ElMessage.warning('加载就诊科室列表失败,使用默认数据')
}
}
// 获取选中科室的名称
const getSelectedDeptName = () => {
if (selectedDept.value === 'all') return null
const dept = departmentList.value.find(d => d.id === selectedDept.value)
return dept ? dept.name : null
}
// 过滤后的智能候选池数据
const filteredCandidatePoolList = computed(() => {
if (selectedDept.value === 'all') {
return originalCandidatePoolList.value
}
const deptName = getSelectedDeptName()
if (!deptName) return originalCandidatePoolList.value
return originalCandidatePoolList.value.filter(item => item.room === deptName)
})
// 原始队列数据存储(用于过滤)
const originalQueueList = ref(getInitialQueueList())
const parseMmSsToSeconds = (mmss) => {
if (!mmss || typeof mmss !== 'string') return 0
const [mm, ss] = mmss.split(':')
const m = Number(mm)
const s = Number(ss)
if (Number.isNaN(m) || Number.isNaN(s)) return 0
return m * 60 + s
}
const formatSecondsToMmSs = (totalSeconds) => {
const safe = Math.max(0, Math.floor(totalSeconds || 0))
const mm = String(Math.floor(safe / 60)).padStart(2, '0')
const ss = String(safe % 60).padStart(2, '0')
return `${mm}:${ss}`
}
// 过滤后的智能队列数据(同时考虑科室过滤和状态过滤)
const filteredQueueList = computed(() => {
let filtered = originalQueueList.value
// 先过滤掉"已完成"状态的患者(无论什么情况都不显示)
filtered = filtered.filter(item => item.status !== '已完成')
// 再按科室过滤
if (selectedDept.value !== 'all') {
const deptName = getSelectedDeptName()
if (deptName) {
filtered = filtered.filter(item => item.room === deptName)
}
}
// 再按状态过滤(只显示等待)
if (showOnlyWaiting.value) {
filtered = filtered.filter(item => item.status === '等待')
}
return filtered
})
const handleQueueRowClick = (row) => {
selectedQueueRow.value = row
}
// 候选池选择变化
const handleCandidateSelectionChange = (selection) => {
selectedCandidates.value = selection
}
// 获取下一个队序号
const getNextQueueOrder = () => {
if (originalQueueList.value.length === 0) return 1
const maxOrder = Math.max(...originalQueueList.value.map(item => item.queueOrder))
return maxOrder + 1
}
// 重新计算队序号(保持连续)
const recalculateQueueOrders = () => {
originalQueueList.value.forEach((item, index) => {
item.queueOrder = index + 1
})
}
// 加入队列(选中的候选池患者)——落库
const handleAddToQueue = async () => {
if (selectedCandidates.value.length === 0) {
ElMessage.warning('请先选择要加入队列的患者')
return
}
// 记录要加入的患者 encounterId用于后续删除
const toAddEncounterIds = new Set()
selectedCandidates.value.forEach((c) => {
if (c.encounterId != null) {
toAddEncounterIds.add(String(c.encounterId))
}
})
const beforeCount = originalCandidatePoolList.value.length
console.log('【心内科】handleAddToQueue 开始:选中', selectedCandidates.value.length, '位患者,候选池当前', beforeCount, '条')
try {
const groups = new Map()
selectedCandidates.value.forEach((c) => {
if (!c.organizationId) return
if (!groups.has(c.organizationId)) {
groups.set(c.organizationId, {
organizationId: c.organizationId,
organizationName: c.organizationName || c.room,
items: []
})
}
groups.get(c.organizationId).items.push({
encounterId: c.encounterId,
patientId: c.patientId,
patientName: c.patientName,
healthcareName: c.appointmentType,
practitionerName: c.doctor
})
})
let added = 0
const addedEncounterIds = new Set()
for (const [, payload] of groups) {
try {
const res = await addToQueue(payload)
if (typeof res?.data === 'number') added += res.data
// 不论后端是否判重,只要尝试加入过的 encounterId一律从候选池移除
payload.items.forEach(it => {
if (it.encounterId != null) {
addedEncounterIds.add(String(it.encounterId))
}
})
} catch (err) {
console.error('【心内科】单个分组加入队列失败:', err)
// 即使失败,也记录 encounterId后续尝试删除避免重复尝试
payload.items.forEach(it => {
if (it.encounterId != null) {
addedEncounterIds.add(String(it.encounterId))
}
})
}
}
// 立即从候选池中删除已加入的患者无论API是否成功只要尝试过就删除
// 确保 encounterId 类型一致(统一转换为字符串)
if (addedEncounterIds.size > 0) {
const beforeDeleteCount = originalCandidatePoolList.value.length
originalCandidatePoolList.value = originalCandidatePoolList.value.filter(
c => {
const candidateId = c.encounterId != null ? String(c.encounterId) : null
const shouldKeep = candidateId == null || !addedEncounterIds.has(candidateId)
return shouldKeep
}
)
const deletedCount = beforeDeleteCount - originalCandidatePoolList.value.length
console.log('【心内科】从候选池删除', deletedCount, '位患者(尝试加入的', addedEncounterIds.size, '位)')
// 同时清空选中状态
selectedCandidates.value = []
}
// 刷新队列数据(从数据库读取最新队列)
await loadQueueFromDb()
totalSignedIn.value = originalCandidatePoolList.value.length
const afterCount = originalCandidatePoolList.value.length
console.log('【心内科】handleAddToQueue 完成:候选池从', beforeCount, '条减少到', afterCount, '条,成功加入', added, '位')
if (added > 0) {
ElMessage.success(`成功将 ${added} 位患者加入队列(已保存)`)
} else if (addedEncounterIds.size > 0) {
ElMessage.warning('所选患者已在队列中,已从候选池移除')
} else {
ElMessage.warning('加入队列失败,请重试')
}
} catch (e) {
console.error('【心内科】handleAddToQueue 异常:', e)
// 即使出错,也尝试删除已尝试加入的患者(避免重复操作)
if (toAddEncounterIds.size > 0) {
const beforeDeleteCount = originalCandidatePoolList.value.length
originalCandidatePoolList.value = originalCandidatePoolList.value.filter(
c => {
const candidateId = c.encounterId != null ? String(c.encounterId) : null
return candidateId == null || !toAddEncounterIds.has(candidateId)
}
)
const deletedCount = beforeDeleteCount - originalCandidatePoolList.value.length
console.log('【心内科】异常情况下从候选池删除', deletedCount, '位患者')
totalSignedIn.value = originalCandidatePoolList.value.length
selectedCandidates.value = []
}
ElMessage.error('加入队列失败(未保存)')
}
}
// 一键加入队列(所有候选池患者)——落库
const handleAddAllToQueue = async () => {
if (filteredCandidatePoolList.value.length === 0) {
ElMessage.warning('候选池中没有患者')
return
}
// 记录要加入的所有患者 encounterId用于后续删除
const toAddEncounterIds = new Set()
filteredCandidatePoolList.value.forEach((c) => {
if (c.encounterId != null) {
toAddEncounterIds.add(String(c.encounterId))
}
})
const beforeCount = originalCandidatePoolList.value.length
console.log('【心内科】handleAddAllToQueue 开始:候选池当前', beforeCount, '条,过滤后', filteredCandidatePoolList.value.length, '条')
try {
const groups = new Map()
filteredCandidatePoolList.value.forEach((c) => {
if (!c.organizationId) return
if (!groups.has(c.organizationId)) {
groups.set(c.organizationId, {
organizationId: c.organizationId,
organizationName: c.organizationName || c.room,
items: []
})
}
groups.get(c.organizationId).items.push({
encounterId: c.encounterId,
patientId: c.patientId,
patientName: c.patientName,
healthcareName: c.appointmentType,
practitionerName: c.doctor
})
})
let added = 0
const addedEncounterIds = new Set()
for (const [, payload] of groups) {
try {
const res = await addToQueue(payload)
if (typeof res?.data === 'number') added += res.data
// 不论后端是否判重,只要尝试加入过的 encounterId一律从候选池移除
payload.items.forEach(it => {
if (it.encounterId != null) {
addedEncounterIds.add(String(it.encounterId))
}
})
} catch (err) {
console.error('【心内科】单个分组一键加入队列失败:', err)
// 即使失败,也记录 encounterId后续尝试删除避免重复尝试
payload.items.forEach(it => {
if (it.encounterId != null) {
addedEncounterIds.add(String(it.encounterId))
}
})
}
}
// 立即从候选池中删除已加入的患者无论API是否成功只要尝试过就删除
// 确保 encounterId 类型一致(统一转换为字符串)
if (addedEncounterIds.size > 0) {
const beforeDeleteCount = originalCandidatePoolList.value.length
originalCandidatePoolList.value = originalCandidatePoolList.value.filter(
c => {
const candidateId = c.encounterId != null ? String(c.encounterId) : null
const shouldKeep = candidateId == null || !addedEncounterIds.has(candidateId)
return shouldKeep
}
)
const deletedCount = beforeDeleteCount - originalCandidatePoolList.value.length
console.log('【心内科】从候选池删除', deletedCount, '位患者(尝试加入的', addedEncounterIds.size, '位)')
// 同时清空选中状态
selectedCandidates.value = []
}
// 刷新队列数据(从数据库读取最新队列)
await loadQueueFromDb()
totalSignedIn.value = originalCandidatePoolList.value.length
const afterCount = originalCandidatePoolList.value.length
console.log('【心内科】handleAddAllToQueue 完成:候选池从', beforeCount, '条减少到', afterCount, '条,成功加入', added, '位')
if (added > 0) {
ElMessage.success(`成功将 ${added} 位患者加入队列(已保存)`)
} else if (addedEncounterIds.size > 0) {
ElMessage.warning('所有候选池患者已在队列中,已从候选池移除')
} else {
ElMessage.warning('一键加入失败,请重试')
}
} catch (e) {
console.error('【心内科】handleAddAllToQueue 异常:', e)
// 即使出错,也尝试删除已尝试加入的患者(避免重复操作)
if (toAddEncounterIds.size > 0) {
const beforeDeleteCount = originalCandidatePoolList.value.length
originalCandidatePoolList.value = originalCandidatePoolList.value.filter(
c => {
const candidateId = c.encounterId != null ? String(c.encounterId) : null
return candidateId == null || !toAddEncounterIds.has(candidateId)
}
)
const deletedCount = beforeDeleteCount - originalCandidatePoolList.value.length
console.log('【心内科】异常情况下从候选池删除', deletedCount, '位患者')
totalSignedIn.value = originalCandidatePoolList.value.length
selectedCandidates.value = []
}
ElMessage.error('一键加入失败(未保存)')
}
}
// 移出队列——落库
const handleRemoveFromQueue = async () => {
if (!selectedQueueRow.value) {
ElMessage.warning('请先选择要移出队列的患者')
return
}
try {
const row = selectedQueueRow.value
if (!row.id) {
ElMessage.warning('该队列项缺少ID无法保存删除请先刷新页面')
return
}
const patientName = row.patientName || '患者'
console.log('【心内科】开始移出队列:', patientName, 'encounterId:', row.encounterId)
await removeFromQueue(row.id)
selectedQueueRow.value = null
// 刷新数据(候选池和队列)
await loadDataFromApi()
console.log('【心内科】移出队列完成,患者应已重新出现在候选池中')
ElMessage.success(`已将 ${patientName} 移出队列,患者已重新出现在候选池中(已保存)`)
} catch (e) {
console.error('【心内科】handleRemoveFromQueue 异常:', e)
ElMessage.error('移出队列失败(未保存)')
}
}
// 是否可以上移
const canMoveUp = computed(() => {
if (!selectedQueueRow.value) return false
const index = originalQueueList.value.findIndex(
item => item.queueOrder === selectedQueueRow.value.queueOrder
)
return index > 0
})
// 是否可以下移
const canMoveDown = computed(() => {
if (!selectedQueueRow.value) return false
const index = originalQueueList.value.findIndex(
item => item.queueOrder === selectedQueueRow.value.queueOrder
)
return index >= 0 && index < originalQueueList.value.length - 1
})
// 上移——落库
const handleMoveUp = async () => {
if (!selectedQueueRow.value || !canMoveUp.value) return
if (!selectedQueueRow.value.id) {
ElMessage.warning('该队列项缺少ID无法保存排序请先刷新页面')
return
}
try {
await adjustQueueOrder({ id: selectedQueueRow.value.id, direction: 'up' })
await loadDataFromApi()
ElMessage.success('队列顺序已调整(已保存)')
} catch (e) {
console.error('【心内科】handleMoveUp 异常:', e)
ElMessage.error('队列顺序调整失败(未保存)')
}
}
// 下移——落库
const handleMoveDown = async () => {
if (!selectedQueueRow.value || !canMoveDown.value) return
if (!selectedQueueRow.value.id) {
ElMessage.warning('该队列项缺少ID无法保存排序请先刷新页面')
return
}
try {
await adjustQueueOrder({ id: selectedQueueRow.value.id, direction: 'down' })
await loadDataFromApi()
ElMessage.success('队列顺序已调整(已保存)')
} catch (e) {
console.error('【心内科】handleMoveDown 异常:', e)
ElMessage.error('队列顺序调整失败(未保存)')
}
}
let waitingTimer = null
const startWaitingTimer = () => {
if (waitingTimer) return
waitingTimer = window.setInterval(() => {
// 仅"等待"状态计算等待时间(基于创建时间)
const now = new Date()
originalQueueList.value.forEach((item) => {
if (item.status !== '等待') return
// 如果有创建时间,基于创建时间计算;否则累加(兼容旧数据)
if (item.createTime) {
try {
const createTime = new Date(item.createTime)
// 检查日期是否有效
if (!isNaN(createTime.getTime())) {
const diffSeconds = Math.floor((now - createTime) / 1000)
item.waitingTime = formatSecondsToMmSs(Math.max(0, diffSeconds))
}
} catch (e) {
// 解析失败时使用累加方式(兼容旧数据)
const seconds = parseMmSsToSeconds(item.waitingTime)
item.waitingTime = formatSecondsToMmSs(seconds + 1)
}
} else {
// 兼容旧数据:累加方式
const seconds = parseMmSsToSeconds(item.waitingTime)
item.waitingTime = formatSecondsToMmSs(seconds + 1)
}
})
}, 1000)
}
const stopWaitingTimer = () => {
if (!waitingTimer) return
window.clearInterval(waitingTimer)
waitingTimer = null
}
// 获取行样式类名(用于高亮当前叫号)
const getRowClassName = ({ row }) => {
const classes = []
if (row.status === '叫号中') classes.push('calling-row')
if (selectedQueueRow.value && row.queueOrder === selectedQueueRow.value.queueOrder) {
classes.push('selected-row')
}
return classes.join(' ')
}
// 获取状态标签类型
const getStatusTagType = (status) => {
const statusMap = {
'等待': 'info',
'叫号中': 'warning',
'就诊中': 'success',
'跳过': 'danger',
'已完成': ''
}
return statusMap[status] || ''
}
// 刷新数据(候选池=门诊挂号接口,队列=数据库接口)
const handleRefresh = async () => {
callType.value = '刷新'
console.log('【心内科】点击了刷新按钮,开始重新加载数据(候选池+队列)')
selectedCandidates.value = []
await loadDataFromApi()
// 确保等待计时器只有一个
stopWaitingTimer()
startWaitingTimer()
ElMessage.success('已刷新(已从数据库恢复队列)')
}
// 退出
const handleExit = () => {
ElMessage.info('退出功能待实现')
// TODO: 实现退出逻辑
}
// 后台配置
const handleConfig = () => {
// 打开配置弹窗并加载当前选中规则
if (rules.value.length > 0) {
editingIndex.value = 0
Object.assign(ruleForm, rules.value[0])
} else {
editingIndex.value = -1
Object.assign(ruleForm, {
name: '',
dept: '心内科',
desc: '',
priority: 10,
weeks: ['1', '2', '3', '4', '5'],
expr: '{}'
})
}
configDialogVisible.value = true
}
// 选呼——落库
const handleSelectCall = async () => {
if (!selectedQueueRow.value) {
ElMessage.warning('请先在右侧队列中选择一个患者')
return
}
try {
callType.value = '选呼'
if (!selectedQueueRow.value.id) {
ElMessage.warning('该队列项缺少ID无法保存选呼请先刷新页面')
return
}
// 检查患者状态,只有"等待"状态才能选呼
if (selectedQueueRow.value.status !== '等待') {
if (selectedQueueRow.value.status === '叫号中') {
ElMessage.info('该患者已经是"叫号中"状态')
return
} else {
ElMessage.warning('只能选呼"等待"状态的患者,当前患者状态为:"' + selectedQueueRow.value.status + '"')
return
}
}
await callPatient({ id: selectedQueueRow.value.id, organizationId: selectedQueueRow.value.organizationId })
// 只刷新队列数据,不重新加载候选池(避免候选池数据被重置)
await loadQueueFromDb()
const latest = originalQueueList.value.find((i) => i.id === selectedQueueRow.value.id)
if (latest) {
selectedQueueRow.value = latest
// 如果有多个"叫号中"的患者,显示当前选中的这个
currentCall.value = { number: String(latest.queueOrder), name: latest.patientName, room: latest.room }
}
// 统计当前"叫号中"的患者数量
const callingCount = originalQueueList.value.filter(i => i.status === '叫号中').length
if (callingCount > 1) {
ElMessage.success(`已选呼(已保存),当前共有 ${callingCount} 位患者处于"叫号中"状态`)
} else {
ElMessage.success('已选呼(已保存)')
}
} catch (e) {
console.error('【心内科】handleSelectCall 异常:', e)
ElMessage.error('选呼失败(未保存)')
}
}
// 下一患者——落库
const handleNextPatient = async () => {
try {
callType.value = '下一患者'
// 关键改进如果用户选中了患者优先使用选中患者的ID像 call 方法一样)
let reqData = {}
if (selectedQueueRow.value?.id) {
// 如果选中了患者直接使用其ID后端会通过ID直接获取不依赖查询条件
reqData.id = selectedQueueRow.value.id
reqData.organizationId = selectedQueueRow.value.organizationId
} else {
// 如果没有选中患者,使用查询条件(兼容旧逻辑)
let orgId = selectedDept.value !== 'all' ? selectedDept.value : undefined
// "全科"模式:优先用"当前叫号中/第一个等待"所在科室
if (orgId == null) {
const calling = originalQueueList.value.find((i) => i.status === '叫号中')
const waiting = originalQueueList.value.find((i) => i.status === '等待')
console.log('【心内科】handleNextPatient 查找:叫号中=', calling?.patientName, '等待=', waiting?.patientName)
orgId = calling?.organizationId ?? waiting?.organizationId
console.log('【心内科】handleNextPatient 确定的 orgId=', orgId)
}
if (orgId != null) {
reqData.organizationId = orgId
}
}
// 如果还是找不到,后端会自己找全科第一个叫号中/等待的
await nextPatient(reqData)
selectedQueueRow.value = null
await loadDataFromApi()
ElMessage.success('已呼叫下一位(已保存)')
} catch (e) {
console.error('【心内科】handleNextPatient 异常:', e)
// 从错误对象中提取错误消息
const errorMsg = e?.message || e?.response?.data?.msg || '当前范围内没有"等待"的患者'
ElMessage.warning(errorMsg)
}
}
// 跳过——落库(当前等同于“过号重排”)
const handleSkip = async () => {
try {
callType.value = '跳过'
// 关键改进如果用户选中了患者优先使用选中患者的ID像 call 方法一样)
let reqData = {}
if (selectedQueueRow.value?.id) {
// 如果选中了患者直接使用其ID后端会通过ID直接获取不依赖查询条件
reqData.id = selectedQueueRow.value.id
reqData.organizationId = selectedQueueRow.value.organizationId
} else {
// 如果没有选中患者,使用查询条件(兼容旧逻辑)
let orgId = selectedDept.value !== 'all' ? selectedDept.value : undefined
// “全科”模式:优先用“当前叫号中”所在科室
if (orgId == null) {
const calling = originalQueueList.value.find((i) => i.status === '叫号中')
orgId = calling?.organizationId
}
if (orgId != null) {
reqData.organizationId = orgId
}
}
await skipPatient(reqData)
selectedQueueRow.value = null
await loadDataFromApi()
ElMessage.success('已跳过(已保存)')
} catch (e) {
console.error('【心内科】handleSkip 异常:', e)
// 从错误对象中提取错误消息
const errorMsg = e?.message || e?.response?.data?.msg || '当前没有"叫号中"的患者'
ElMessage.warning(errorMsg)
}
}
// 完成——落库
const handleComplete = async () => {
try {
callType.value = '完成'
// 关键改进如果用户选中了患者优先使用选中患者的ID像 call 方法一样)
let reqData = {}
if (selectedQueueRow.value?.id) {
// 如果选中了患者直接使用其ID后端会通过ID直接获取不依赖查询条件
reqData.id = selectedQueueRow.value.id
reqData.organizationId = selectedQueueRow.value.organizationId
} else {
// 如果没有选中患者,使用查询条件(兼容旧逻辑)
let orgId = selectedDept.value !== 'all' ? selectedDept.value : undefined
// “全科”模式:优先用“当前叫号中”所在科室
if (orgId == null) {
const calling = originalQueueList.value.find((i) => i.status === '叫号中')
orgId = calling?.organizationId
}
if (orgId != null) {
reqData.organizationId = orgId
}
}
await completeCall(reqData)
selectedQueueRow.value = null
await loadDataFromApi()
ElMessage.success('已完成(已保存)')
} catch (e) {
console.error('【心内科】handleComplete 异常:', e)
// 从错误对象中提取错误消息
const errorMsg = e?.message || e?.response?.data?.msg || '当前没有"叫号中"的患者'
ElMessage.warning(errorMsg)
}
}
// 过号重排——落库
const handleRequeue = async () => {
try {
callType.value = '过号重排'
// 关键改进如果用户选中了患者优先使用选中患者的ID像 call 方法一样)
let reqData = {}
if (selectedQueueRow.value?.id) {
// 如果选中了患者直接使用其ID后端会通过ID直接获取不依赖查询条件
reqData.id = selectedQueueRow.value.id
reqData.organizationId = selectedQueueRow.value.organizationId
} else {
// 如果没有选中患者,使用查询条件(兼容旧逻辑)
let orgId = selectedDept.value !== 'all' ? selectedDept.value : undefined
// “全科”模式:优先用“当前叫号中”所在科室
if (orgId == null) {
const calling = originalQueueList.value.find((i) => i.status === '叫号中')
orgId = calling?.organizationId
}
if (orgId != null) {
reqData.organizationId = orgId
}
}
await requeuePatient(reqData)
// 如果执行到这里,说明操作成功
selectedQueueRow.value = null
await loadDataFromApi()
ElMessage.success('已过号重排(已保存)')
} catch (e) {
console.error('【心内科】handleRequeue 异常:', e)
// 从错误对象中提取错误消息
// 注意:当后端返回 R.fail() 时code=500响应拦截器会 Promise.reject(new Error(msg))
// 所以错误消息在 e.message 中
const errorMsg = e?.message || e?.response?.data?.msg || '当前没有等待中的患者'
ElMessage.warning(errorMsg)
// 失败时不要刷新数据,避免显示错误的状态
// await loadDataFromApi() // 注释掉,避免刷新数据
}
}
// 后台配置:选择规则
const handleSelectRule = (index) => {
editingIndex.value = index
Object.assign(ruleForm, rules.value[index])
}
// 后台配置:保存当前规则到列表
const persistRuleForm = () => {
if (!ruleForm.name || !ruleForm.priority) {
ElMessage.warning('请填写规则名称和优先级')
return false
}
if (!Array.isArray(ruleForm.weeks) || ruleForm.weeks.length === 0) {
ElMessage.warning('请选择生效周几')
return false
}
const parsed = tryParseExpr(ruleForm.expr)
if (parsed === null) return false
const target = {
name: ruleForm.name,
dept: ruleForm.dept || '心内科',
desc: ruleForm.desc || '',
priority: Number(ruleForm.priority) || 0,
weeks: [...ruleForm.weeks],
expr: ruleForm.expr
}
if (editingIndex.value >= 0 && editingIndex.value < rules.value.length) {
rules.value.splice(editingIndex.value, 1, target)
} else {
rules.value.push(target)
editingIndex.value = rules.value.length - 1
}
return true
}
// JSON 语法检查
const tryParseExpr = (expr) => {
try {
JSON.parse(expr || '{}')
return true
} catch (e) {
ElMessage.error('条件表达式 JSON 语法错误')
return null
}
}
// 后台配置:新增规则
const handleAddRule = () => {
const ok = persistRuleForm()
if (!ok) return
const newRule = {
name: '新规则',
dept: '心内科',
desc: '',
priority: rules.value.length * 10 + 10,
weeks: ['1', '2', '3', '4', '5'],
expr: '{}'
}
rules.value.push(newRule)
editingIndex.value = rules.value.length - 1
Object.assign(ruleForm, newRule)
}
// 后台配置:删除规则
const handleDeleteRule = (index) => {
rules.value.splice(index, 1)
if (rules.value.length === 0) {
editingIndex.value = -1
Object.assign(ruleForm, {
name: '',
dept: '心内科',
desc: '',
priority: 10,
weeks: ['1', '2', '3', '4', '5'],
expr: '{}'
})
return
}
const newIndex = Math.max(0, Math.min(index, rules.value.length - 1))
editingIndex.value = newIndex
Object.assign(ruleForm, rules.value[newIndex])
}
// 后台配置:上移
const handleRuleMoveUp = (index) => {
if (index <= 0) return
;[rules.value[index - 1], rules.value[index]] = [rules.value[index], rules.value[index - 1]]
editingIndex.value = index - 1
}
// 后台配置:下移
const handleRuleMoveDown = (index) => {
if (index >= rules.value.length - 1) return
;[rules.value[index + 1], rules.value[index]] = [rules.value[index], rules.value[index + 1]]
editingIndex.value = index + 1
}
// 后台配置:保存当前规则
const handleSaveCurrentRule = () => {
const ok = persistRuleForm()
if (ok) {
ElMessage.success('当前规则已保存')
}
}
// 后台配置:保存全部(本地模拟)
const handleSaveAllRules = () => {
const ok = persistRuleForm()
if (!ok) return
// TODO: 调用后端保存接口
ElMessage.success('全部规则已保存(前端模拟)')
}
// 后台配置:语法检查
const handleValidateRule = () => {
const ok = tryParseExpr(ruleForm.expr)
if (ok) {
ElMessage.success('语法检查通过')
}
}
// 构建条件对象(公共函数)
const buildConditions = () => {
const conditions = {}
// 年龄条件
if (quickGeneratorForm.ageOperator && quickGeneratorForm.ageValue !== null) {
conditions.age = `${quickGeneratorForm.ageOperator}${quickGeneratorForm.ageValue}`
}
// 号别
if (quickGeneratorForm.regType) {
conditions.regType = quickGeneratorForm.regType
}
// 科室
if (quickGeneratorForm.dept) {
conditions.dept = quickGeneratorForm.dept
}
// 医生
if (quickGeneratorForm.doctor) {
conditions.doctor = quickGeneratorForm.doctor
}
// 自定义条件
if (quickGeneratorForm.customKey && quickGeneratorForm.customValue) {
conditions[quickGeneratorForm.customKey] = quickGeneratorForm.customValue
}
return conditions
}
// 预览JSON计算属性
const previewJson = computed(() => {
return JSON.stringify(buildConditions(), null, 2)
})
// 后台配置:快速生成器
const handleQuickGenerate = () => {
// 如果当前表达式不为空,尝试解析并填充表单
if (ruleForm.expr) {
try {
const parsed = JSON.parse(ruleForm.expr)
// 解析年龄条件
if (parsed.age) {
const ageMatch = parsed.age.match(/^(>=|<=|=|>|<)(\d+)$/)
if (ageMatch) {
quickGeneratorForm.ageOperator = ageMatch[1]
quickGeneratorForm.ageValue = parseInt(ageMatch[2])
}
}
// 填充其他字段
if (parsed.regType) quickGeneratorForm.regType = parsed.regType
if (parsed.dept) quickGeneratorForm.dept = parsed.dept
if (parsed.doctor) quickGeneratorForm.doctor = parsed.doctor
// 填充自定义字段(排除已知字段)
const knownFields = ['age', 'regType', 'dept', 'doctor']
for (const key in parsed) {
if (!knownFields.includes(key)) {
quickGeneratorForm.customKey = key
quickGeneratorForm.customValue = parsed[key]
break
}
}
} catch (e) {
// 如果解析失败,清空表单
resetQuickGeneratorForm()
}
} else {
// 如果表达式为空,重置表单
resetQuickGeneratorForm()
}
quickGeneratorDialogVisible.value = true
}
// 重置快速生成器表单
const resetQuickGeneratorForm = () => {
quickGeneratorForm.ageOperator = null
quickGeneratorForm.ageValue = null
quickGeneratorForm.regType = null
quickGeneratorForm.dept = null
quickGeneratorForm.doctor = null
quickGeneratorForm.customKey = ''
quickGeneratorForm.customValue = ''
}
// 应用快速生成器生成的JSON
const handleApplyQuickGenerate = () => {
const conditions = buildConditions()
// 检查是否至少有一个条件
if (Object.keys(conditions).length === 0) {
ElMessage.warning('请至少设置一个条件')
return
}
// 应用到规则表单
ruleForm.expr = JSON.stringify(conditions, null, 2)
// 关闭对话框
quickGeneratorDialogVisible.value = false
ElMessage.success('条件表达式已生成并应用')
}
// 后台配置:测试规则(占位)
const handleTestRule = () => {
const ok = tryParseExpr(ruleForm.expr)
if (!ok) return
ElMessage.info('测试规则功能待接入后端,当前为占位')
}
// 组件挂载
onMounted(() => {
// 加载就诊科室列表
loadDepartmentList()
// 初始化:优先从后端加载,失败则回退本地假数据
loadDataFromApi()
startWaitingTimer()
})
onUnmounted(() => {
stopWaitingTimer()
})
</script>
<style lang="scss" scoped>
.smart-triage-queue {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
background-color: #f5f5f5;
padding: 20px;
box-sizing: border-box;
overflow: hidden;
// 顶部标题栏
.header-section {
display: flex;
justify-content: space-between;
align-items: flex-start;
background-color: #fff;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.header-left {
flex: 1;
display: flex;
align-items: center;
.title {
font-size: 24px;
font-weight: bold;
color: #333;
}
.header-info {
display: flex;
gap: 20px;
margin-bottom: 10px;
font-size: 14px;
color: #666;
.date-info {
font-weight: 500;
}
.stat-info {
color: #409eff;
}
}
.current-call-info {
font-size: 16px;
color: #333;
font-weight: 500;
}
}
.header-right {
display: flex;
gap: 10px;
}
}
// 主要内容区域
.main-content {
flex: 1;
display: flex;
gap: 20px;
margin-bottom: 20px;
min-height: 0;
.left-panel,
.right-panel {
flex: 1;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
overflow: hidden;
.panel-header {
padding: 15px 20px;
border-bottom: 2px solid #409eff;
background-color: #f8f9fa;
.panel-title {
font-size: 16px;
font-weight: bold;
color: #333;
}
}
.table-container {
flex: 1;
overflow: hidden;
padding: 10px;
}
.display-options {
padding: 10px 20px;
border-top: 1px solid #ebeef5;
display: flex;
gap: 10px;
justify-content: space-between;
align-items: center;
.queue-actions-left {
display: flex;
gap: 10px;
}
.queue-actions-right {
display: flex;
gap: 10px;
}
}
.candidate-actions {
padding: 10px 20px;
border-top: 1px solid #ebeef5;
display: flex;
gap: 10px;
justify-content: flex-start;
}
}
}
// 底部控制面板
.footer-section {
background-color: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.filter-section {
margin-bottom: 20px;
.filter-label {
font-size: 14px;
font-weight: bold;
color: #333;
margin-bottom: 10px;
}
.filter-select-wrapper {
width: 100%;
}
}
.call-control-section {
margin-bottom: 20px;
padding: 15px;
background-color: #f8f9fa;
border-radius: 6px;
.call-control-label {
font-size: 14px;
font-weight: bold;
color: #333;
margin-bottom: 10px;
}
.call-control-content {
display: flex;
justify-content: space-between;
align-items: center;
.current-call-display {
font-size: 16px;
color: #333;
font-weight: 500;
}
.control-buttons {
display: flex;
gap: 10px;
}
}
}
.led-section {
margin-bottom: 15px;
padding: 15px;
background-color: #1a1a1a;
border-radius: 6px;
display: flex;
align-items: center;
gap: 10px;
.led-label {
font-size: 14px;
color: #fff;
font-weight: bold;
}
.led-display {
flex: 1;
font-size: 20px;
color: #00ff00;
font-weight: bold;
font-family: 'Courier New', monospace;
text-shadow: 0 0 10px rgba(0, 255, 0, 0.5);
}
}
.voice-section {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
background-color: #f0f0f0;
border-radius: 6px;
.voice-label {
font-size: 14px;
color: #666;
font-weight: bold;
}
.voice-text {
font-size: 14px;
color: #333;
}
}
// 配置弹窗样式
.config-container {
display: flex;
gap: 20px;
min-height: 500px;
}
.config-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0 12px 0;
}
.config-topbar-actions {
display: flex;
gap: 12px;
}
.config-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.config-dialog-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.config-left {
width: 30%;
background: #f8f9fb;
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 12px;
box-sizing: border-box;
}
.rule-card {
border: 1px solid #dcdfe6;
border-radius: 6px;
padding: 14px 14px 12px;
margin: 0 6px 14px;
background: #fff;
cursor: pointer;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.06);
position: relative;
}
.rule-card.active {
border-color: #409eff;
box-shadow: 0 0 0 2px #409eff22;
}
.rule-card.active::before {
content: '';
position: absolute;
left: 0;
top: 10px;
bottom: 10px;
width: 4px;
background: #409eff;
border-radius: 0 4px 4px 0;
}
.rule-card:hover {
border-color: #b3d8ff;
}
.rule-title {
font-weight: 600;
margin-bottom: 4px;
}
.rule-sub {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.rule-actions {
display: flex;
gap: 6px;
margin-top: 6px;
padding-top: 8px;
border-top: 1px dashed #ebeef5;
}
.config-right {
flex: 1;
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 16px;
box-sizing: border-box;
background: #fff;
}
.config-form-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
font-weight: 600;
}
.config-form-buttons {
display: flex;
gap: 8px;
}
.config-inline-actions {
display: flex;
gap: 12px;
padding-left: 110px; // 对齐 label-width
margin-top: -6px;
}
.config-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.config-fullwidth {
width: 100%;
}
}
}
// 表格行样式
:deep(.calling-row) {
background-color: #fffbe6 !important;
}
:deep(.selected-row) {
background-color: #e8f4ff !important;
}
:deep(.el-table) {
.el-table__body {
tr.calling-row {
background-color: #fffbe6;
}
tr.selected-row {
background-color: #e8f4ff;
}
}
}
</style>