Files
zyt/admin/src/views/first_visit/my_patients/components/ProgressPanel.vue
T
2026-08-10 17:29:05 +08:00

844 lines
24 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="progress-board">
<section class="board-section overview-section">
<div class="section-heading">
<div class="heading-copy">
<h2>今日面诊概览</h2>
<span class="heading-badge">{{ isOwnershipMode ? '按本人归属' : '与排班合并' }}</span>
</div>
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ scopeLabel }}</span>
</div>
<div class="overview-grid">
<article class="overview-card">
<span>{{ isOwnershipMode ? '今日本人面诊' : '今日面诊总号源' }}</span>
<strong>{{ todayOverview.totalVisits }}</strong>
<small>{{ todayOverview.doctorCount }} {{ isOwnershipMode ? '接诊' : '排班' }}医生</small>
</article>
<article class="overview-card">
<span>{{ isOwnershipMode ? '待面诊' : '已预约' }}</span>
<strong>{{ todayOverview.booked }}</strong>
<small>{{ isOwnershipMode ? '本人归属患者的有效挂号' : '按有效挂号占用号源' }}</small>
</article>
<article :class="['overview-card', isOwnershipMode ? 'overview-card-completed' : 'overview-card-empty']">
<span>{{ isOwnershipMode ? '已完成' : '空号' }}</span>
<strong>{{ isOwnershipMode ? todayOverview.completed : todayOverview.emptySlots }}</strong>
<small>{{ isOwnershipMode ? `已过号 ${todayOverview.missed} 人` : '未被有效挂号占用' }}</small>
</article>
</div>
</section>
<section class="board-section schedule-section">
<div class="section-heading">
<div class="heading-copy">
<h2>{{ isOwnershipMode ? '近一周面诊安排' : '近一周排班' }}</h2>
<span class="heading-badge">{{ isOwnershipMode ? '本人患者' : '今日起 7 天' }}</span>
</div>
<span class="schedule-range">{{ scheduleRange }}</span>
</div>
<div class="schedule-grid">
<button
v-for="day in weekSchedule"
:key="day.date"
type="button"
class="schedule-card"
:class="{
'is-today': day.date === today,
'is-selected': day.date === selectedScheduleDate
}"
:aria-pressed="day.date === selectedScheduleDate"
@click="selectScheduleDay(day.date)"
>
<span class="schedule-date">{{ day.date_text }} {{ day.weekday }}</span>
<strong>{{ isOwnershipMode ? day.total_appointments : day.total_slots }}</strong>
<span class="schedule-card-stats">
<span>{{ isOwnershipMode ? '待诊' : '已约' }} {{ isOwnershipMode ? day.waiting_appointments : day.booked_slots }}</span>
<i>/</i>
<span :class="{ 'is-completed': isOwnershipMode }">{{ isOwnershipMode ? '完成' : '空' }} {{ isOwnershipMode ? day.completed_appointments : day.empty_slots }}</span>
</span>
<span class="schedule-card-action">
<span>{{ day.doctor_count }} 位医生</span>
<span>{{ day.date === selectedScheduleDate ? '正在查看' : '查看明细' }} </span>
</span>
</button>
</div>
<div class="schedule-drilldown">
<div class="drilldown-heading">
<div>
<h3>{{ selectedScheduleLabel }}{{ isOwnershipMode ? '面诊安排' : '排班明细' }}</h3>
<p> {{ selectedScheduleDay.doctor_count }} {{ isOwnershipMode ? '接诊' : '排班' }}医生点击上方日期可切换</p>
</div>
<div :class="['drilldown-summary', { 'is-ownership': isOwnershipMode }]">
<span>{{ isOwnershipMode ? '面诊总数' : '总号源' }} <strong>{{ isOwnershipMode ? selectedScheduleDay.total_appointments : selectedScheduleDay.total_slots }}</strong></span>
<span>{{ isOwnershipMode ? '待面诊' : '已预约' }} <strong>{{ isOwnershipMode ? selectedScheduleDay.waiting_appointments : selectedScheduleDay.booked_slots }}</strong></span>
<span>{{ isOwnershipMode ? '已完成' : '空号' }} <strong>{{ isOwnershipMode ? selectedScheduleDay.completed_appointments : selectedScheduleDay.empty_slots }}</strong></span>
<span v-if="isOwnershipMode">已过号 <strong>{{ selectedScheduleDay.missed_appointments }}</strong></span>
</div>
</div>
<div v-if="selectedScheduleDoctors.length" class="doctor-schedule-list">
<article
v-for="(doctor, index) in selectedScheduleDoctors"
:key="doctor.doctor_id"
class="doctor-schedule-row"
>
<div class="doctor-identity">
<span class="doctor-index">{{ index + 1 }}</span>
<div>
<strong>{{ doctor.doctor_name }}</strong>
<small>{{ isOwnershipMode ? '本人患者接诊医生' : '医生排班' }}</small>
</div>
</div>
<div class="doctor-windows">
<span>{{ isOwnershipMode ? '预约时刻' : '排班时段' }}</span>
<strong>{{ doctorWindows(doctor) }}</strong>
</div>
<div class="doctor-metric">
<span>{{ isOwnershipMode ? '面诊总数' : '总号源' }}</span>
<strong>{{ isOwnershipMode ? doctor.total_appointments : doctor.total_slots }}</strong>
</div>
<div class="doctor-metric doctor-metric-booked">
<span>{{ isOwnershipMode ? '待面诊' : '已预约' }}</span>
<strong>{{ isOwnershipMode ? doctor.waiting_appointments : doctor.booked_slots }}</strong>
</div>
<div :class="['doctor-metric', isOwnershipMode ? 'doctor-metric-completed' : 'doctor-metric-empty']">
<span>{{ isOwnershipMode ? '完成 / 过号' : '空号' }}</span>
<strong>{{ isOwnershipMode ? `${doctor.completed_appointments} / ${doctor.missed_appointments}` : doctor.empty_slots }}</strong>
</div>
</article>
</div>
<el-empty
v-else
:image-size="56"
:description="isOwnershipMode ? '该日期暂无本人归属患者的有效挂号' : '该日期暂无当前权限范围内的医生排班'"
/>
</div>
</section>
<section class="board-section queue-section">
<div class="section-heading queue-heading">
<div class="heading-copy">
<h2>{{ queueDateLabel }}候诊列表</h2>
<span class="heading-badge">按医生排队</span>
<span class="queue-count"> {{ pager.count }} </span>
</div>
<span class="refresh-time"> 15 秒自动刷新</span>
</div>
<el-table
v-loading="pager.loading"
:data="pager.lists"
class="queue-table"
:row-class-name="tableRowClassName"
@row-dblclick="openDiagnosis"
>
<el-table-column label="排队" width="92">
<template #default="{ row }">
<span class="queue-number">{{ row.queue_no || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="患者" min-width="170">
<template #default="{ row }">
<div class="patient-cell">
<strong>{{ row.patient_name || '未命名患者' }}</strong>
<span>{{ row.phone_masked || '无手机号' }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="doctor_name" label="医生" min-width="130" show-overflow-tooltip />
<el-table-column label="预约时间" min-width="135">
<template #default="{ row }">
<span class="appointment-clock">{{ appointmentClock(row) }}</span>
</template>
</el-table-column>
<el-table-column label="前方等待" min-width="180">
<template #default="{ row }">
<span :class="['waiting-text', `is-${row.queue_status || 'waiting'}`]">
{{ waitingText(row) }}
</span>
</template>
</el-table-column>
<el-table-column label="状态" min-width="110">
<template #default="{ row }">
<el-tag size="small" round effect="light" :type="queueTagType(row.queue_status)">
{{ row.queue_status_text || '等待中' }}
</el-tag>
</template>
</el-table-column>
<template #empty>
<el-empty :description="`当前权限范围内${queueDateLabel}暂无候诊患者`" />
</template>
</el-table>
<div v-if="pager.count > pager.size" class="pagination-wrap">
<pagination v-model="pager" @change="getLists" />
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import dayjs from 'dayjs'
import { Lock } from '@element-plus/icons-vue'
import { usePaging } from '@/hooks/usePaging'
import { myPatientProgressLists } from '@/api/first_visit'
const emit = defineEmits<{ openDiagnosis: [row: Record<string, any>] }>()
const today = dayjs().format('YYYY-MM-DD')
const selectedScheduleDate = ref(today)
const formData = reactive({
keyword: '',
status: 1 as const,
start_date: today,
end_date: today
})
const { pager, getLists, resetPage } = usePaging({
fetchFun: myPatientProgressLists as any,
params: formData,
size: 15,
firstLoading: true,
latestOnly: true
})
const todayOverview = computed(() => ({
totalVisits: Number(pager.extend?.today_overview?.total_visits || 0),
booked: Number(pager.extend?.today_overview?.booked || 0),
completed: Number(pager.extend?.today_overview?.completed || 0),
missed: Number(pager.extend?.today_overview?.missed || 0),
emptySlots: Number(pager.extend?.today_overview?.empty_slots || 0),
doctorCount: Number(pager.extend?.today_overview?.doctor_count || 0)
}))
const isOwnershipMode = computed(() => pager.extend?.schedule_mode === 'ownership')
const weekSchedule = computed(() => {
const rows = Array.isArray(pager.extend?.week_schedule) ? pager.extend.week_schedule : []
if (rows.length) return rows
return Array.from({ length: 7 }, (_, index) => {
const date = dayjs().add(index, 'day')
return {
date: date.format('YYYY-MM-DD'),
date_text: date.format('MM-DD'),
weekday: `周${'日一二三四五六'[date.day()]}`,
total_slots: 0,
booked_slots: 0,
empty_slots: 0,
doctor_count: 0,
doctors: [],
total_appointments: 0,
waiting_appointments: 0,
completed_appointments: 0,
missed_appointments: 0
}
})
})
const selectedScheduleDay = computed(() => {
return weekSchedule.value.find((day: any) => day.date === selectedScheduleDate.value) || weekSchedule.value[0] || {
date: today,
date_text: dayjs().format('MM-DD'),
weekday: `周${'日一二三四五六'[dayjs().day()]}`,
total_slots: 0,
booked_slots: 0,
empty_slots: 0,
doctor_count: 0,
doctors: [],
total_appointments: 0,
waiting_appointments: 0,
completed_appointments: 0,
missed_appointments: 0
}
})
const selectedScheduleDoctors = computed(() => {
return Array.isArray(selectedScheduleDay.value?.doctors) ? selectedScheduleDay.value.doctors : []
})
const selectedScheduleLabel = computed(() => {
const day = selectedScheduleDay.value
return `${day.date_text || ''} ${day.weekday || ''} `
})
const queueDateLabel = computed(() => {
if (selectedScheduleDate.value === today) return '今日'
return selectedScheduleLabel.value.trim() || selectedScheduleDate.value
})
const scopeLabel = computed(() => pager.extend?.scope?.label || '按权限加载')
const scheduleRange = computed(() => {
const rows = weekSchedule.value
if (!rows.length) return ''
return `${rows[0].date_text}${rows[rows.length - 1].date_text}`
})
let refreshTimer: ReturnType<typeof setInterval> | null = null
function refreshPanel(options?: { silent?: boolean }) {
return getLists(options)
}
function selectScheduleDay(date: string) {
if (!date) return
selectedScheduleDate.value = date
formData.start_date = date
formData.end_date = date
resetPage()
}
function doctorWindows(doctor: Record<string, any>) {
const source = isOwnershipMode.value ? doctor.appointment_times : doctor.schedule_windows
const windows = Array.isArray(source) ? source.filter(Boolean) : []
if (windows.length) return windows.join('、')
return isOwnershipMode.value ? '暂无预约时刻' : '未设置具体时段'
}
function appointmentClock(row: Record<string, any>) {
const time = String(row.appointment_time || '').slice(0, 5)
return time || '—'
}
function waitingText(row: Record<string, any>) {
if (row.queue_status === 'consulting') return '0(进行中)'
if (row.queue_status === 'next') return '0(待接诊)'
const ahead = Math.max(0, Number(row.ahead_count || 0))
if (ahead === 0) return '0 位'
return `${ahead} 位 · 约 ${Number(row.estimated_wait_minutes || ahead * 15)} 分钟`
}
function queueTagType(status: string): 'primary' | 'success' | 'warning' | 'danger' | 'info' {
if (status === 'consulting') return 'success'
if (status === 'next') return 'warning'
if (status === 'completed') return 'success'
if (status === 'missed') return 'danger'
return 'info'
}
function tableRowClassName({ row }: { row: Record<string, any> }) {
return Number(row.is_self_patient) === 1 ? 'queue-row-self' : ''
}
function openDiagnosis(row: Record<string, any>) {
emit('openDiagnosis', row)
}
defineExpose({ refresh: refreshPanel, loading: computed(() => pager.loading) })
onMounted(() => {
getLists()
refreshTimer = setInterval(() => getLists({ silent: true }), 15_000)
})
onUnmounted(() => {
if (refreshTimer) clearInterval(refreshTimer)
})
</script>
<style scoped lang="scss">
.progress-board {
display: grid;
gap: 14px;
color: #172033;
}
.board-section {
padding: 16px;
border: 1px solid #e1e7ee;
border-radius: 12px;
background: #fff;
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.heading-copy,
.scope-chip {
display: flex;
align-items: center;
}
.heading-copy {
gap: 8px;
min-width: 0;
h2 {
margin: 0;
color: #172033;
font-size: 15px;
font-weight: 700;
}
}
.heading-badge {
padding: 3px 7px;
border-radius: 5px;
color: #758195;
background: #eef2f6;
font-size: 11px;
white-space: nowrap;
}
.scope-chip {
gap: 5px;
color: #0f766e;
font-size: 12px;
white-space: nowrap;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.overview-card {
min-height: 78px;
padding: 14px 16px;
border: 1px solid #dfe5ed;
border-radius: 10px;
background: #fff;
> span,
> small {
display: block;
color: #778398;
font-size: 12px;
}
> strong {
display: block;
margin: 7px 0 5px;
color: #111a2c;
font-size: 24px;
line-height: 1;
font-variant-numeric: tabular-nums;
}
> small {
color: #a0a9b7;
font-size: 11px;
}
}
.overview-card-empty > strong {
color: #ee4d55;
}
.overview-card-completed > strong {
color: #07886d;
}
.schedule-range,
.refresh-time,
.queue-count {
color: #929dac;
font-size: 12px;
}
.schedule-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 8px;
}
.schedule-card {
min-width: 0;
padding: 11px 8px;
text-align: center;
border: 1px solid #dfe5ed;
border-radius: 9px;
background: #fbfcfe;
color: inherit;
font: inherit;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
&:hover {
border-color: #7ac9c2;
background: #f7fcfb;
transform: translateY(-2px);
}
&:active {
transform: translateY(0);
}
&:focus-visible {
outline: 3px solid rgba(15, 145, 133, 0.18);
outline-offset: 2px;
}
&.is-today {
border-color: #8fd3cd;
background: #f4fbfa;
}
&.is-selected {
border-color: #0f9185;
background: #effaf8;
box-shadow: 0 8px 20px rgba(15, 118, 110, 0.1), inset 0 -3px 0 #0f9185;
}
> strong {
display: block;
margin: 7px 0 5px;
color: #131c2d;
font-size: 20px;
line-height: 1;
font-variant-numeric: tabular-nums;
}
}
.schedule-date {
display: block;
overflow: hidden;
color: #70809a;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.schedule-card-stats {
display: flex;
justify-content: center;
gap: 4px;
color: #0c9967;
font-size: 11px;
white-space: nowrap;
i {
color: #a5afbd;
font-style: normal;
}
span:last-child {
color: #d8862b;
}
span.is-completed {
color: #07886d;
}
}
.schedule-card-action {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
margin: 10px 2px 0;
padding-top: 8px;
border-top: 1px solid #e7ecef;
color: #7b8799;
font-size: 10px;
span:last-child {
color: #0f8077;
font-weight: 600;
}
}
.schedule-drilldown {
margin-top: 14px;
padding: 15px;
border-radius: 10px;
background: #f7f9fb;
}
.drilldown-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 11px;
h3 {
margin: 0;
color: #1e293b;
font-size: 14px;
font-weight: 700;
}
p {
margin: 4px 0 0;
color: #8a95a6;
font-size: 11px;
}
}
.drilldown-summary {
display: flex;
align-items: center;
gap: 18px;
color: #7a8698;
font-size: 11px;
white-space: nowrap;
strong {
margin-left: 3px;
color: #243044;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
span:last-child strong {
color: #d17820;
}
}
.doctor-schedule-list {
display: grid;
gap: 7px;
}
.doctor-schedule-row {
display: grid;
grid-template-columns: minmax(150px, 1fr) minmax(210px, 1.8fr) repeat(3, minmax(64px, 0.55fr));
align-items: center;
gap: 14px;
min-height: 58px;
padding: 8px 13px;
border: 1px solid #e4e9ef;
border-radius: 8px;
background: #fff;
}
.doctor-identity {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
> div {
min-width: 0;
}
strong,
small {
display: block;
}
strong {
overflow: hidden;
color: #202b3d;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
small {
margin-top: 2px;
color: #9aa4b2;
font-size: 10px;
}
}
.doctor-index {
display: grid;
flex: 0 0 28px;
width: 28px;
height: 28px;
place-items: center;
border-radius: 7px;
color: #0f766e;
background: #e7f6f4;
font-size: 12px;
font-weight: 700;
}
.doctor-windows,
.doctor-metric {
min-width: 0;
span,
strong {
display: block;
}
span {
color: #98a2b3;
font-size: 10px;
}
strong {
margin-top: 3px;
color: #344054;
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
}
.doctor-windows strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.doctor-metric {
text-align: right;
strong {
font-size: 15px;
}
}
.doctor-metric-booked strong {
color: #07886d;
}
.doctor-metric-empty strong {
color: #d17820;
}
.doctor-metric-completed strong {
color: #07886d;
}
.queue-heading {
margin-bottom: 10px;
}
.queue-table {
width: 100%;
border-radius: 8px;
overflow: hidden;
:deep(th.el-table__cell) {
height: 40px;
color: #667085;
background: #f7f9fb;
font-size: 12px;
font-weight: 600;
}
:deep(td.el-table__cell) {
height: 46px;
padding: 6px 0;
color: #253044;
font-size: 13px;
}
:deep(.queue-row-self > td.el-table__cell) {
background: #fff8e8 !important;
}
:deep(.queue-row-self > td.el-table__cell:first-child) {
border-left: 3px solid #f08332;
}
}
.queue-number {
display: inline-grid;
min-width: 26px;
height: 26px;
padding: 0 7px;
place-items: center;
border-radius: 8px;
color: #0f766e;
background: #e7f6f4;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.patient-cell {
display: flex;
flex-direction: column;
gap: 2px;
strong {
color: #1f2937;
font-size: 13px;
}
span {
color: #98a2b3;
font-size: 11px;
}
}
.appointment-clock {
color: #263246;
font-variant-numeric: tabular-nums;
}
.waiting-text {
color: #667085;
font-size: 12px;
&.is-consulting {
color: #07886d;
font-weight: 600;
}
&.is-next {
color: #d17820;
}
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
padding-top: 14px;
}
@media (max-width: 1180px) {
.schedule-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.doctor-schedule-row {
grid-template-columns: minmax(140px, 1fr) minmax(180px, 1.5fr) repeat(3, minmax(56px, 0.5fr));
gap: 10px;
}
}
@media (max-width: 760px) {
.board-section {
padding: 12px;
}
.overview-grid,
.schedule-grid {
grid-template-columns: 1fr;
}
.section-heading {
align-items: flex-start;
flex-direction: column;
gap: 7px;
}
.heading-copy {
flex-wrap: wrap;
}
.drilldown-heading,
.drilldown-summary {
align-items: flex-start;
flex-direction: column;
}
.drilldown-summary {
gap: 5px;
}
.doctor-schedule-row {
grid-template-columns: repeat(3, 1fr);
}
.doctor-identity,
.doctor-windows {
grid-column: 1 / -1;
}
.doctor-metric {
text-align: left;
}
}
</style>