Files
zyt/admin/src/views/patient/reception/index.vue
T
2026-05-05 17:52:35 +08:00

1340 lines
38 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="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>