This commit is contained in:
Your Name
2026-08-12 17:18:56 +08:00
parent 28cd110dae
commit f48a66b611
24 changed files with 3095 additions and 233 deletions
@@ -145,7 +145,7 @@
<div class="panel-heading panel-heading--table">
<div>
<h2>明细数据列表</h2>
<p>展开部门可查看人员明细加粉=区间有效加粉按员工+客户去重须会话同意剔除已删客户挂号=已支付且实收低于 10 元的订单预约=有效预约记录开口率=开口/加粉挂号率=挂号/加粉面诊率=面诊/预约预约接诊=接诊诊单/面诊面诊接诊率=面诊/挂号接诊率=接诊诊单/加粉</p>
<p>展开部门可查看人员明细加粉=总进线=区间有效加粉按员工+客户去重须会话同意剔除已删客户与继承客户挂号=已支付且实收低于 10 元的订单预约=有效预约记录开口率=开口/加粉挂号率=挂号/加粉面诊率=面诊/挂号看挂号后流失预约率=面诊/预约看预约后未面诊面诊接诊率=接诊诊单/面诊接诊率=接诊诊单/总进线</p>
</div>
<span>{{ dashboard.rows.length }} 个顶层节点</span>
</div>
@@ -194,13 +194,13 @@
<template #default="{ row }">{{ formatPercent(row.paid_appointment_rate) }}</template>
</el-table-column>
<el-table-column label="面诊率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_paid_rate) }}</template>
</el-table-column>
<el-table-column label="预约率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_rate) }}</template>
</el-table-column>
<el-table-column label="预约接诊率" min-width="116" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_receive_rate) }}</template>
</el-table-column>
<el-table-column label="面诊接诊率" min-width="116" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_paid_rate) }}</template>
<template #default="{ row }">{{ formatPercent(row.interview_receive_rate) }}</template>
</el-table-column>
<el-table-column label="接诊率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.receive_rate) }}</template>
@@ -330,7 +330,7 @@ const timeOptions = [
{ label: '自定义', value: 'custom' }
]
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间有效加粉:去重,须会话同意,剔除已删客户' },
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间有效加粉:去重,须会话同意,剔除已删客户与继承客户' },
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
@@ -0,0 +1,556 @@
<template>
<section class="embedded-panel" v-loading="loading">
<div class="panel-toolbar">
<div>
<h2>医生排班</h2>
<p>查看近 7 日出诊医生及可约时段与医生排班管理同口径</p>
</div>
<div class="toolbar-actions">
<el-button
:icon="Refresh"
:loading="refreshing"
:disabled="!selectedDoctorId || !form.date"
@click="handleRefreshSlots"
>
刷新时段
</el-button>
</div>
</div>
<el-form :model="form" label-width="88px" class="paiban-form">
<el-form-item label="选择医生">
<div class="doctor-list">
<el-radio-group v-model="selectedDoctorId" @change="handleDoctorChange">
<el-radio
v-for="doctor in doctorList"
:key="doctor.id"
:value="doctor.id"
class="doctor-radio"
>
{{ doctor.name }}
</el-radio>
</el-radio-group>
<el-empty
v-if="!loading && doctorList.length === 0"
description="暂无可用医生"
:image-size="64"
/>
</div>
</el-form-item>
<el-form-item label="排班时间">
<el-empty v-if="!selectedDoctorId" description="请先选择医生" :image-size="72" />
<el-empty
v-else-if="selectedDoctorId && doctorRosterDates.length === 0"
description="该医生暂无排班"
:image-size="72"
/>
<div v-else class="paiban-time-container">
<div class="date-selector">
<el-button
v-for="dateOption in dateOptions"
:key="dateOption.date"
:type="form.date === dateOption.date ? 'primary' : ''"
class="date-button"
@click="selectDate(dateOption.date)"
>
{{ dateOption.label }}
</el-button>
</div>
<div v-if="form.date" class="time-slots-container">
<div class="time-slots-grid">
<div
v-for="slot in filteredTimeSlots"
:key="slot.time"
class="time-slot-item"
:class="{
available: slot.available,
unavailable: !slot.available,
selected: form.selectedTime === slot.time
}"
@click="selectTimeSlot(slot)"
>
<div class="slot-time">{{ slot.time }}</div>
<div class="slot-status" :class="{ 'status-available': slot.available }">
{{ slot.available ? '可约' : slot.hasAppointment ? '已约' : '空号' }}
</div>
</div>
</div>
<el-empty
v-if="filteredTimeSlots.length === 0"
description="当前日期暂无可展示时段"
:image-size="64"
/>
</div>
</div>
</el-form-item>
</el-form>
</section>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import dayjs from 'dayjs'
import isoWeek from 'dayjs/plugin/isoWeek'
import { getDoctors } from '@/api/tcm'
import { getAvailableSlots, rosterLists } from '@/api/doctor'
import feedback from '@/utils/feedback'
dayjs.extend(isoWeek)
interface TimeSlot {
time: string
available: boolean
hasAppointment: boolean
quota: number
}
interface DateOption {
date: string
label: string
}
const loading = ref(false)
const refreshing = ref(false)
const doctorList = ref<any[]>([])
const timeSlots = ref<TimeSlot[]>([])
const selectedDoctorId = ref(0)
const doctorRosterDates = ref<string[]>([])
let autoRefreshTimer: number | null = null
let isLoadingSlots = false
const form = reactive({
date: '',
selectedTime: ''
})
const dateOptions = computed<DateOption[]>(() => {
if (!selectedDoctorId.value || doctorRosterDates.value.length === 0) {
return []
}
const options: DateOption[] = []
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
const today = dayjs().startOf('day')
for (const date of doctorRosterDates.value) {
const dateObj = dayjs(date)
if (dateObj.isBefore(today)) {
continue
}
options.push({
date,
label: `${dateObj.format('MM月DD日')} (${weekDays[dateObj.day()]})`
})
}
return options
})
const filteredTimeSlots = computed(() => {
if (!form.date || timeSlots.value.length === 0) {
return []
}
const today = dayjs().format('YYYY-MM-DD')
if (form.date !== today) {
return timeSlots.value
}
const now = dayjs()
return timeSlots.value.map((slot) => {
const slotDateTime = dayjs(`${form.date} ${slot.time}`)
const isPast = slotDateTime.isBefore(now) || slotDateTime.isSame(now, 'minute')
if (isPast) {
return { ...slot, available: false }
}
return slot
})
})
async function loadDoctors() {
try {
loading.value = true
const res = await getDoctors()
doctorList.value = res || []
if (doctorList.value.length > 0 && !selectedDoctorId.value) {
selectedDoctorId.value = doctorList.value[0].id
await handleDoctorChange()
}
} catch (error) {
console.error('加载医生列表失败:', error)
feedback.msgError('加载医生列表失败')
} finally {
loading.value = false
}
}
async function handleDoctorChange() {
form.selectedTime = ''
form.date = ''
timeSlots.value = []
doctorRosterDates.value = []
if (selectedDoctorId.value) {
await loadDoctorRoster()
}
}
async function loadDoctorRoster() {
if (!selectedDoctorId.value) {
return
}
try {
loading.value = true
const startDate = dayjs().format('YYYY-MM-DD')
const endDate = dayjs().add(6, 'day').format('YYYY-MM-DD')
const res = await rosterLists({
doctor_id: selectedDoctorId.value,
start_date: startDate,
end_date: endDate,
status: 1
})
if (res?.lists && res.lists.length > 0) {
doctorRosterDates.value = [...new Set(res.lists.map((item: any) => item.date))] as string[]
doctorRosterDates.value.sort()
await nextTick()
if (doctorRosterDates.value.length > 0) {
const today = dayjs().format('YYYY-MM-DD')
const defaultDate = doctorRosterDates.value.includes(today)
? today
: doctorRosterDates.value[0]
selectDate(defaultDate)
}
} else {
doctorRosterDates.value = []
feedback.msgWarning('该医生暂无排班')
}
} catch (error) {
console.error('加载医生排班失败:', error)
feedback.msgError('加载医生排班失败')
doctorRosterDates.value = []
} finally {
loading.value = false
}
}
function selectDate(date: string) {
form.date = date
form.selectedTime = ''
if (selectedDoctorId.value) {
loadTimeSlots()
}
}
async function loadTimeSlots(silent = false) {
if (!selectedDoctorId.value || !form.date) {
return
}
if (isLoadingSlots) {
return
}
try {
isLoadingSlots = true
if (!silent) {
loading.value = true
}
const response = await getAvailableSlots({
doctor_id: selectedDoctorId.value,
appointment_date: form.date,
period: 'all'
})
timeSlots.value = (response?.slots || []).map((slot: any) => ({
time: slot.time,
available: slot.available,
hasAppointment: Boolean(slot.has_appointment ?? slot.available === false),
quota: slot.available ? 1 : 0
}))
} catch (error) {
console.error('加载时间段失败:', error)
if (!silent) {
feedback.msgError('加载时间段失败')
}
timeSlots.value = []
} finally {
isLoadingSlots = false
if (!silent) {
loading.value = false
}
}
}
function selectTimeSlot(slot: TimeSlot) {
if (!slot.available) {
feedback.msgWarning('该时间段不可预约')
return
}
form.selectedTime = slot.time
}
async function handleRefreshSlots() {
if (!selectedDoctorId.value || !form.date) {
return
}
if (isLoadingSlots) {
feedback.msgWarning('正在刷新中,请稍候')
return
}
try {
refreshing.value = true
const previousSelection = form.selectedTime
form.selectedTime = ''
await loadTimeSlots()
if (previousSelection) {
const slot = timeSlots.value.find((s) => s.time === previousSelection)
if (slot?.available) {
form.selectedTime = previousSelection
}
}
feedback.msgSuccess('刷新成功')
} catch (error) {
console.error('刷新失败:', error)
feedback.msgError('刷新失败,请重试')
} finally {
refreshing.value = false
}
}
function startAutoRefresh() {
stopAutoRefresh()
autoRefreshTimer = window.setInterval(() => {
if (selectedDoctorId.value && form.date) {
loadTimeSlots(true)
}
}, 5000)
}
function stopAutoRefresh() {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer)
autoRefreshTimer = null
}
}
async function refresh() {
if (selectedDoctorId.value && form.date) {
await loadTimeSlots()
return
}
await loadDoctors()
}
defineExpose({
refresh,
loading
})
onMounted(() => {
loadDoctors()
startAutoRefresh()
})
onUnmounted(() => {
stopAutoRefresh()
})
</script>
<style scoped lang="scss">
.embedded-panel {
padding-top: 4px;
}
.panel-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
h2 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #1f2a37;
}
p {
margin: 6px 0 0;
color: #667085;
font-size: 13px;
}
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.paiban-form {
:deep(.el-form-item__label) {
font-weight: 500;
}
}
.doctor-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
width: 100%;
.doctor-radio {
margin-right: 0;
}
}
.paiban-time-container {
width: 100%;
}
.date-selector {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 16px;
.date-button {
min-width: 130px;
height: 40px;
font-size: 14px;
border-radius: 8px;
transition: all 0.2s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
}
.time-slots-container {
background-color: #f8f9fa;
border-radius: 8px;
padding: 16px;
}
.time-slots-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 10px;
max-height: 450px;
overflow-y: auto;
padding: 2px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: #dcdfe6;
border-radius: 3px;
&:hover {
background-color: #c0c4cc;
}
}
.time-slot-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 8px;
border: 2px solid #e4e7ed;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
background-color: #fff;
min-height: 70px;
.slot-time {
font-size: 15px;
font-weight: 600;
color: #303133;
margin-bottom: 6px;
}
.slot-status {
font-size: 12px;
color: #909399;
padding: 0 8px;
border-radius: 4px;
background-color: #f4f4f5;
&.status-available {
color: #67c23a;
background-color: #f0f9ff;
}
}
&.available {
border-color: #e4e7ed;
&:hover {
border-color: #409eff;
background-color: #ecf5ff;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.15);
}
}
&.unavailable {
background-color: #f5f7fa;
border-color: #e4e7ed;
cursor: not-allowed;
opacity: 0.6;
.slot-time {
color: #c0c4cc;
}
.slot-status {
color: #c0c4cc;
background-color: #f5f7fa;
}
&:hover {
transform: none;
box-shadow: none;
}
}
&.selected {
border-color: #409eff;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
.slot-time {
color: #fff;
}
.slot-status {
color: #fff;
background-color: rgba(255, 255, 255, 0.2);
}
}
}
}
:deep(.el-radio-group) {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
:deep(.el-radio) {
margin-right: 0;
}
</style>
@@ -17,6 +17,7 @@
<el-tab-pane label="患者列表" name="patients" />
<el-tab-pane label="订单管理" name="orders" />
<el-tab-pane label="面诊进度" name="progress" />
<el-tab-pane label="医生排班" name="paiban" />
</el-tabs>
<div v-show="activeWorkspace === 'patients'">
@@ -207,6 +208,10 @@
ref="progressPanelRef"
@open-diagnosis="openDiagnosis"
/>
<paiban-panel
v-if="activeWorkspace === 'paiban'"
ref="paibanPanelRef"
/>
</el-card>
<edit-popup ref="editRef" @success="refreshPage" />
@@ -342,6 +347,7 @@ const EditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.
const AppointmentPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/appointment.vue'))
const OrderPanel = defineAsyncComponent(() => import('./components/OrderPanel.vue'))
const ProgressPanel = defineAsyncComponent(() => import('./components/ProgressPanel.vue'))
const PaibanPanel = defineAsyncComponent(() => import('./components/PaibanPanel.vue'))
type StatusFilter = '' | 'unbooked' | 'pending_interview' | 'completed' | 'missed'
type DateType = 'all' | 'today' | 'tomorrow' | 'day_after' | 'last7' | 'last30' | 'custom'
@@ -355,6 +361,7 @@ const editRef = ref<any>()
const appointmentRef = ref<any>()
const orderPanelRef = ref<any>()
const progressPanelRef = ref<any>()
const paibanPanelRef = ref<any>()
const qrcodeDialogVisible = ref(false)
const qrcodeLoading = ref(false)
const qrcodeUrl = ref('')
@@ -423,6 +430,7 @@ const canFillIdCard = computed(() => hasPermission(['tcm.diagnosis/edit']))
const workspaceLoading = computed(() => {
if (activeWorkspace.value === 'orders') return Boolean(orderPanelRef.value?.loading)
if (activeWorkspace.value === 'progress') return Boolean(progressPanelRef.value?.loading)
if (activeWorkspace.value === 'paiban') return Boolean(paibanPanelRef.value?.loading)
return pager.loading
})
@@ -437,6 +445,7 @@ function handleFilterChange() {
function refreshPage() {
if (activeWorkspace.value === 'orders') return orderPanelRef.value?.refresh?.()
if (activeWorkspace.value === 'progress') return progressPanelRef.value?.refresh?.()
if (activeWorkspace.value === 'paiban') return paibanPanelRef.value?.refresh?.()
return getLists()
}