Files
zyt/admin/src/views/tcm/diagnosis/appointment.vue
T
2026-05-14 11:00:56 +08:00

916 lines
24 KiB
Vue

<template>
<el-drawer
v-model="visible"
title="预约问诊"
direction="rtl"
size="60%"
:close-on-click-modal="false"
:z-index="2000"
>
<div v-loading="loading">
<el-alert
v-if="todayHasBlockingAppointment"
:type="isSelectingToday ? 'warning' : 'info'"
:closable="false"
show-icon
class="today-block-alert"
:title="todayBlockingAlertText"
/>
<el-form :model="form" label-width="100px" class="appointment-form">
<!-- 上次就诊 -->
<el-form-item label="上次就诊:">
<span class="text-gray-500">{{ lastVisit || '无就诊记录' }}</span>
</el-form-item>
<!-- 预约方式 -->
<el-form-item label="预约方式:">
<el-radio-group v-model="form.appointmentMethod" @change="handleMethodChange">
<el-radio value="time">选择时段预约有专医生</el-radio>
<!-- <el-radio value="doctor">选择医生预约有专时段</el-radio> -->
</el-radio-group>
</el-form-item>
<!-- 预约类型 -->
<el-form-item label="预约类型:">
<el-radio-group v-model="form.appointmentType">
<el-radio value="video">视频问诊</el-radio>
</el-radio-group>
</el-form-item>
<!-- 选择患者 -->
<el-form-item label="选择患者:">
<el-radio-group v-model="form.patientId">
<el-radio :value="patientInfo.id">{{ patientInfo.name }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="渠道来源:" required>
<el-select
v-model="form.channel_source"
placeholder="请选择渠道来源"
class="channel-source-select"
@change="onChannelSourceChange"
>
<el-option
v-for="item in channelOptions"
:key="String(item.value)"
:label="item.name"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item
v-if="needsChannelSourceDetail"
label="自媒体补充:"
required
>
<el-input
v-model="form.channel_source_detail"
clearable
maxlength="128"
show-word-limit
placeholder="请输入自媒体相关补充内容"
class="channel-source-select"
/>
</el-form-item>
<!-- 预约医生 -->
<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-tag
v-if="getDoctorAvailability(doctor.id)"
:type="getDoctorAvailability(doctor.id) > 0 ? 'success' : 'info'"
size="small"
class="ml-2"
>
{{ getDoctorAvailability(doctor.id) > 0 ? getDoctorAvailability(doctor.id) : '无' }}
</el-tag> -->
</el-radio>
</el-radio-group>
</div>
</el-form-item>
<!-- 预约时间 -->
<el-form-item label="预约时间:">
<!-- 未选择医生提示 -->
<el-empty
v-if="!selectedDoctorId"
description="请先选择医生"
:image-size="80"
/>
<!-- 医生无排班提示 -->
<el-empty
v-else-if="selectedDoctorId && doctorRosterDates.length === 0"
description="该医生暂无排班"
:image-size="80"
/>
<!-- 有排班时显示日期和时间段 -->
<div v-else class="appointment-time-container">
<!-- 日期选择 -->
<div class="date-selector">
<el-button
v-for="dateOption in dateOptions"
:key="dateOption.date"
:type="form.date === dateOption.date ? 'primary' : ''"
@click="selectDate(dateOption.date)"
class="date-button"
>
{{ dateOption.label }}
</el-button>
</div>
<!-- 时间段区域 -->
<div v-if="form.date" class="time-slots-container">
<!-- 时间段标题和刷新按钮 -->
<div class="time-slots-header">
<span class="header-title">可预约时段</span>
<el-button
text
type="primary"
size="small"
:loading="refreshing"
@click="handleRefreshSlots"
>
<template #icon>
<Refresh />
</template>
刷新
</el-button>
</div>
<!-- 时间段网格 -->
<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.appointmentTime === slot.time
}"
@click="selectTimeSlot(slot)"
>
<div class="slot-time">{{ slot.time }}</div>
<div class="slot-status" :class="{ 'status-available': slot.available }">
{{ slot.available ? '可约' : '已约' }}
</div>
</div>
</div>
</div>
</div>
</el-form-item>
<!-- 备注 -->
<el-form-item label="备注:">
<el-input
v-model="form.remark"
type="textarea"
:rows="2"
placeholder="请输入备注信息"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="drawer-footer">
<el-button @click="visible = false">取消</el-button>
<el-button
type="primary"
@click="handleConfirm"
:loading="submitting"
:disabled="!canSubmit"
>
确定
</el-button>
</div>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { ref, reactive, computed, nextTick } 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 { getDictData } from '@/api/app'
import { getAvailableSlots, createAppointment, rosterLists, appointmentLists } from '@/api/doctor'
import feedback from '@/utils/feedback'
dayjs.extend(isoWeek)
interface TimeSlot {
time: string
available: boolean
quota: number
}
interface DateOption {
date: string
label: string
}
const visible = ref(false)
const loading = ref(false)
const submitting = ref(false)
const refreshing = ref(false)
const doctorList = ref<any[]>([])
const timeSlots = ref<TimeSlot[]>([])
const lastVisit = ref('')
const selectedDoctorId = ref(0)
const doctorRosterDates = ref<string[]>([]) // 医生有排班的日期列表
const channelOptions = ref<{ name: string; value: string }[]>([])
/** 今日是否已有「已预约(1)/已过号(4)」挂号;仅在选择「今天」再预约时禁止提交,其他日期可正常挂 */
const todayHasBlockingAppointment = ref(false)
const patientInfo = reactive({
id: 0,
name: '',
gender: '',
age: 0
})
const form = reactive({
appointmentMethod: 'time' as 'time' | 'doctor',
appointmentType: 'video',
patientId: 0,
date: '',
appointmentTime: '',
remark: '',
channel_source: '' as string,
channel_source_detail: '' as string
})
/** 仅这些渠道字典 name 需填「自媒体补充」(与后台字典名称完全一致) */
const CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL = new Set([
'自媒体4H',
'自媒体3Q',
'自媒体3H',
'自媒体2H',
'自媒体2Q'
])
function channelNameRequiresSelfMediaDetail(name: string) {
return CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL.has(String(name ?? '').trim())
}
/** 当前选中渠道字典名称(用于判断是否需填「自媒体补充」) */
const selectedChannelDictName = computed(() => {
const v = form.channel_source
if (v === '' || v == null) return ''
const row = channelOptions.value.find((item: any) => String(item.value) === String(v))
return row ? String(row.name ?? '').trim() : ''
})
/** 仅白名单内字典项需填写补充说明 */
const needsChannelSourceDetail = computed(() =>
channelNameRequiresSelfMediaDetail(selectedChannelDictName.value)
)
const onChannelSourceChange = (val: string) => {
const row = channelOptions.value.find((item: any) => String(item.value) === String(val))
const name = row ? String(row.name ?? '').trim() : ''
if (!channelNameRequiresSelfMediaDetail(name)) {
form.channel_source_detail = ''
}
}
// 生成未来7天的日期选项(只显示有排班的日期,且大于等于今天)
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
}
const label = `${dateObj.format('MM月DD日')} (${weekDays[dateObj.day()]})`
options.push({
date: date,
label
})
}
return options
})
// 过滤时间段:如果是今天,只显示当前时间之后的时间段
const filteredTimeSlots = computed(() => {
if (!form.date || timeSlots.value.length === 0) {
return []
}
const today = dayjs().format('YYYY-MM-DD')
const isToday = form.date === today
if (!isToday) {
// 不是今天,显示所有时间段
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
})
})
const todayYmd = () => dayjs().format('YYYY-MM-DD')
/** 当前选中的预约日是否为今天(与今日冲突检测联动) */
const isSelectingToday = computed(() => form.date === todayYmd())
/** 今日已有占用号时:选今天给警告文案,选他日给提示文案 */
const todayBlockingAlertText = computed(() => {
if (!todayHasBlockingAppointment.value) return ''
return isSelectingToday.value
? '该患者今日已有挂号(已预约或已过号),不能重复预约今天,请改选其他日期。'
: '该患者今日已有挂号记录;可为患者预约其他日期的时段。'
})
/** 仅当「选今天 + 今日已有占用号」时禁止提交 */
const blockedByTodayDuplicate = computed(
() => todayHasBlockingAppointment.value && isSelectingToday.value
)
// 是否可以提交
const canSubmit = computed(() => {
return (
!blockedByTodayDuplicate.value &&
selectedDoctorId.value &&
form.date &&
form.appointmentTime &&
!!form.channel_source &&
(!needsChannelSourceDetail.value || form.channel_source_detail.trim() !== '')
)
})
const emit = defineEmits(['success'])
// 打开弹窗
const open = async (patient: any) => {
visible.value = true
patientInfo.id = patient.patient_id || patient.id
patientInfo.name = patient.patient_name || patient.name
patientInfo.gender = patient.gender_desc || patient.gender
patientInfo.age = patient.age || 0
// 重置表单
form.appointmentMethod = 'time'
form.appointmentType = 'video'
form.patientId = patientInfo.id
selectedDoctorId.value = 0
form.date = ''
form.appointmentTime = ''
form.remark = ''
form.channel_source = ''
form.channel_source_detail = ''
timeSlots.value = []
doctorRosterDates.value = []
lastVisit.value = ''
todayHasBlockingAppointment.value = false
await loadChannelOptions()
// 加载医生列表
await loadDoctors()
// 查询该患者的最近一次就诊记录
await loadLastVisit()
await loadTodayAppointmentBlock()
}
/** 今日是否已有状态为已预约(1)或已过号(4)的挂号(仅禁止再挂「今天」,不禁止挂其他天) */
const loadTodayAppointmentBlock = async () => {
todayHasBlockingAppointment.value = false
const pid = patientInfo.id
if (!pid) {
return
}
const today = dayjs().format('YYYY-MM-DD')
try {
const res = await appointmentLists({
patient_id: pid,
start_date: today,
end_date: today,
page_no: 1,
page_size: 50
})
const rows = res?.lists || []
const blocking = rows.find((r: any) => {
const s = Number(r.status)
return s === 1 || s === 4
})
todayHasBlockingAppointment.value = !!blocking
} catch (e) {
console.error('检查今日挂号状态失败:', e)
}
}
// 加载患者最近一次就诊记录(从预约表查询已完成的预约)
const loadLastVisit = async () => {
try {
const res = await appointmentLists({
patient_id: patientInfo.id,
status: 3, // 只查询已完成的预约
page_no: 1,
page_size: 1
})
if (res?.lists && res.lists.length > 0) {
const lastRecord = res.lists[0]
// 格式化显示:日期 + 时间
if (lastRecord.appointment_date && lastRecord.appointment_time) {
lastVisit.value = `${lastRecord.appointment_date} ${lastRecord.appointment_time}`
} else if (lastRecord.appointment_date) {
lastVisit.value = lastRecord.appointment_date
}
}
} catch (error) {
console.error('加载就诊记录失败:', error)
}
}
const loadChannelOptions = async () => {
try {
const data = await getDictData({ type: 'channels' })
const rows = (data?.channels || []).filter((row: any) => row.status !== 0)
rows.sort((a: any, b: any) => {
const ds = Number(b?.sort ?? 0) - Number(a?.sort ?? 0)
if (ds !== 0) return ds
return Number(b?.id ?? 0) - Number(a?.id ?? 0)
})
// 统一为字符串,避免字典 value 为数字时 el-select 与表单校验不一致导致看似选了但实际未绑定
channelOptions.value = rows.map((row: any) => ({
...row,
value: row?.value != null && row.value !== '' ? String(row.value) : ''
}))
} catch (e) {
console.error('加载渠道来源失败:', e)
channelOptions.value = []
}
}
// 加载医生列表
const loadDoctors = async () => {
try {
loading.value = true
const res = await getDoctors()
doctorList.value = res || []
} catch (error) {
console.error('加载医生列表失败:', error)
feedback.msgError('加载医生列表失败')
} finally {
loading.value = false
}
}
// 获取医生可用号源数
const getDoctorAvailability = (doctorId: number) => {
// 这里应该从后端获取医生的可用号源数
// 暂时返回模拟数据
return Math.floor(Math.random() * 10)
}
// 预约方式改变
const handleMethodChange = () => {
selectedDoctorId.value = 0
form.date = ''
form.appointmentTime = ''
timeSlots.value = []
}
// 医生改变
const handleDoctorChange = async () => {
form.appointmentTime = ''
form.date = ''
timeSlots.value = []
doctorRosterDates.value = []
if (selectedDoctorId.value) {
await loadDoctorRoster()
}
}
// 加载医生排班信息
const loadDoctorRoster = async () => {
if (!selectedDoctorId.value) {
return
}
try {
loading.value = true
// 获取未来7天的日期范围
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')
let defaultDate = doctorRosterDates.value.includes(today)
? today
: doctorRosterDates.value[0]
// 今日已有占用号时,默认不要落在「今天」,避免一进来确定按钮灰掉
if (todayHasBlockingAppointment.value && defaultDate === today) {
const later = doctorRosterDates.value.find((d) => d > today)
defaultDate = later ?? doctorRosterDates.value.find((d) => d !== today) ?? defaultDate
}
selectDate(defaultDate)
}
} else {
doctorRosterDates.value = []
feedback.msgWarning('该医生暂无排班')
}
} catch (error) {
console.error('加载医生排班失败:', error)
feedback.msgError('加载医生排班失败')
doctorRosterDates.value = []
} finally {
loading.value = false
}
}
// 选择日期
const selectDate = (date: string) => {
form.date = date
form.appointmentTime = ''
if (selectedDoctorId.value) {
loadTimeSlots()
}
}
// 加载时间段
const loadTimeSlots = async () => {
if (!selectedDoctorId.value || !form.date) {
return
}
try {
loading.value = true
// 加载全天时段(9:00-18:00,每15分钟一个)
const response = await getAvailableSlots({
doctor_id: selectedDoctorId.value,
appointment_date: form.date,
period: 'all' // 获取全天时段
})
console.log('时间段数据:', response)
timeSlots.value = (response?.slots || []).map((slot: any) => ({
time: slot.time,
available: slot.available,
quota: slot.available ? 1 : 0 // 每个时间段只能预约1次
}))
console.log('处理后的时间段:', timeSlots.value)
} catch (error) {
console.error('加载时间段失败:', error)
feedback.msgError('加载时间段失败')
timeSlots.value = []
} finally {
loading.value = false
}
}
// 选择时间段
const selectTimeSlot = (slot: TimeSlot) => {
if (!slot.available) {
feedback.msgWarning('该时间段不可预约')
return
}
form.appointmentTime = slot.time
}
// 刷新时间段
const handleRefreshSlots = async () => {
if (!selectedDoctorId.value || !form.date) {
return
}
try {
refreshing.value = true
const previousSelection = form.appointmentTime
form.appointmentTime = ''
await loadTimeSlots()
if (previousSelection) {
const slot = timeSlots.value.find(s => s.time === previousSelection)
if (slot && slot.available) {
form.appointmentTime = previousSelection
}
}
feedback.msgSuccess('刷新成功')
} catch (error) {
console.error('刷新失败:', error)
feedback.msgError('刷新失败,请重试')
} finally {
refreshing.value = false
}
}
// 确认挂号
const handleConfirm = async () => {
if (!selectedDoctorId.value) {
feedback.msgWarning('请选择医生')
return
}
if (!form.date) {
feedback.msgWarning('请选择日期')
return
}
if (!form.appointmentTime) {
feedback.msgWarning('请选择预约时间')
return
}
if (!form.channel_source) {
feedback.msgWarning('请选择渠道来源')
return
}
if (needsChannelSourceDetail.value && !form.channel_source_detail.trim()) {
feedback.msgWarning('请填写自媒体渠道补充信息')
return
}
if (blockedByTodayDuplicate.value) {
feedback.msgWarning(
'该患者今日已有挂号(已预约或已过号),不能重复预约今天,请改选其他日期。'
)
return
}
try {
submitting.value = true
// 构建请求参数
const params = {
patient_id: patientInfo.id,
doctor_id: selectedDoctorId.value,
appointment_date: form.date,
period: 'all', // 全天时段
appointment_time: form.appointmentTime,
appointment_type: form.appointmentType,
remark: form.remark,
channel_source: String(form.channel_source ?? ''),
channel_source_detail: form.channel_source_detail.trim()
}
console.log('提交预约参数:', params)
await createAppointment(params)
feedback.msgSuccess('预约成功')
visible.value = false
emit('success')
} catch (error) {
console.error('预约失败:', error)
feedback.msgError('预约失败')
} finally {
submitting.value = false
}
}
defineExpose({ open })
</script>
<style scoped lang="scss">
.today-block-alert {
margin-bottom: 16px;
}
.appointment-form {
:deep(.el-form-item__label) {
font-weight: 500;
}
.channel-source-select {
width: 100%;
max-width: 360px;
}
}
.doctor-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
.doctor-radio {
margin-right: 0;
:deep(.el-radio__label) {
display: flex;
align-items: center;
}
}
}
.appointment-time-container {
width: 100%;
}
.date-selector {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 20px;
.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-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.header-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
.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: 0px 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: 0px 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;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 12px 0;
}
</style>