1340 lines
38 KiB
Vue
1340 lines
38 KiB
Vue
<template>
|
||
<div class="reception-page">
|
||
<!-- 页头 -->
|
||
<div class="reception-header">
|
||
<div class="header-left">
|
||
<h1 class="header-title">接诊台</h1>
|
||
<!-- <div class="header-desc">显示今日待接诊与已过号患者,左侧选人,右侧处理。</div> -->
|
||
<div class="header-meta">{{ todayMeta }}</div>
|
||
</div>
|
||
<div class="header-right">
|
||
<div class="live-pill">
|
||
<span class="live-dot" :class="{ paused: pollPaused }"></span>
|
||
<span>{{ pollPaused ? '已暂停' : `${countdown}s 后刷新` }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 筛选条 -->
|
||
<div class="filter-shell">
|
||
<el-input
|
||
v-model="searchName"
|
||
placeholder="请输入患者姓名"
|
||
clearable
|
||
size="default"
|
||
class="search-input"
|
||
@keyup.enter="handleSearch"
|
||
@clear="handleSearch"
|
||
>
|
||
<template #prefix>
|
||
<el-icon><Search /></el-icon>
|
||
</template>
|
||
</el-input>
|
||
<div class="filter-tabs">
|
||
<span
|
||
class="filter-tab"
|
||
:class="{ active: activeStatus === 1 }"
|
||
@click="switchStatus(1)"
|
||
>待接诊 {{ waitingTotal }} 人</span>
|
||
<span class="filter-sep">·</span>
|
||
<span
|
||
class="filter-tab"
|
||
:class="{ active: activeStatus === 4 }"
|
||
@click="switchStatus(4)"
|
||
>已过号 {{ passedTotal }} 人</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主体 -->
|
||
<div class="main-area">
|
||
<!-- 左侧:待接诊队列 -->
|
||
<aside class="queue-col">
|
||
<div class="queue-panel">
|
||
<div class="queue-head">
|
||
<span class="queue-title">{{ activeStatus === 1 ? '待接诊' : '已过号' }}</span>
|
||
<span class="queue-meta">{{ total }} 人</span>
|
||
</div>
|
||
<div ref="queueScrollRef" v-loading="queueLoading && queue.length === 0" class="queue-list">
|
||
<div
|
||
v-for="row in queue"
|
||
:key="row.id"
|
||
class="queue-row"
|
||
:class="{ active: selectedId === row.id, passed: row.status === 4 }"
|
||
@click="selectRow(row)"
|
||
>
|
||
<div class="row-name">
|
||
<div class="name-line">
|
||
{{ row.patient_name || '未命名' }}
|
||
<span class="name-meta">{{ formatGenderAge(row) }}</span>
|
||
<el-tag v-if="row.status === 4" size="small" type="warning" class="ml-1">已过号</el-tag>
|
||
</div>
|
||
<div class="time-line">
|
||
{{ formatTimeHM(row.appointment_time) }} · 医助 {{ row.assistant_name || '—' }}
|
||
</div>
|
||
</div>
|
||
<div class="row-actions" @click.stop>
|
||
<el-tooltip content="通知医助(企业微信)" placement="top">
|
||
<button
|
||
class="row-icon-btn notify"
|
||
:disabled="notifyingId === row.id"
|
||
@click="handleNotify(row)"
|
||
>
|
||
<el-icon v-if="notifyingId === row.id" class="is-loading"><Loading /></el-icon>
|
||
<el-icon v-else><ChatDotRound /></el-icon>
|
||
</button>
|
||
</el-tooltip>
|
||
<el-tooltip content="发起通话" placement="top">
|
||
<button class="row-icon-btn call" @click="handleCall(row)">
|
||
<el-icon><Phone /></el-icon>
|
||
</button>
|
||
</el-tooltip>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="queue.length > 0" ref="sentinelRef" class="scroll-sentinel">
|
||
<span v-if="loadingMore">加载中…</span>
|
||
<span v-else-if="!hasMore" class="no-more">— 已全部加载 —</span>
|
||
</div>
|
||
|
||
<el-empty v-if="!queueLoading && queue.length === 0" description="暂无待接诊患者" :image-size="64" />
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- 右侧:患者信息 + 病例 + 血糖血压 -->
|
||
<section class="sidebar" v-loading="detailLoading && !detail">
|
||
<template v-if="!selectedId">
|
||
<div class="placeholder-card">
|
||
<el-empty description="请从左侧选择一位待接诊患者" />
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="detail">
|
||
<!-- 患者信息 -->
|
||
<PatientInfoCard :apt="apt" :diag="diag" />
|
||
|
||
<!-- 患者病例(与 tcm/diagnosis/readonly 共用同一组件,确保两处字段一致) -->
|
||
<PatientCaseCard :apt="apt" :diag="diag" />
|
||
|
||
<!-- 医生备注 & 舌苔照片 -->
|
||
<div class="card note-card">
|
||
<div class="card-title">医生备注 & 舌苔照片 & 检查报告</div>
|
||
<NoteTimeline
|
||
:notes="doctorNotes"
|
||
:diagnosis-id="diag?.id"
|
||
@refresh="handleDeskOperationRefresh"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 跟踪备注(只读时间轴;新增入口在「编辑诊单」内) -->
|
||
<div class="card tracking-note-card">
|
||
<div class="card-title">跟踪备注</div>
|
||
<TrackingNoteTimeline :notes="trackingNotes" readonly />
|
||
</div>
|
||
|
||
<!-- 跟踪信息(与只读病例页同口径,自治 lazy load) -->
|
||
<div class="card metric-card">
|
||
<div class="card-title">跟踪信息</div>
|
||
<TrackingMatrix
|
||
:diagnosis-id="Number(diag?.id ?? 0)"
|
||
:age="diag?.age ?? null"
|
||
/>
|
||
</div>
|
||
</template>
|
||
</section>
|
||
</div>
|
||
|
||
<!-- 悬浮操作栏 -->
|
||
<transition name="fade">
|
||
<div v-if="selectedId && detail" class="float-bar">
|
||
<el-button
|
||
v-perms="['doctor.appointment/addDoctorNote']"
|
||
class="float-action note"
|
||
size="large"
|
||
@click="showNoteDialog = true"
|
||
>
|
||
备注
|
||
</el-button>
|
||
<el-button
|
||
v-perms="['tcm.diagnosis/edit']"
|
||
class="float-action edit"
|
||
size="large"
|
||
@click="handleEditDiagnosis">
|
||
编辑病历
|
||
</el-button>
|
||
<el-button
|
||
v-if="apt.status === 1 || apt.status === 4"
|
||
v-perms="['doctor.appointment/complete']"
|
||
class="float-action finish"
|
||
size="large"
|
||
:loading="finishLoading"
|
||
@click="handleFinish"
|
||
>
|
||
完成接诊
|
||
</el-button>
|
||
</div>
|
||
</transition>
|
||
|
||
<!-- 备注弹窗 -->
|
||
<el-dialog v-model="showNoteDialog" title="添加备注" width="480px" append-to-body>
|
||
<el-input v-model="noteContent" type="textarea" :rows="4" placeholder="输入今日备注..." maxlength="500" show-word-limit />
|
||
<template #footer>
|
||
<el-button @click="showNoteDialog = false">取消</el-button>
|
||
<el-button type="primary" :loading="noteSaving" @click="handleSaveNote">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 复用:通话弹窗 + 诊单编辑 -->
|
||
<component :is="ChatDialog" ref="chatDialogRef" />
|
||
<component :is="DiagnosisEdit" ref="diagnosisEditRef" @success="onDiagnosisEditSuccess" />
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { defineAsyncComponent, onMounted, onUnmounted } from 'vue'
|
||
import { receptionQueue, receptionDetail, notifyAssistant, addDoctorNote } from '@/api/patient'
|
||
import NoteTimeline from './components/NoteTimeline.vue'
|
||
import PatientInfoCard from '@/views/tcm/diagnosis/components/PatientInfoCard.vue'
|
||
import PatientCaseCard from '@/views/tcm/diagnosis/components/PatientCaseCard.vue'
|
||
import TrackingMatrix from '@/views/tcm/diagnosis/components/TrackingMatrix.vue'
|
||
import TrackingNoteTimeline from '@/views/tcm/diagnosis/components/TrackingNoteTimeline.vue'
|
||
import { completeAppointment } from '@/api/doctor'
|
||
import { getCallSignature } from '@/api/tcm'
|
||
import feedback from '@/utils/feedback'
|
||
import { formatDiabetesDiscoveryDisplay } from '@/utils/diabetes-discovery-display'
|
||
import { formatGender } from '@/utils/diag-display'
|
||
import {
|
||
Phone,
|
||
Search,
|
||
ChatDotRound,
|
||
Loading
|
||
} from '@element-plus/icons-vue'
|
||
|
||
const ChatDialog = defineAsyncComponent(() => import('@/components/chat-dialog/index.vue'))
|
||
const DiagnosisEdit = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||
|
||
const POLL_MS = 5_000
|
||
const PAGE_SIZE = 15
|
||
|
||
interface QueueRow {
|
||
id: number
|
||
patient_id: number
|
||
patient_name: string
|
||
patient_phone?: string
|
||
diagnosis_id?: number
|
||
doctor_id?: number
|
||
doctor_name?: string
|
||
assistant_id?: number
|
||
assistant_name?: string
|
||
appointment_date?: string
|
||
appointment_time?: string
|
||
gender?: number
|
||
age?: number | null
|
||
status?: number
|
||
status_desc?: string
|
||
has_prescription?: number
|
||
remark?: string
|
||
}
|
||
|
||
const activeStatus = ref<1 | 4>(1)
|
||
const queue = ref<QueueRow[]>([])
|
||
const total = ref(0)
|
||
const waitingTotal = ref(0)
|
||
const passedTotal = ref(0)
|
||
const page = ref(1)
|
||
const hasMore = ref(true)
|
||
const loadingMore = ref(false)
|
||
const queueLoading = ref(false)
|
||
const detailLoading = ref(false)
|
||
const selectedId = ref<number | null>(null)
|
||
const detail = ref<any>(null)
|
||
const finishLoading = ref(false)
|
||
const searchName = ref('')
|
||
const pollPaused = ref(false)
|
||
const countdown = ref(POLL_MS / 1000)
|
||
const notifyingId = ref<number | null>(null)
|
||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||
/** 轮询请求未返回时的防重入标志,避免 5s 间隔下网络波动造成请求堆积 */
|
||
let queueInflight = false
|
||
let detailInflight = false
|
||
let scrollObserver: IntersectionObserver | null = null
|
||
let queueRequestSeq = 0
|
||
let detailRequestSeq = 0
|
||
|
||
const queueScrollRef = ref<HTMLElement | null>(null)
|
||
const sentinelRef = ref<HTMLElement | null>(null)
|
||
const chatDialogRef = ref()
|
||
const diagnosisEditRef = ref()
|
||
const showNoteDialog = ref(false)
|
||
const noteContent = ref('')
|
||
const noteSaving = ref(false)
|
||
|
||
const apt = computed<any>(() => detail.value?.appointment || {})
|
||
const diag = computed<any>(() => detail.value?.diagnosis || {})
|
||
const doctorNotes = computed<any[]>(() => detail.value?.doctor_notes || [])
|
||
const trackingNotes = computed<any[]>(() => detail.value?.tracking_notes || [])
|
||
|
||
const todayMeta = computed(() => {
|
||
const d = new Date()
|
||
const week = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()]
|
||
const pad = (n: number) => String(n).padStart(2, '0')
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${week}`
|
||
})
|
||
|
||
|
||
const todayStr = () => {
|
||
const d = new Date()
|
||
const pad = (n: number) => String(n).padStart(2, '0')
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||
}
|
||
|
||
const formatGenderAge = (row: QueueRow) => {
|
||
const parts = [formatGender(row.gender), row.age != null ? row.age + '岁' : '']
|
||
return parts.filter((p) => p && p !== '—').join(' ')
|
||
}
|
||
|
||
const formatTimeHM = (time?: string) => {
|
||
if (!time) return '—'
|
||
return time.replace(/^(\d{2}:\d{2})(:\d{2})?$/, '$1')
|
||
}
|
||
|
||
const fetchQueuePage = async (pageNo: number, status?: number, patientName?: string) => {
|
||
const today = todayStr()
|
||
const res: any = await receptionQueue({
|
||
status: status ?? activeStatus.value,
|
||
start_date: today,
|
||
end_date: today,
|
||
patient_name: (patientName ?? searchName.value) || undefined,
|
||
page_no: pageNo,
|
||
page_size: PAGE_SIZE
|
||
})
|
||
const list: QueueRow[] = res?.lists || res?.data || []
|
||
const count = Number(res?.count ?? res?.total ?? 0)
|
||
return { list, count }
|
||
}
|
||
|
||
const fetchOtherCount = async (baseStatus: 1 | 4, patientName: string, requestSeq: number) => {
|
||
const otherStatus = baseStatus === 1 ? 4 : 1
|
||
try {
|
||
const { count } = await fetchQueuePage(1, otherStatus, patientName || undefined)
|
||
if (requestSeq !== queueRequestSeq || activeStatus.value !== baseStatus || searchName.value !== patientName) return
|
||
if (otherStatus === 1) waitingTotal.value = count
|
||
else passedTotal.value = count
|
||
} catch { /* 统计计数失败不阻塞 */ }
|
||
}
|
||
|
||
const loadQueue = async (opts?: { silent?: boolean; autoSelect?: boolean }) => {
|
||
const silent = opts?.silent === true
|
||
if (silent && queueInflight) return
|
||
const requestSeq = ++queueRequestSeq
|
||
const statusSnapshot = activeStatus.value
|
||
const searchSnapshot = searchName.value
|
||
const autoSelect = opts?.autoSelect ?? !silent
|
||
queueInflight = true
|
||
if (!silent) queueLoading.value = true
|
||
try {
|
||
page.value = 1
|
||
const { list, count } = await fetchQueuePage(1, statusSnapshot, searchSnapshot || undefined)
|
||
if (requestSeq !== queueRequestSeq || activeStatus.value !== statusSnapshot || searchName.value !== searchSnapshot) return
|
||
queue.value = list
|
||
total.value = count || list.length
|
||
if (statusSnapshot === 1) waitingTotal.value = count
|
||
else passedTotal.value = count
|
||
hasMore.value = list.length >= PAGE_SIZE && queue.value.length < (count || 0)
|
||
|
||
void fetchOtherCount(statusSnapshot, searchSnapshot, requestSeq)
|
||
|
||
if (selectedId.value && !list.some((r) => r.id === selectedId.value)) {
|
||
selectedId.value = null
|
||
detail.value = null
|
||
}
|
||
if (!selectedId.value && autoSelect && list.length > 0) {
|
||
await selectRow(list[0])
|
||
}
|
||
} catch (e) {
|
||
if (!silent) console.error('加载队列失败:', e)
|
||
} finally {
|
||
if (requestSeq === queueRequestSeq) {
|
||
queueLoading.value = false
|
||
queueInflight = false
|
||
}
|
||
}
|
||
}
|
||
|
||
const loadMore = async () => {
|
||
if (loadingMore.value || !hasMore.value) return
|
||
const statusSnapshot = activeStatus.value
|
||
const searchSnapshot = searchName.value
|
||
loadingMore.value = true
|
||
try {
|
||
const nextPage = page.value + 1
|
||
const { list, count } = await fetchQueuePage(nextPage, statusSnapshot, searchSnapshot || undefined)
|
||
if (activeStatus.value !== statusSnapshot || searchName.value !== searchSnapshot) return
|
||
// 去重合并(防止轮询时上方有新插入导致重复)
|
||
const existing = new Set(queue.value.map((r) => r.id))
|
||
const appended = list.filter((r) => !existing.has(r.id))
|
||
queue.value = [...queue.value, ...appended]
|
||
page.value = nextPage
|
||
total.value = count || total.value
|
||
hasMore.value = list.length >= PAGE_SIZE && queue.value.length < (count || 0)
|
||
} catch (e) {
|
||
console.error('加载更多失败:', e)
|
||
} finally {
|
||
loadingMore.value = false
|
||
}
|
||
}
|
||
|
||
const refreshDetailSilently = async () => {
|
||
if (!selectedId.value || detailInflight) return
|
||
const currentId = selectedId.value
|
||
const requestSeq = ++detailRequestSeq
|
||
detailInflight = true
|
||
try {
|
||
const res: any = await receptionDetail({ id: currentId })
|
||
if (requestSeq !== detailRequestSeq || selectedId.value !== currentId) return
|
||
detail.value = res || detail.value
|
||
} catch (e) {
|
||
/* 静默失败 */
|
||
} finally {
|
||
if (requestSeq === detailRequestSeq) detailInflight = false
|
||
}
|
||
}
|
||
|
||
const selectRow = async (row: QueueRow) => {
|
||
if (selectedId.value === row.id) return
|
||
const currentId = row.id
|
||
const requestSeq = ++detailRequestSeq
|
||
selectedId.value = row.id
|
||
detail.value = null
|
||
detailLoading.value = true
|
||
detailInflight = true
|
||
restartPolling()
|
||
try {
|
||
const res: any = await receptionDetail({ id: currentId })
|
||
if (requestSeq !== detailRequestSeq || selectedId.value !== currentId) return
|
||
detail.value = res || null
|
||
} catch (e) {
|
||
console.error('加载详情失败:', e)
|
||
feedback.msgError('加载患者详情失败')
|
||
} finally {
|
||
if (requestSeq === detailRequestSeq) {
|
||
detailLoading.value = false
|
||
detailInflight = false
|
||
}
|
||
}
|
||
}
|
||
|
||
const switchStatus = async (status: 1 | 4) => {
|
||
if (activeStatus.value === status) return
|
||
activeStatus.value = status
|
||
selectedId.value = null
|
||
detail.value = null
|
||
await loadQueue()
|
||
restartPolling()
|
||
}
|
||
|
||
const handleSearch = async () => {
|
||
selectedId.value = null
|
||
detail.value = null
|
||
await loadQueue()
|
||
restartPolling()
|
||
}
|
||
|
||
const handleCall = async (row: QueueRow) => {
|
||
if (!row.patient_id) {
|
||
feedback.msgWarning('患者信息不完整')
|
||
return
|
||
}
|
||
try {
|
||
const res = await getCallSignature({
|
||
patient_id: row.patient_id,
|
||
diagnosis_id: row.diagnosis_id || row.id
|
||
})
|
||
chatDialogRef.value?.open({
|
||
patientId: row.patient_id,
|
||
patientName: row.patient_name,
|
||
diagnosisId: row.diagnosis_id || row.id,
|
||
signatureData: res
|
||
})
|
||
} catch (error: any) {
|
||
console.error('获取通话签名失败:', error)
|
||
feedback.msgError(error?.message || '获取通话签名失败')
|
||
}
|
||
}
|
||
|
||
const handleNotify = async (row: QueueRow) => {
|
||
if (notifyingId.value) return
|
||
notifyingId.value = row.id
|
||
try {
|
||
await notifyAssistant({ id: row.id })
|
||
feedback.msgSuccess(`已通知医助 ${row.assistant_name || ''}`)
|
||
await handleDeskOperationRefresh()
|
||
} catch (e: any) {
|
||
feedback.msgError(e?.message || '通知失败')
|
||
} finally {
|
||
notifyingId.value = null
|
||
}
|
||
}
|
||
|
||
const handleEditDiagnosis = () => {
|
||
if (!diag.value?.patient_id) return
|
||
diagnosisEditRef.value?.open('edit', diag.value.patient_id)
|
||
}
|
||
|
||
const onDiagnosisEditSuccess = async () => {
|
||
await handleDeskOperationRefresh()
|
||
}
|
||
|
||
const handleDeskOperationRefresh = async () => {
|
||
const currentId = selectedId.value
|
||
await loadQueue({ silent: true, autoSelect: false })
|
||
if (currentId && selectedId.value === currentId) {
|
||
await refreshDetailSilently()
|
||
}
|
||
restartPolling()
|
||
}
|
||
|
||
const handleFinish = async () => {
|
||
if (!apt.value?.id) return
|
||
try {
|
||
await feedback.confirm('确认将该患者标记为「已完成接诊」?此操作不可撤销。')
|
||
} catch {
|
||
return
|
||
}
|
||
finishLoading.value = true
|
||
try {
|
||
await completeAppointment({ id: apt.value.id })
|
||
feedback.msgSuccess('已完成接诊')
|
||
selectedId.value = null
|
||
detail.value = null
|
||
await loadQueue({ autoSelect: true })
|
||
restartPolling()
|
||
} catch (e: any) {
|
||
feedback.msgError(e?.message || '操作失败')
|
||
} finally {
|
||
finishLoading.value = false
|
||
}
|
||
}
|
||
|
||
const handleSaveNote = async () => {
|
||
const diagId = diag.value?.id
|
||
if (!diagId) {
|
||
feedback.msgWarning('当前患者无诊单,无法添加备注')
|
||
return
|
||
}
|
||
const text = noteContent.value.trim()
|
||
if (!text) {
|
||
feedback.msgWarning('请输入备注内容')
|
||
return
|
||
}
|
||
noteSaving.value = true
|
||
try {
|
||
await addDoctorNote({ diagnosis_id: diagId, content: text })
|
||
feedback.msgSuccess('备注保存成功')
|
||
noteContent.value = ''
|
||
showNoteDialog.value = false
|
||
await handleDeskOperationRefresh()
|
||
} catch (e: any) {
|
||
feedback.msgError(e?.message || '保存失败')
|
||
} finally {
|
||
noteSaving.value = false
|
||
}
|
||
}
|
||
|
||
const resetCountdown = () => {
|
||
countdown.value = POLL_MS / 1000
|
||
}
|
||
|
||
const startCountdown = () => {
|
||
if (countdownTimer) clearInterval(countdownTimer)
|
||
resetCountdown()
|
||
countdownTimer = setInterval(() => {
|
||
if (pollPaused.value) return
|
||
if (countdown.value > 1) {
|
||
countdown.value -= 1
|
||
} else {
|
||
countdown.value = 1
|
||
}
|
||
}, 1000)
|
||
}
|
||
|
||
const scheduleNextPoll = () => {
|
||
if (pollTimer) clearTimeout(pollTimer)
|
||
if (pollPaused.value) return
|
||
startCountdown()
|
||
pollTimer = setTimeout(async () => {
|
||
await handleDeskOperationRefresh()
|
||
}, POLL_MS)
|
||
}
|
||
|
||
const restartPolling = () => {
|
||
scheduleNextPoll()
|
||
}
|
||
|
||
const startPolling = () => {
|
||
stopPolling()
|
||
scheduleNextPoll()
|
||
}
|
||
|
||
const stopPolling = () => {
|
||
if (pollTimer) {
|
||
clearTimeout(pollTimer)
|
||
pollTimer = null
|
||
}
|
||
if (countdownTimer) {
|
||
clearInterval(countdownTimer)
|
||
countdownTimer = null
|
||
}
|
||
}
|
||
|
||
const handleVisibilityChange = () => {
|
||
pollPaused.value = document.hidden
|
||
if (!document.hidden) {
|
||
void handleDeskOperationRefresh()
|
||
} else {
|
||
stopPolling()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 浏览器扩展(如钱包/翻译插件)注入的 content script 在页面卸载后投递消息,
|
||
* 常见报错 "Could not establish connection. Receiving end does not exist."
|
||
* 与本应用无关,静默吞掉避免污染控制台。
|
||
*/
|
||
const onUnhandledRejection = (e: PromiseRejectionEvent) => {
|
||
const msg = String(e?.reason?.message || e?.reason || '')
|
||
if (/Receiving end does not exist|Could not establish connection/i.test(msg)) {
|
||
e.preventDefault()
|
||
}
|
||
}
|
||
|
||
const setupScrollObserver = () => {
|
||
if (!sentinelRef.value || !queueScrollRef.value) return
|
||
scrollObserver?.disconnect()
|
||
scrollObserver = new IntersectionObserver(
|
||
(entries) => {
|
||
for (const entry of entries) {
|
||
if (entry.isIntersecting) loadMore()
|
||
}
|
||
},
|
||
{ root: queueScrollRef.value, rootMargin: '80px', threshold: 0 }
|
||
)
|
||
scrollObserver.observe(sentinelRef.value)
|
||
}
|
||
|
||
watch(sentinelRef, () => setupScrollObserver())
|
||
|
||
onMounted(() => {
|
||
loadQueue()
|
||
startPolling()
|
||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||
window.addEventListener('unhandledrejection', onUnhandledRejection)
|
||
nextTick(() => setupScrollObserver())
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
stopPolling()
|
||
scrollObserver?.disconnect()
|
||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||
window.removeEventListener('unhandledrejection', onUnhandledRejection)
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.reception-page {
|
||
--rx-bg: #f5f7fb;
|
||
--rx-bg-grad: linear-gradient(180deg, #f5f7fb 0%, #eef2f8 100%);
|
||
--rx-surface: #ffffff;
|
||
--rx-ink: #1f2937;
|
||
--rx-muted: #4b5563;
|
||
--rx-soft: #94a3b8;
|
||
--rx-line: #eef0f5;
|
||
--rx-line-strong: #e2e6ee;
|
||
--rx-accent: #4f8cff;
|
||
--rx-accent-strong: #2563eb;
|
||
--rx-accent-soft: #eef3ff;
|
||
--rx-accent-glow: rgba(79, 140, 255, 0.18);
|
||
--rx-success: #22c55e;
|
||
--rx-warning: #f59e0b;
|
||
--rx-warning-soft: #fff7ed;
|
||
--rx-success-soft: #f0fdf4;
|
||
--rx-shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||
--rx-shadow: 0 4px 16px rgba(15, 23, 42, 0.06);
|
||
--rx-shadow-lg: 0 8px 30px rgba(15, 23, 42, 0.08);
|
||
--rx-font-data: 'IBM Plex Mono', Consolas, Menlo, monospace;
|
||
|
||
min-height: calc(100vh - 80px);
|
||
padding: 18px 22px 28px;
|
||
background: var(--rx-bg-grad);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
/* 页头 */
|
||
.reception-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-end;
|
||
gap: 16px;
|
||
}
|
||
.header-title {
|
||
margin: 0 0 4px;
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
color: var(--rx-ink);
|
||
letter-spacing: -0.02em;
|
||
background: linear-gradient(120deg, #1f2937 0%, #4f8cff 120%);
|
||
-webkit-background-clip: text;
|
||
background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
}
|
||
.header-desc {
|
||
color: var(--rx-muted);
|
||
font-size: 13px;
|
||
}
|
||
.header-meta {
|
||
margin-top: 4px;
|
||
color: var(--rx-soft);
|
||
font-family: var(--rx-font-data);
|
||
font-size: 12px;
|
||
}
|
||
.header-right {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
.live-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 7px 14px;
|
||
border-radius: 999px;
|
||
background: var(--rx-surface);
|
||
border: 1px solid var(--rx-line);
|
||
color: var(--rx-ink);
|
||
font-family: var(--rx-font-data);
|
||
font-size: 12px;
|
||
min-width: 124px;
|
||
justify-content: center;
|
||
box-shadow: var(--rx-shadow-sm);
|
||
}
|
||
.live-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: var(--rx-success);
|
||
animation: pulse 1.6s infinite;
|
||
}
|
||
.live-dot.paused {
|
||
background: var(--rx-soft);
|
||
animation: none;
|
||
}
|
||
@keyframes pulse {
|
||
0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.5); }
|
||
70% { box-shadow: 0 0 0 8px rgba(34, 197, 94, 0); }
|
||
100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
|
||
}
|
||
|
||
/* 筛选条 */
|
||
.filter-shell {
|
||
background: var(--rx-surface);
|
||
border: 1px solid var(--rx-line);
|
||
border-radius: 14px;
|
||
padding: 10px 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
box-shadow: var(--rx-shadow-sm);
|
||
}
|
||
.search-input {
|
||
width: 280px;
|
||
border: 1px solid var(--rx-line);
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
transition: border-color 0.2s ease;
|
||
}
|
||
.search-input:hover {
|
||
border-color: #cfd9ee;
|
||
}
|
||
.search-input:focus-within {
|
||
border-color: var(--rx-accent);
|
||
}
|
||
.search-input :deep(.el-input__wrapper) {
|
||
background: transparent;
|
||
border-radius: 10px;
|
||
border: none !important;
|
||
box-shadow: none !important;
|
||
outline: none !important;
|
||
}
|
||
.search-input :deep(.el-input__inner) {
|
||
border: none !important;
|
||
outline: none;
|
||
box-shadow: none !important;
|
||
}
|
||
.filter-tabs {
|
||
margin-left: auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
}
|
||
.filter-tab {
|
||
color: var(--rx-soft);
|
||
cursor: pointer;
|
||
padding: 6px 14px;
|
||
border-radius: 999px;
|
||
transition: all 0.2s ease;
|
||
user-select: none;
|
||
font-weight: 500;
|
||
}
|
||
.filter-tab:hover {
|
||
color: var(--rx-accent-strong);
|
||
background: var(--rx-accent-soft);
|
||
}
|
||
.filter-tab.active {
|
||
color: #fff;
|
||
background: linear-gradient(135deg, #4f8cff 0%, #2563eb 100%);
|
||
font-weight: 600;
|
||
box-shadow: 0 2px 8px var(--rx-accent-glow);
|
||
}
|
||
.filter-sep {
|
||
color: var(--rx-line-strong);
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* 主体 */
|
||
.main-area {
|
||
display: flex;
|
||
gap: 14px;
|
||
flex: 1;
|
||
min-height: 0;
|
||
align-items: flex-start;
|
||
}
|
||
.queue-col {
|
||
width: 312px;
|
||
flex-shrink: 0;
|
||
}
|
||
.queue-panel {
|
||
background: var(--rx-surface);
|
||
border: 1px solid var(--rx-line);
|
||
border-radius: 14px;
|
||
padding: 14px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
height: calc(100vh - 170px);
|
||
box-shadow: var(--rx-shadow-sm);
|
||
}
|
||
.queue-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 0 4px 10px;
|
||
border-bottom: 1px solid var(--rx-line);
|
||
}
|
||
.queue-title {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: var(--rx-ink);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.queue-title::before {
|
||
content: '';
|
||
width: 3px;
|
||
height: 14px;
|
||
border-radius: 2px;
|
||
background: linear-gradient(180deg, #4f8cff 0%, #2563eb 100%);
|
||
}
|
||
.queue-meta {
|
||
color: var(--rx-muted);
|
||
font-family: var(--rx-font-data);
|
||
font-size: 12px;
|
||
padding: 2px 10px;
|
||
background: var(--rx-accent-soft);
|
||
color: var(--rx-accent-strong);
|
||
border-radius: 999px;
|
||
font-weight: 600;
|
||
}
|
||
.queue-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
padding-right: 2px;
|
||
padding-bottom: 4px;
|
||
}
|
||
.queue-list::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
.queue-list::-webkit-scrollbar-thumb {
|
||
background: var(--rx-line-strong);
|
||
border-radius: 3px;
|
||
}
|
||
.queue-list::-webkit-scrollbar-thumb:hover {
|
||
background: var(--rx-soft);
|
||
}
|
||
.queue-row {
|
||
position: relative;
|
||
border: 1px solid var(--rx-line);
|
||
border-radius: 12px;
|
||
padding: 14px 14px 14px 18px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
background: #fff;
|
||
overflow: hidden;
|
||
min-height: 84px;
|
||
gap: 12px;
|
||
}
|
||
.queue-row::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 3px;
|
||
background: var(--rx-accent);
|
||
opacity: 0;
|
||
transition: opacity 0.2s ease;
|
||
}
|
||
.queue-row:hover {
|
||
border-color: #cfd9ee;
|
||
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||
transform: translateY(-1px);
|
||
box-shadow: var(--rx-shadow);
|
||
}
|
||
.queue-row:hover::before {
|
||
opacity: 1;
|
||
background: #b9c8ea;
|
||
}
|
||
.queue-row.active {
|
||
background: linear-gradient(135deg, #eef4ff 0%, #e4edff 100%);
|
||
border-color: var(--rx-accent);
|
||
box-shadow: 0 4px 14px var(--rx-accent-glow);
|
||
}
|
||
.queue-row.active:hover {
|
||
border-color: var(--rx-accent);
|
||
background: linear-gradient(135deg, #ebf2ff 0%, #dde9ff 100%);
|
||
}
|
||
.queue-row.active::before {
|
||
opacity: 1;
|
||
background: linear-gradient(180deg, #4f8cff 0%, #2563eb 100%);
|
||
}
|
||
.queue-row.passed {
|
||
opacity: 0.82;
|
||
}
|
||
.queue-row.passed:hover {
|
||
background: linear-gradient(180deg, #fffdfa 0%, #fff7ed 100%);
|
||
border-color: #f5d7a6;
|
||
}
|
||
.queue-row.passed.active {
|
||
background: linear-gradient(135deg, #fff7eb 0%, #ffefcf 100%);
|
||
border-color: var(--rx-warning);
|
||
box-shadow: 0 4px 14px rgba(245, 158, 11, 0.18);
|
||
}
|
||
.queue-row.passed::before {
|
||
background: var(--rx-warning);
|
||
}
|
||
.queue-row.passed:hover::before,
|
||
.queue-row.passed.active::before {
|
||
opacity: 1;
|
||
}
|
||
.row-name {
|
||
flex: 1;
|
||
min-width: 0;
|
||
padding-top: 1px;
|
||
}
|
||
.name-line {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--rx-ink);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
line-height: 1.45;
|
||
}
|
||
.name-meta {
|
||
font-size: 12px;
|
||
color: var(--rx-soft);
|
||
font-weight: 400;
|
||
}
|
||
.time-line {
|
||
margin-top: 8px;
|
||
font-size: 12px;
|
||
color: var(--rx-soft);
|
||
font-family: var(--rx-font-data);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
line-height: 1.5;
|
||
flex-wrap: wrap;
|
||
}
|
||
.row-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
align-self: center;
|
||
gap: 6px;
|
||
flex-shrink: 0;
|
||
}
|
||
.row-icon-btn {
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 8px;
|
||
border: 1px solid transparent;
|
||
background: transparent;
|
||
color: var(--rx-soft);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
|
||
font-size: 15px;
|
||
padding: 0;
|
||
}
|
||
.row-icon-btn:hover {
|
||
background: var(--rx-accent-soft);
|
||
color: var(--rx-accent-strong);
|
||
border-color: var(--rx-accent);
|
||
transform: translateY(-1px);
|
||
}
|
||
.row-icon-btn:active {
|
||
transform: scale(0.92);
|
||
}
|
||
.row-icon-btn.notify:hover {
|
||
background: #fff7e6;
|
||
color: var(--rx-warning);
|
||
border-color: var(--rx-warning);
|
||
}
|
||
.row-icon-btn:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
.scroll-sentinel {
|
||
padding: 12px 0 4px;
|
||
text-align: center;
|
||
color: var(--rx-soft);
|
||
font-size: 12px;
|
||
font-family: var(--rx-font-data);
|
||
}
|
||
.scroll-sentinel .no-more {
|
||
color: #c0c4cc;
|
||
}
|
||
|
||
/* 右侧 */
|
||
.sidebar {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
min-width: 0;
|
||
overflow-y: auto;
|
||
max-height: calc(100vh - 170px);
|
||
padding-right: 4px;
|
||
}
|
||
.sidebar::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
.sidebar::-webkit-scrollbar-thumb {
|
||
background: var(--rx-line-strong);
|
||
border-radius: 3px;
|
||
}
|
||
.sidebar::-webkit-scrollbar-thumb:hover {
|
||
background: var(--rx-soft);
|
||
}
|
||
.placeholder-card {
|
||
flex: 1;
|
||
background: var(--rx-surface);
|
||
border: 1px solid var(--rx-line);
|
||
border-radius: 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 400px;
|
||
box-shadow: var(--rx-shadow-sm);
|
||
}
|
||
.card {
|
||
background: var(--rx-surface);
|
||
border: 1px solid var(--rx-line);
|
||
border-radius: 14px;
|
||
padding: 18px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
box-shadow: var(--rx-shadow-sm);
|
||
transition: box-shadow 0.2s ease;
|
||
}
|
||
.card:hover {
|
||
box-shadow: var(--rx-shadow);
|
||
}
|
||
.card-title {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: var(--rx-ink);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding-left: 10px;
|
||
position: relative;
|
||
}
|
||
.card-title::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 0;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
width: 3px;
|
||
height: 16px;
|
||
border-radius: 2px;
|
||
background: linear-gradient(180deg, #4f8cff 0%, #2563eb 100%);
|
||
}
|
||
.case-sub {
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
color: var(--rx-soft);
|
||
font-family: var(--rx-font-data);
|
||
}
|
||
.patient-hero {
|
||
background: linear-gradient(135deg, #f5f8ff 0%, #eef3ff 100%);
|
||
border: 1px solid #dde7ff;
|
||
border-radius: 12px;
|
||
padding: 16px 18px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
.patient-hero::after {
|
||
content: '';
|
||
position: absolute;
|
||
right: -20px;
|
||
top: -20px;
|
||
width: 100px;
|
||
height: 100px;
|
||
border-radius: 50%;
|
||
background: radial-gradient(circle, rgba(79, 140, 255, 0.08) 0%, transparent 70%);
|
||
}
|
||
.patient-name {
|
||
font-size: 22px;
|
||
font-weight: 700;
|
||
color: var(--rx-ink);
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.patient-meta {
|
||
color: var(--rx-muted);
|
||
font-size: 13px;
|
||
line-height: 1.7;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
/* 病例紧凑网格 */
|
||
.grid-block {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
padding-top: 12px;
|
||
border-top: 1px dashed var(--rx-line);
|
||
}
|
||
.grid-block:first-of-type {
|
||
border-top: none;
|
||
padding-top: 0;
|
||
}
|
||
.grid-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--rx-accent-strong);
|
||
letter-spacing: 0.02em;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.grid-title::before {
|
||
content: '';
|
||
width: 4px;
|
||
height: 4px;
|
||
border-radius: 50%;
|
||
background: var(--rx-accent);
|
||
}
|
||
.kv-grid {
|
||
display: grid;
|
||
gap: 8px 16px;
|
||
}
|
||
.kv-grid.one { grid-template-columns: 1fr; }
|
||
.kv-grid.two { grid-template-columns: repeat(2, 1fr); }
|
||
.kv-grid.three { grid-template-columns: repeat(3, 1fr); }
|
||
.kv-grid.four { grid-template-columns: repeat(4, 1fr); }
|
||
|
||
:deep(.kv-item) {
|
||
display: flex;
|
||
gap: 6px;
|
||
font-size: 12.5px;
|
||
line-height: 1.55;
|
||
min-width: 0;
|
||
}
|
||
:deep(.kv-item.multiline) {
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
:deep(.kv-item .kv-label) {
|
||
color: var(--rx-soft);
|
||
flex-shrink: 0;
|
||
min-width: 58px;
|
||
}
|
||
:deep(.kv-item .kv-value) {
|
||
color: var(--rx-ink);
|
||
word-break: break-word;
|
||
flex: 1;
|
||
font-weight: 500;
|
||
}
|
||
:deep(.metric-kv-item .metric-kv-value) {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
:deep(.metric-kv-item .metric-kv-value.high) {
|
||
color: #dc2626;
|
||
font-weight: 700;
|
||
}
|
||
:deep(.kv-item.empty .kv-value) {
|
||
color: #c0c4cc;
|
||
font-weight: 400;
|
||
}
|
||
:deep(.kv-item.multiline .kv-value) {
|
||
white-space: pre-wrap;
|
||
font-weight: 400;
|
||
}
|
||
|
||
.rx-note {
|
||
font-size: 13px;
|
||
color: var(--rx-muted);
|
||
line-height: 1.6;
|
||
padding: 12px 14px;
|
||
background: linear-gradient(135deg, #fafbfd 0%, #f5f7fb 100%);
|
||
border-radius: 8px;
|
||
border-left: 3px solid var(--rx-accent);
|
||
}
|
||
|
||
.metric-viewport {
|
||
border: 1px solid var(--rx-line);
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
}
|
||
.metric-table :deep(.el-table__header) th {
|
||
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
||
color: var(--rx-ink);
|
||
font-weight: 600;
|
||
}
|
||
.metric-value {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
font-weight: 500;
|
||
color: var(--rx-ink);
|
||
}
|
||
.metric-value.high {
|
||
color: #dc2626;
|
||
font-weight: 700;
|
||
}
|
||
.metric-up {
|
||
font-size: 14px;
|
||
line-height: 1;
|
||
color: #dc2626;
|
||
}
|
||
|
||
.float-bar {
|
||
position: fixed;
|
||
right: 80px;
|
||
bottom: 18px;
|
||
z-index: 20;
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
|
||
.float-action {
|
||
min-width: 104px;
|
||
border: none;
|
||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
|
||
}
|
||
|
||
.float-action.note {
|
||
color: #fff;
|
||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||
}
|
||
|
||
.float-action.note:hover,
|
||
.float-action.note:focus-visible {
|
||
color: #fff;
|
||
background: linear-gradient(135deg, #ea580c 0%, #c2410c 100%);
|
||
}
|
||
|
||
.float-action.edit {
|
||
color: #fff;
|
||
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
|
||
}
|
||
|
||
.float-action.edit:hover,
|
||
.float-action.edit:focus-visible {
|
||
color: #fff;
|
||
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
|
||
}
|
||
|
||
.float-action.finish {
|
||
color: #fff;
|
||
background: linear-gradient(135deg, #4f8cff 0%, #2563eb 100%);
|
||
}
|
||
|
||
.float-action.finish:hover,
|
||
.float-action.finish:focus-visible {
|
||
color: #fff;
|
||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||
}
|
||
|
||
.float-action:deep(.el-icon),
|
||
.float-action :deep(.el-icon) {
|
||
color: inherit;
|
||
}
|
||
|
||
.fade-enter-active,
|
||
.fade-leave-active {
|
||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||
}
|
||
.fade-enter-from,
|
||
.fade-leave-to {
|
||
opacity: 0;
|
||
transform: translateY(8px);
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.main-area {
|
||
flex-direction: column;
|
||
}
|
||
.queue-col {
|
||
width: 100%;
|
||
}
|
||
.queue-panel {
|
||
height: auto;
|
||
max-height: 360px;
|
||
}
|
||
.float-bar {
|
||
right: 20px;
|
||
bottom: 20px;
|
||
}
|
||
.kv-grid.three,
|
||
.kv-grid.four {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
}
|
||
}
|
||
</style>
|