Files
zyt/admin/src/views/doctor/roster.vue
T
2026-06-29 09:25:29 +08:00

702 lines
23 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="doctor-roster p-4">
<el-card shadow="never">
<template #header>
<div class="flex justify-between items-center">
<span class="text-lg font-medium">医生排班管理</span>
<div>
<el-button type="success" @click="showBatchDialog">批量排班</el-button>
<el-button @click="prevWeek">上一周</el-button>
<el-button @click="nextWeek">下一周</el-button>
<el-button type="primary" @click="goToday">本周</el-button>
</div>
</div>
</template>
<div class="mb-4">
<el-form inline>
<el-form-item label="医生">
<el-input v-model="searchName" placeholder="请输入医生姓名" clearable style="width: 200px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadData">查询</el-button>
</el-form-item>
</el-form>
</div>
<el-alert type="info" :closable="false" class="mb-4" show-icon>
每条排班为一段连续可挂号时间同一天可添加多段如白班 + 夜班)。休息时段不添加出诊段即可号源间隔默认 15 分钟号源数填 0 表示按时段长度自动满号
</el-alert>
<div class="text-center mb-4 text-gray-600">
{{ weekText }}
</div>
<el-table :data="filteredTableData" border v-loading="loading">
<el-table-column prop="doctorName" label="医生" width="120" fixed />
<el-table-column v-for="day in weekDays" :key="day.date" :label="day.label" min-width="168">
<template #default="{ row }">
<div
class="day-cell rounded border border-dashed border-gray-200 p-1 min-h-[56px] cursor-pointer hover:bg-gray-50"
@click="openDayDialog(row, day.date)"
>
<template v-if="getDaySegments(row, day.date).length === 0">
<div class="text-xs text-gray-400 text-center py-2">点击管理</div>
</template>
<div v-else class="space-y-1">
<div
v-for="seg in getDaySegments(row, day.date)"
:key="seg.id"
class="text-xs px-1 py-0.5 rounded leading-tight"
:class="segmentChipClass(seg.status)"
>
{{ formatSegmentLine(seg) }}
</div>
</div>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 某日排班列表 -->
<el-dialog v-model="dayDialogVisible" :title="dayDialogTitle" width="720px" destroy-on-close>
<el-table :data="dayDialogSegments" border size="small" max-height="360">
<el-table-column label="时间" width="150">
<template #default="{ row }">{{ row.start_time }} - {{ row.end_time }}</template>
</el-table-column>
<el-table-column label="班次" width="80">
<template #default="{ row }">{{ shiftLabel(row.shift_type) }}</template>
</el-table-column>
<el-table-column label="状态" width="88">
<template #default="{ row }">{{ statusText(row.status) }}</template>
</el-table-column>
<el-table-column label="间隔" width="72">
<template #default="{ row }">{{ row.slot_minutes || 15 }} </template>
</el-table-column>
<el-table-column label="号源" width="72">
<template #default="{ row }">{{ row.status === 1 ? row.quota || '不限' : '-' }}</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="100" show-overflow-tooltip />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="openSegmentForm(row)">编辑</el-button>
<el-button type="danger" link @click="removeSegment(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="dayDialogVisible = false">关闭</el-button>
<el-button type="primary" @click="openSegmentForm()">添加时段</el-button>
</template>
</el-dialog>
<!-- 单段排班表单 -->
<el-dialog v-model="segmentDialogVisible" :title="segmentForm.id ? '编辑时段' : '添加时段'" width="520px" destroy-on-close>
<el-form :model="segmentForm" label-width="110px">
<el-form-item label="接诊时间" required>
<div class="flex items-center gap-2 flex-wrap">
<el-time-select
v-model="segmentForm.start_time"
start="00:00"
step="00:5"
end="23:45"
placeholder="开始"
style="width: 120px"
/>
<span></span>
<el-time-select
v-model="segmentForm.end_time"
start="00:00"
step="00:5"
end="23:45"
placeholder="结束"
style="width: 120px"
/>
</div>
</el-form-item>
<el-form-item label="班次标签">
<el-select v-model="segmentForm.shift_type" placeholder="选填" clearable style="width: 200px">
<el-option label="白班" value="day" />
<el-option label="夜班" value="night" />
</el-select>
</el-form-item>
<el-form-item label="号源间隔(分)">
<el-input-number v-model="segmentForm.slot_minutes" :min="5" :max="120" :step="5" />
<span class="ml-2 text-gray-500 text-sm">默认 15</span>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="segmentForm.status">
<el-radio :label="1">出诊</el-radio>
<el-radio :label="2">停诊</el-radio>
<el-radio :label="3">休息</el-radio>
<el-radio :label="4">请假</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="号源上限" v-if="segmentForm.status === 1">
<el-input-number v-model="segmentForm.quota" :min="0" :max="200" />
<div class="text-gray-500 text-xs mt-1">0 表示不限制由时段÷间隔计算可约数</div>
</el-form-item>
<el-form-item label="最大接诊" v-if="segmentForm.status === 1">
<el-input-number v-model="segmentForm.max_patients" :min="0" :max="500" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="segmentForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="segmentDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="segmentSaving" @click="saveSegment">保存</el-button>
</template>
</el-dialog>
<!-- 批量排班 -->
<el-dialog v-model="batchDialogVisible" title="批量排班" width="780px" destroy-on-close>
<el-form :model="batchForm" label-width="100px">
<el-form-item label="选择医生" required>
<el-select v-model="batchForm.doctorIds" multiple placeholder="请选择医生" class="w-full" filterable>
<el-option v-for="doctor in tableData" :key="doctor.doctorId" :label="doctor.doctorName" :value="doctor.doctorId" />
</el-select>
</el-form-item>
<el-form-item label="日期范围" required>
<el-date-picker
v-model="batchForm.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
class="w-full"
/>
</el-form-item>
<el-form-item label="星期" required>
<el-checkbox-group v-model="batchForm.weekdays">
<el-checkbox :label="1">周一</el-checkbox>
<el-checkbox :label="2">周二</el-checkbox>
<el-checkbox :label="3">周三</el-checkbox>
<el-checkbox :label="4">周四</el-checkbox>
<el-checkbox :label="5">周五</el-checkbox>
<el-checkbox :label="6">周六</el-checkbox>
<el-checkbox :label="0">周日</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="时段模板">
<div class="w-full space-y-2">
<div v-for="(seg, idx) in batchForm.segments" :key="idx" class="flex flex-wrap items-center gap-2 p-2 bg-gray-50 rounded">
<el-time-select v-model="seg.start_time" start="00:00" step="00:05" end="23:45" placeholder="开始" style="width: 110px" />
<span></span>
<el-time-select v-model="seg.end_time" start="00:00" step="00:05" end="23:45" placeholder="结束" style="width: 110px" />
<el-select v-model="seg.shift_type" placeholder="班次" clearable style="width: 100px">
<el-option label="白班" value="day" />
<el-option label="夜班" value="night" />
</el-select>
<span class="text-sm text-gray-500">间隔</span>
<el-input-number v-model="seg.slot_minutes" :min="5" :max="120" :step="5" style="width: 100px" />
<span class="text-sm text-gray-500">号源0=不限</span>
<el-input-number v-model="seg.quota" :min="0" :max="200" style="width: 100px" />
<el-button type="danger" link @click="batchRemoveSegment(idx)" :disabled="batchForm.segments.length <= 1">删除</el-button>
</div>
<el-button type="primary" link @click="batchAddSegment">+ 增加一段</el-button>
<el-button type="success" link @click="batchAddNightTemplate">+ 夜班 18:0022:00</el-button>
</div>
</el-form-item>
<el-form-item label="排班状态" required>
<el-radio-group v-model="batchForm.status">
<el-radio :label="1">出诊</el-radio>
<el-radio :label="2">停诊</el-radio>
<el-radio :label="3">休息</el-radio>
<el-radio :label="4">请假</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="batchForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="batchDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleBatchSave" :loading="batchSaving">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts" name="doctorRoster">
import { ref, computed, onMounted } from 'vue'
import dayjs from 'dayjs'
import isoWeek from 'dayjs/plugin/isoWeek'
import feedback from '@/utils/feedback'
import { doctorLists, rosterLists, rosterSave, rosterDelete, rosterBatchSave } from '@/api/doctor'
import { ElMessageBox } from 'element-plus'
dayjs.extend(isoWeek)
interface RosterSegment {
id: number
status: number
quota: number
max_patients?: number
start_time: string
end_time: string
shift_type?: string | null
slot_minutes?: number
remark?: string
period?: string
}
interface DoctorRow {
doctorId: number
doctorName: string
/** date -> segments */
rostersByDate: Record<string, RosterSegment[]>
}
const searchName = ref('')
const currentWeekStart = ref(dayjs().startOf('isoWeek'))
const loading = ref(false)
const dayDialogVisible = ref(false)
const dayDialogDoctor = ref<DoctorRow | null>(null)
const dayDialogDate = ref('')
const dayDialogSegments = ref<RosterSegment[]>([])
const segmentDialogVisible = ref(false)
const segmentSaving = ref(false)
const segmentForm = ref({
id: null as number | null,
doctorId: 0,
date: '',
start_time: '09:00',
end_time: '12:00',
shift_type: '' as string,
slot_minutes: 15,
status: 1,
quota: 0,
max_patients: 0,
remark: ''
})
const batchDialogVisible = ref(false)
const batchSaving = ref(false)
interface BatchSegTpl {
start_time: string
end_time: string
shift_type: string
slot_minutes: number
quota: number
}
const defaultBatchSegments = (): BatchSegTpl[] => [
{ start_time: '09:00', end_time: '12:00', shift_type: 'day', slot_minutes: 15, quota: 0 },
{ start_time: '14:00', end_time: '18:00', shift_type: 'day', slot_minutes: 15, quota: 0 }
]
const batchForm = ref({
doctorIds: [] as number[],
dateRange: [] as string[],
weekdays: [1, 2, 3, 4, 5] as number[],
status: 1,
remark: '',
segments: defaultBatchSegments()
})
const tableData = ref<DoctorRow[]>([])
const filteredTableData = computed(() => {
const q = searchName.value.trim()
if (!q) return tableData.value
return tableData.value.filter((r) => r.doctorName.includes(q))
})
const dayDialogTitle = computed(() => {
if (!dayDialogDoctor.value || !dayDialogDate.value) return '当日排班'
return `${dayDialogDoctor.value.doctorName} · ${dayDialogDate.value}`
})
const weekDays = computed(() => {
const days = []
const labels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
for (let i = 0; i < 7; i++) {
const date = currentWeekStart.value.add(i, 'day')
days.push({
date: date.format('YYYY-MM-DD'),
label: `${labels[i]} ${date.format('MM-DD')}`
})
}
return days
})
const weekText = computed(() => {
const start = currentWeekStart.value.format('YYYY-MM-DD')
const end = currentWeekStart.value.add(6, 'day').format('YYYY-MM-DD')
return `${start}${end}`
})
function statusText(s: number) {
const m: Record<number, string> = { 1: '出诊', 2: '停诊', 3: '休息', 4: '请假' }
return m[s] ?? String(s)
}
function shiftLabel(t?: string | null) {
if (t === 'day') return '白班'
if (t === 'night') return '夜班'
return '—'
}
function segmentChipClass(status: number) {
if (status === 1) return 'bg-emerald-50 text-emerald-800 border border-emerald-100'
if (status === 2) return 'bg-amber-50 text-amber-900 border border-amber-100'
if (status === 3) return 'bg-slate-100 text-slate-600 border border-slate-200'
return 'bg-gray-100 text-gray-700 border border-gray-200'
}
function formatSegmentLine(seg: RosterSegment) {
const t = `${seg.start_time}-${seg.end_time}`
const sh = shiftLabel(seg.shift_type)
const shPart = sh !== '—' ? ` ${sh}` : ''
if (seg.status === 1) {
const q = seg.quota > 0 ? ` 号源${seg.quota}` : ''
return `${t}${shPart} 出诊${q}`
}
return `${t}${shPart} ${statusText(seg.status)}`
}
function getDaySegments(row: DoctorRow, date: string): RosterSegment[] {
return row.rostersByDate[date] || []
}
function refreshDayDialogList() {
if (!dayDialogDoctor.value || !dayDialogDate.value) return
const row = tableData.value.find((r) => r.doctorId === dayDialogDoctor.value!.doctorId)
if (!row) return
dayDialogSegments.value = [...(row.rostersByDate[dayDialogDate.value] || [])].sort((a, b) =>
a.start_time.localeCompare(b.start_time)
)
}
function openDayDialog(row: DoctorRow, date: string) {
dayDialogDoctor.value = row
dayDialogDate.value = date
refreshDayDialogList()
dayDialogVisible.value = true
}
function openSegmentForm(existing?: RosterSegment) {
if (!dayDialogDoctor.value || !dayDialogDate.value) {
feedback.msgWarning('请先选择日期格子')
return
}
if (existing) {
segmentForm.value = {
id: existing.id,
doctorId: dayDialogDoctor.value.doctorId,
date: dayDialogDate.value,
start_time: existing.start_time || '09:00',
end_time: existing.end_time || '12:00',
shift_type: existing.shift_type || '',
slot_minutes: existing.slot_minutes || 15,
status: existing.status,
quota: existing.quota ?? 0,
max_patients: existing.max_patients ?? 0,
remark: existing.remark || ''
}
} else {
segmentForm.value = {
id: null,
doctorId: dayDialogDoctor.value.doctorId,
date: dayDialogDate.value,
start_time: '09:00',
end_time: '12:00',
shift_type: 'day',
slot_minutes: 15,
status: 1,
quota: 0,
max_patients: 0,
remark: ''
}
}
segmentDialogVisible.value = true
}
async function saveSegment() {
const f = segmentForm.value
if (!f.start_time || !f.end_time) {
feedback.msgWarning('请填写接诊开始与结束时间')
return
}
if (f.status === 1 && f.start_time >= f.end_time) {
feedback.msgWarning('出诊时结束时间须晚于开始时间')
return
}
if (f.start_time >= f.end_time) {
feedback.msgWarning('结束时间须晚于开始时间')
return
}
segmentSaving.value = true
try {
const payload: Record<string, unknown> = {
doctor_id: f.doctorId,
date: f.date,
period: 'segment',
start_time: f.start_time,
end_time: f.end_time,
slot_minutes: f.slot_minutes || 15,
status: f.status,
quota: f.status === 1 ? f.quota : 0,
max_patients: f.status === 1 ? f.max_patients : 0,
remark: f.remark || ''
}
if (f.shift_type) {
payload.shift_type = f.shift_type
}
if (f.id) {
payload.id = f.id
}
await rosterSave(payload)
feedback.msgSuccess('保存成功')
segmentDialogVisible.value = false
await loadRosterData()
refreshDayDialogList()
} catch {
feedback.msgError('保存失败')
} finally {
segmentSaving.value = false
}
}
async function removeSegment(row: RosterSegment) {
try {
await ElMessageBox.confirm('确定删除该时段?', '提示', { type: 'warning' })
} catch {
return
}
try {
await rosterDelete({ id: row.id })
feedback.msgSuccess('已删除')
await loadRosterData()
refreshDayDialogList()
} catch {
feedback.msgError('删除失败')
}
}
const loadDoctors = async () => {
try {
const res = await doctorLists({
page_no: 1,
page_size: 1000
})
tableData.value = (res?.lists || []).map((doctor: any) => ({
doctorId: doctor.id,
doctorName: doctor.name || doctor.account,
rostersByDate: {} as Record<string, RosterSegment[]>
}))
await loadRosterData()
} catch (error) {
console.error('加载医生列表失败:', error)
feedback.msgError('加载医生列表失败')
}
}
const loadRosterData = async () => {
loading.value = true
try {
const params = {
start_date: currentWeekStart.value.format('YYYY-MM-DD'),
end_date: currentWeekStart.value.add(6, 'day').format('YYYY-MM-DD')
}
const res = await rosterLists(params)
const rosters = res?.lists || []
tableData.value.forEach((doctor) => {
doctor.rostersByDate = {}
rosters.forEach((roster: any) => {
if (roster.doctor_id !== doctor.doctorId) return
const d = roster.date
if (!doctor.rostersByDate[d]) doctor.rostersByDate[d] = []
doctor.rostersByDate[d].push({
id: roster.id,
status: roster.status,
quota: roster.quota || 0,
max_patients: roster.max_patients,
start_time: roster.start_time || legacyStart(roster.period),
end_time: roster.end_time || legacyEnd(roster.period),
shift_type: roster.shift_type,
slot_minutes: roster.slot_minutes ?? 15,
remark: roster.remark,
period: roster.period
})
})
})
refreshDayDialogList()
} catch (error) {
console.error('加载排班数据失败:', error)
feedback.msgError('加载排班数据失败')
} finally {
loading.value = false
}
}
function legacyStart(period: string) {
if (period === 'morning') return '09:00'
if (period === 'afternoon') return '14:00'
if (period === 'night') return '18:00'
return '09:00'
}
function legacyEnd(period: string) {
if (period === 'morning') return '12:00'
if (period === 'afternoon') return '18:00'
if (period === 'night') return '22:00'
return '12:00'
}
const loadData = () => {
loadRosterData()
}
const prevWeek = () => {
currentWeekStart.value = currentWeekStart.value.subtract(1, 'week')
loadRosterData()
}
const nextWeek = () => {
currentWeekStart.value = currentWeekStart.value.add(1, 'week')
loadRosterData()
}
const goToday = () => {
currentWeekStart.value = dayjs().startOf('isoWeek')
loadRosterData()
}
const showBatchDialog = () => {
batchForm.value = {
doctorIds: [],
dateRange: [],
weekdays: [1, 2, 3, 4, 5],
status: 1,
remark: '',
segments: defaultBatchSegments()
}
batchDialogVisible.value = true
}
const batchAddSegment = () => {
batchForm.value.segments.push({
start_time: '09:00',
end_time: '12:00',
shift_type: 'day',
slot_minutes: 15,
quota: 0
})
}
const batchAddNightTemplate = () => {
batchForm.value.segments.push({
start_time: '18:00',
end_time: '22:00',
shift_type: 'night',
slot_minutes: 15,
quota: 0
})
}
const batchRemoveSegment = (idx: number) => {
if (batchForm.value.segments.length <= 1) return
batchForm.value.segments.splice(idx, 1)
}
const handleBatchSave = async () => {
if (batchForm.value.doctorIds.length === 0) {
feedback.msgWarning('请选择医生')
return
}
if (!batchForm.value.dateRange || batchForm.value.dateRange.length !== 2) {
feedback.msgWarning('请选择日期范围')
return
}
if (batchForm.value.weekdays.length === 0) {
feedback.msgWarning('请选择星期')
return
}
for (const seg of batchForm.value.segments) {
if (!seg.start_time || !seg.end_time) {
feedback.msgWarning('请完善时段模板的开始与结束时间')
return
}
if (seg.start_time >= seg.end_time) {
feedback.msgWarning('时段模板中结束时间须晚于开始时间')
return
}
}
batchSaving.value = true
try {
const rosters: any[] = []
const startDate = dayjs(batchForm.value.dateRange[0])
const endDate = dayjs(batchForm.value.dateRange[1])
let currentDate = startDate
while (currentDate.isBefore(endDate) || currentDate.isSame(endDate, 'day')) {
const weekday = currentDate.day()
if (batchForm.value.weekdays.includes(weekday)) {
batchForm.value.doctorIds.forEach((doctorId) => {
batchForm.value.segments.forEach((seg) => {
const row: any = {
doctor_id: doctorId,
date: currentDate.format('YYYY-MM-DD'),
period: 'segment',
start_time: seg.start_time,
end_time: seg.end_time,
slot_minutes: seg.slot_minutes || 15,
status: batchForm.value.status,
quota: batchForm.value.status === 1 ? seg.quota : 0,
max_patients: 0,
remark: batchForm.value.remark
}
if (seg.shift_type) {
row.shift_type = seg.shift_type
}
rosters.push(row)
})
})
}
currentDate = currentDate.add(1, 'day')
}
if (rosters.length === 0) {
feedback.msgWarning('没有符合条件的排班数据')
return
}
await rosterBatchSave({ rosters })
feedback.msgSuccess(`批量保存成功!共处理 ${rosters.length} 条记录`)
batchDialogVisible.value = false
await loadRosterData()
} catch (error) {
console.error('批量保存失败:', error)
feedback.msgError('批量保存失败')
} finally {
batchSaving.value = false
}
}
onMounted(() => {
loadDoctors()
})
</script>
<style scoped>
.doctor-roster {
min-height: 100vh;
}
</style>