This commit is contained in:
Your Name
2026-03-27 18:06:12 +08:00
parent 9160c36735
commit 099bc1dd22
645 changed files with 276473 additions and 957 deletions
+439
View File
@@ -0,0 +1,439 @@
<template>
<div class="medicine-library">
<el-card shadow="never">
<template #header>
<div class="flex justify-between items-center">
<span class="text-lg font-medium">药品库管理</span>
<el-button type="primary" @click="handleAdd">
<el-icon class="mr-1"><Plus /></el-icon>
添加药品
</el-button>
</div>
</template>
<el-form inline class="mb-4">
<el-form-item label="药材名称">
<el-input v-model="queryParams.name" placeholder="请输入药材名称" clearable style="width: 200px" />
</el-form-item>
<el-form-item label="供应商">
<el-input v-model="queryParams.supplier" placeholder="请输入供应商名称" clearable style="width: 200px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<el-table :data="tableData" border v-loading="loading" stripe>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="image" label="图片" width="100" align="center">
<template #default="{ row }">
<el-image
v-if="row.image"
:src="row.image"
:preview-src-list="[row.image]"
fit="cover"
class="w-16 h-16 rounded"
/>
<span v-else class="text-gray-400">暂无图片</span>
</template>
</el-table-column>
<el-table-column prop="name" label="药材名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="supplier" label="供应商" min-width="120" show-overflow-tooltip />
<el-table-column prop="unit" label="单位" width="80" align="center" />
<el-table-column prop="settlement_price" label="结算价(元)" width="110" align="right">
<template #default="{ row }">
<span class="text-blue-600">{{ formatPrice(row.settlement_price) }}</span>
</template>
</el-table-column>
<el-table-column prop="retail_price" label="零售价(元)" width="110" align="right">
<template #default="{ row }">
<span class="text-green-600 font-medium">{{ formatPrice(row.retail_price) }}</span>
</template>
</el-table-column>
<el-table-column prop="stock" label="库存" width="100" align="center">
<template #default="{ row }">
<el-tag :type="getStockTagType(row.stock)" size="small">
{{ row.stock || 0 }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right" align="center">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<el-pagination
v-model:current-page="queryParams.page_no"
v-model:page-size="queryParams.page_size"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleQuery"
@current-change="handleQuery"
/>
</div>
</el-card>
<!-- 添加/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
destroy-on-close
@close="handleDialogClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="药材名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入药材名称" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item label="供应商" prop="supplier">
<el-input v-model="formData.supplier" placeholder="请输入供应商名称" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item label="单位" prop="unit">
<el-select v-model="formData.unit" placeholder="请选择单位" clearable style="width: 100%">
<el-option label="克" value="克" />
<el-option label="千克" value="千克" />
<el-option label="斤" value="斤" />
<el-option label="盒" value="盒" />
<el-option label="瓶" value="瓶" />
<el-option label="袋" value="袋" />
<el-option label="包" value="包" />
<el-option label="片" value="片" />
<el-option label="粒" value="粒" />
<el-option label="支" value="支" />
</el-select>
</el-form-item>
<el-form-item label="结算价" prop="settlement_price">
<el-input-number
v-model="formData.settlement_price"
:min="0"
:precision="2"
:step="0.1"
controls-position="right"
style="width: 100%"
/>
<span class="ml-2 text-gray-500 text-sm"></span>
</el-form-item>
<el-form-item label="零售价" prop="retail_price">
<el-input-number
v-model="formData.retail_price"
:min="0"
:precision="2"
:step="0.1"
controls-position="right"
style="width: 100%"
/>
<span class="ml-2 text-gray-500 text-sm"></span>
</el-form-item>
<el-form-item label="库存" prop="stock">
<el-input-number
v-model="formData.stock"
:min="0"
:step="1"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="药品图片" prop="image">
<el-upload
class="medicine-uploader"
:action="uploadAction"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleUploadSuccess"
:before-upload="beforeUpload"
accept="image/*"
>
<img v-if="formData.image" :src="formData.image" class="medicine-image" />
<el-icon v-else class="medicine-uploader-icon"><Plus /></el-icon>
</el-upload>
<div class="text-gray-500 text-xs mt-1">建议尺寸300x300像素支持jpgpng格式大小不超过2MB</div>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="formData.remark"
type="textarea"
:rows="3"
placeholder="请输入备注信息"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts" name="medicineLibrary">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessageBox, type FormInstance, type FormRules, type UploadProps } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import feedback from '@/utils/feedback'
import { getToken } from '@/utils/auth'
import { medicineLists, medicineAdd, medicineEdit, medicineDelete } from '@/api/medicine'
interface Medicine {
id?: number
name: string
supplier: string
unit: string
settlement_price: number
retail_price: number
stock: number
image: string
status: number
remark: string
}
const loading = ref(false)
const submitLoading = ref(false)
const dialogVisible = ref(false)
const formRef = ref<FormInstance>()
const queryParams = reactive({
page_no: 1,
page_size: 20,
name: '',
supplier: ''
})
const tableData = ref<Medicine[]>([])
const total = ref(0)
const formData = ref<Medicine>({
name: '',
supplier: '',
unit: '克',
settlement_price: 0,
retail_price: 0,
stock: 0,
image: '',
status: 1,
remark: ''
})
const formRules: FormRules = {
name: [{ required: true, message: '请输入药材名称', trigger: 'blur' }],
supplier: [{ required: true, message: '请输入供应商名称', trigger: 'blur' }],
unit: [{ required: true, message: '请选择单位', trigger: 'change' }],
settlement_price: [{ required: true, message: '请输入结算价', trigger: 'blur' }],
retail_price: [{ required: true, message: '请输入零售价', trigger: 'blur' }]
}
const dialogTitle = computed(() => {
return formData.value.id ? '编辑药品' : '添加药品'
})
const uploadAction = computed(() => {
return import.meta.env.VITE_APP_BASE_URL + '/api/upload/image'
})
const uploadHeaders = computed(() => {
return {
token: getToken()
}
})
const formatPrice = (price: number | string | null | undefined) => {
if (price === null || price === undefined || price === '') return '0.00'
const num = typeof price === 'string' ? parseFloat(price) : price
return isNaN(num) ? '0.00' : num.toFixed(2)
}
const getStockTagType = (stock: number) => {
if (stock === 0) return 'danger'
if (stock < 10) return 'warning'
return 'success'
}
const handleQuery = () => {
loadData()
}
const handleReset = () => {
queryParams.name = ''
queryParams.supplier = ''
queryParams.page_no = 1
loadData()
}
const handleAdd = () => {
formData.value = {
name: '',
supplier: '',
unit: '克',
settlement_price: 0,
retail_price: 0,
stock: 0,
image: '',
status: 1,
remark: ''
}
dialogVisible.value = true
}
const handleEdit = (row: Medicine) => {
formData.value = { ...row }
dialogVisible.value = true
}
const handleDelete = async (row: Medicine) => {
try {
await ElMessageBox.confirm('确定要删除该药品吗?', '提示', {
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消'
})
await medicineDelete({ id: row.id })
feedback.msgSuccess('删除成功')
loadData()
} catch (error) {
if (error !== 'cancel') {
feedback.msgError('删除失败')
}
}
}
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
if (formData.value.id) {
await medicineEdit(formData.value)
} else {
await medicineAdd(formData.value)
}
feedback.msgSuccess('保存成功')
dialogVisible.value = false
loadData()
} catch (error) {
feedback.msgError('保存失败')
} finally {
submitLoading.value = false
}
})
}
const handleDialogClose = () => {
formRef.value?.resetFields()
}
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
const isImage = file.type.startsWith('image/')
const isLt2M = file.size / 1024 / 1024 < 2
if (!isImage) {
feedback.msgError('只能上传图片文件!')
return false
}
if (!isLt2M) {
feedback.msgError('图片大小不能超过 2MB!')
return false
}
return true
}
const handleUploadSuccess: UploadProps['onSuccess'] = (response) => {
if (response.code === 1 && response.data?.url) {
formData.value.image = response.data.url
feedback.msgSuccess('上传成功')
} else {
feedback.msgError(response.msg || '上传失败')
}
}
const loadData = async () => {
loading.value = true
try {
const res = await medicineLists(queryParams)
tableData.value = res.lists || []
total.value = res.count || 0
} catch (error) {
console.error('加载数据失败:', error)
feedback.msgError('加载数据失败')
} finally {
loading.value = false
}
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.medicine-library {
padding: 16px;
}
.medicine-uploader {
width: 148px;
height: 148px;
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
}
.medicine-uploader:hover {
border-color: var(--el-color-primary);
}
.medicine-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 148px;
height: 148px;
text-align: center;
line-height: 148px;
}
.medicine-image {
width: 148px;
height: 148px;
display: block;
object-fit: cover;
}
</style>
+465 -204
View File
@@ -12,7 +12,7 @@
</div>
</div>
</template>
<div class="mb-4">
<el-form inline>
<el-form-item label="医生">
@@ -24,20 +24,34 @@
</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="tableData" border v-loading="loading">
<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="150">
<el-table-column v-for="day in weekDays" :key="day.date" :label="day.label" min-width="168">
<template #default="{ row }">
<div class="space-y-1">
<div class="text-xs p-1 bg-gray-100 rounded cursor-pointer hover:bg-gray-200" @click="editRoster(row, day.date, 'morning')">
上午: {{ getRosterText(row, day.date, 'morning') }}
</div>
<div class="text-xs p-1 bg-gray-100 rounded cursor-pointer hover:bg-gray-200" @click="editRoster(row, day.date, 'afternoon')">
下午: {{ getRosterText(row, day.date, 'afternoon') }}
<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>
@@ -45,48 +59,106 @@
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" title="编辑排班" width="500px">
<el-form :model="editForm" label-width="100px">
<el-form-item label="医生">
<span>{{ editForm.doctorName }}</span>
</el-form-item>
<el-form-item label="日期">
<span>{{ editForm.date }}</span>
</el-form-item>
<el-form-item label="时段">
<span>{{ editForm.period === 'morning' ? '上午' : '下午' }}</span>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="editForm.status">
<el-radio :label="1">出诊</el-radio>
<el-radio :label="2">停诊</el-radio>
<el-radio :label="3">休息</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="号源数" v-if="editForm.status === 1">
<el-input-number v-model="editForm.quota" :min="0" :max="100" />
</el-form-item>
</el-form>
<!-- 某日排班列表 -->
<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="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveRoster">保存</el-button>
<el-button @click="dayDialogVisible = false">关闭</el-button>
<el-button type="primary" @click="openSegmentForm()">添加时段</el-button>
</template>
</el-dialog>
<!-- 批量排班弹窗 -->
<el-dialog v-model="batchDialogVisible" title="批量排班" width="700px">
<el-form :model="batchForm" label-width="100px">
<el-form-item label="选择医生" required>
<el-select v-model="batchForm.doctorIds" multiple placeholder="请选择医生" class="w-full">
<el-option
v-for="doctor in tableData"
:key="doctor.doctorId"
:label="doctor.doctorName"
:value="doctor.doctorId"
<!-- 单段排班表单 -->
<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:15"
end="23:45"
placeholder="开始"
style="width: 120px"
/>
<span></span>
<el-time-select
v-model="segmentForm.end_time"
start="00:00"
step="00:15"
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"
@@ -100,14 +172,7 @@
/>
</el-form-item>
<el-form-item label="选择时段" required>
<el-checkbox-group v-model="batchForm.periods">
<el-checkbox label="morning">上午</el-checkbox>
<el-checkbox label="afternoon">下午</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="选择星期" required>
<el-form-item label="星期" required>
<el-checkbox-group v-model="batchForm.weekdays">
<el-checkbox :label="1">周一</el-checkbox>
<el-checkbox :label="2">周二</el-checkbox>
@@ -119,6 +184,27 @@
</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:15" end="23:45" placeholder="开始" style="width: 110px" />
<span></span>
<el-time-select v-model="seg.end_time" start="00:00" step="00:15" 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>
@@ -128,16 +214,8 @@
</el-radio-group>
</el-form-item>
<el-form-item label="号源数" v-if="batchForm.status === 1">
<el-input-number v-model="batchForm.quota" :min="0" :max="100" />
</el-form-item>
<el-form-item label="最大接诊数" v-if="batchForm.status === 1">
<el-input-number v-model="batchForm.maxPatients" :min="0" :max="100" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="batchForm.remark" type="textarea" :rows="2" placeholder="请输入备注" />
<el-input v-model="batchForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
@@ -155,99 +233,92 @@ import isoWeek from 'dayjs/plugin/isoWeek'
import feedback from '@/utils/feedback'
import { adminLists } from '@/api/perms/admin'
import { rosterLists, rosterSave, rosterDelete, rosterBatchSave } from '@/api/doctor'
import { ElMessageBox } from 'element-plus'
dayjs.extend(isoWeek)
interface RosterItem {
id?: number
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
rosters: Record<string, RosterItem>
/** date -> segments */
rostersByDate: Record<string, RosterSegment[]>
}
const searchName = ref('')
const currentWeekStart = ref(dayjs().startOf('isoWeek'))
const dialogVisible = ref(false)
const loading = ref(false)
const doctorOptions = ref<any[]>([])
// 批量排班
const batchDialogVisible = ref(false)
const batchSaving = ref(false)
const batchForm = ref({
doctorIds: [] as number[],
dateRange: [] as string[],
periods: ['morning', 'afternoon'] as string[],
weekdays: [1, 2, 3, 4, 5] as number[], // 默认周一到周五
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: 20,
maxPatients: 30,
quota: 0,
max_patients: 0,
remark: ''
})
// 加载医生列表(role_id=1
const loadDoctors = async () => {
try {
const res = await adminLists({
page_no: 1,
page_size: 1000,
role_id: 1 // 只获取医生角色
})
doctorOptions.value = res?.lists || []
// 转换为表格数据格式
tableData.value = (res?.lists || []).map((doctor: any) => ({
doctorId: doctor.id,
doctorName: doctor.name || doctor.account,
rosters: {}
}))
// 加载排班数据
await loadRosterData()
} catch (error) {
console.error('加载医生列表失败:', error)
feedback.msgError('加载医生列表失败')
}
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 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.rosters = {}
rosters.forEach((roster: any) => {
if (roster.doctor_id === doctor.doctorId) {
const key = `${roster.date}_${roster.period}`
doctor.rosters[key] = {
id: roster.id,
status: roster.status,
quota: roster.quota || 0
}
}
})
})
} catch (error) {
console.error('加载排班数据失败:', error)
feedback.msgError('加载排班数据失败')
} finally {
loading.value = false
}
}
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 = []
@@ -268,61 +339,228 @@ const weekText = computed(() => {
return `${start}${end}`
})
const tableData = ref<DoctorRow[]>([])
const editForm = ref({
id: null as number | null,
doctorId: 0,
doctorName: '',
date: '',
period: 'morning',
status: 1,
quota: 20
})
const getRosterText = (row: DoctorRow, date: string, period: string) => {
const key = `${date}_${period}`
const roster = row.rosters[key]
if (!roster) return '未排班'
const statusMap: Record<number, string> = { 1: '出诊', 2: '停诊', 3: '休息' }
return roster.status === 1 ? `${statusMap[roster.status]}(${roster.quota})` : statusMap[roster.status]
function statusText(s: number) {
const m: Record<number, string> = { 1: '出诊', 2: '停诊', 3: '休息', 4: '请假' }
return m[s] ?? String(s)
}
const editRoster = (row: DoctorRow, date: string, period: string) => {
const key = `${date}_${period}`
const roster = row.rosters[key] || { status: 1, quota: 20 }
editForm.value = {
id: roster.id || null,
doctorId: row.doctorId,
doctorName: row.doctorName,
date,
period,
status: roster.status,
quota: roster.quota
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}`
}
dialogVisible.value = true
return `${t}${shPart} ${statusText(seg.status)}`
}
const saveRoster = async () => {
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 {
await rosterSave({
id: editForm.value.id,
doctor_id: editForm.value.doctorId,
date: editForm.value.date,
period: editForm.value.period,
status: editForm.value.status,
quota: editForm.value.status === 1 ? editForm.value.quota : 0
})
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('保存成功')
dialogVisible.value = false
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 adminLists({
page_no: 1,
page_size: 1000,
role_id: 1
})
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('保存失败')
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()
}
@@ -342,24 +580,44 @@ const goToday = () => {
loadRosterData()
}
// 显示批量排班弹窗
const showBatchDialog = () => {
batchForm.value = {
doctorIds: [],
dateRange: [],
periods: ['morning', 'afternoon'],
weekdays: [1, 2, 3, 4, 5],
status: 1,
quota: 20,
maxPatients: 30,
remark: ''
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
@@ -368,46 +626,51 @@ const handleBatchSave = async () => {
feedback.msgWarning('请选择日期范围')
return
}
if (batchForm.value.periods.length === 0) {
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() // 0-60是周日
// 检查是否在选中的星期内
const weekday = currentDate.day()
if (batchForm.value.weekdays.includes(weekday)) {
// 遍历医生
batchForm.value.doctorIds.forEach(doctorId => {
// 遍历时段
batchForm.value.periods.forEach(period => {
rosters.push({
batchForm.value.doctorIds.forEach((doctorId) => {
batchForm.value.segments.forEach((seg) => {
const row: any = {
doctor_id: doctorId,
date: currentDate.format('YYYY-MM-DD'),
period: period,
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 ? batchForm.value.quota : 0,
max_patients: batchForm.value.status === 1 ? batchForm.value.maxPatients : 0,
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')
}
@@ -416,9 +679,7 @@ const handleBatchSave = async () => {
return
}
// 调用批量保存接口
await rosterBatchSave({ rosters })
feedback.msgSuccess(`批量保存成功!共处理 ${rosters.length} 条记录`)
batchDialogVisible.value = false
await loadRosterData()
+257
View File
@@ -0,0 +1,257 @@
<template>
<div class="statistics-page">
<el-card shadow="never" class="!border-none">
<!-- 筛选区域 -->
<el-form :inline="true" :model="queryParams" class="filter-form">
<el-form-item label="医生">
<el-select
v-model="queryParams.doctor_id"
placeholder="全部医生"
clearable
filterable
style="width: 200px"
>
<el-option
v-for="doctor in doctorList"
:key="doctor.id"
:label="doctor.name"
:value="doctor.id"
/>
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-radio-group v-model="queryParams.time_type" @change="handleTimeTypeChange">
<el-radio-button label="today">今天</el-radio-button>
<el-radio-button label="week">最近7天</el-radio-button>
<el-radio-button label="month">最近30天</el-radio-button>
<el-radio-button label="custom">自定义</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="queryParams.time_type === 'custom'">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 260px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getStatistics" :loading="loading">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 统计表格 -->
<el-card shadow="never" class="!border-none mt-4">
<el-table
v-loading="loading"
:data="statisticsList"
border
stripe
style="width: 100%"
:header-cell-style="{ background: '#f5f7fa', color: '#606266', fontWeight: '600' }"
>
<el-table-column prop="doctor_name" label="医生姓名" width="120" fixed />
<el-table-column label="部门统计" min-width="250" fixed>
<template #default="{ row }">
<div class="dept-stats">
<el-tag
v-for="(item, index) in parseDeptStats(row.dept_stats)"
:key="index"
type="primary"
size="small"
class="stat-tag"
>
{{ item }}
</el-tag>
<span v-if="!row.dept_stats || row.dept_stats === '未分配'" class="text-gray-400">未分配</span>
</div>
</template>
</el-table-column>
<el-table-column label="渠道统计" min-width="250">
<template #default="{ row }">
<div class="channel-stats">
<el-tag
v-for="(item, index) in parseChannelStats(row.channel_stats)"
:key="index"
type="success"
size="small"
class="stat-tag"
>
{{ item }}
</el-tag>
<span v-if="!row.channel_stats || row.channel_stats === '未设置'" class="text-gray-400">未设置</span>
</div>
</template>
</el-table-column>
<el-table-column prop="total_count" label="总诊单" width="90" align="center">
<template #default="{ row }">
<span class="font-semibold text-blue-600">{{ row.total_count }}</span>
</template>
</el-table-column>
<el-table-column prop="registered_count" label="已挂号" width="90" align="center">
<template #default="{ row }">
<el-tag type="info" size="small">{{ row.registered_count }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="completed_count" label="已完成" width="90" align="center">
<template #default="{ row }">
<el-tag type="success" size="small">{{ row.completed_count }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="missed_count" label="已过号" width="90" align="center">
<template #default="{ row }">
<el-tag type="warning" size="small">{{ row.missed_count }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="cancelled_count" label="已取消" width="90" align="center">
<template #default="{ row }">
<el-tag type="danger" size="small">{{ row.cancelled_count }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="completion_rate" label="完成率" width="100" align="center">
<template #default="{ row }">
<span :class="getCompletionRateClass(row.completion_rate)">
{{ row.completion_rate }}%
</span>
</template>
</el-table-column>
<el-table-column prop="time_range" label="统计时间" min-width="200" />
<template #empty>
<el-empty description="暂无统计数据" />
</template>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { doctorLists, getDoctorStatistics } from '@/api/doctor'
const loading = ref(false)
const doctorList = ref<any[]>([])
const statisticsList = ref<any[]>([])
const dateRange = ref<string[]>([])
const queryParams = reactive({
doctor_id: '',
time_type: 'today',
start_date: '',
end_date: ''
})
const getDoctorList = async () => {
try {
const res = await doctorLists({ role_id: 1 })
doctorList.value = res.lists || []
} catch (error) {
console.error('获取医生列表失败:', error)
}
}
const getStatistics = async () => {
loading.value = true
try {
const params: any = {
time_type: queryParams.time_type
}
if (queryParams.doctor_id) {
params.doctor_id = queryParams.doctor_id
}
if (queryParams.time_type === 'custom' && dateRange.value && dateRange.value.length === 2) {
params.start_date = dateRange.value[0]
params.end_date = dateRange.value[1]
}
const res = await getDoctorStatistics(params)
statisticsList.value = res.lists || []
} catch (error: any) {
console.error('获取统计数据错误:', error)
ElMessage.error(error.msg || '获取统计数据失败')
} finally {
loading.value = false
}
}
const handleTimeTypeChange = () => {
if (queryParams.time_type !== 'custom') {
dateRange.value = []
}
}
const handleReset = () => {
queryParams.doctor_id = ''
queryParams.time_type = 'today'
dateRange.value = []
getStatistics()
}
const getCompletionRateClass = (rate: number) => {
if (rate >= 80) return 'text-green-600 font-semibold'
if (rate >= 60) return 'text-blue-600 font-semibold'
if (rate >= 40) return 'text-orange-600 font-semibold'
return 'text-red-600 font-semibold'
}
// 解析部门统计字符串为数组
const parseDeptStats = (stats: string) => {
if (!stats || stats === '未分配') return []
return stats.split(' ').filter(item => item.trim())
}
// 解析渠道统计字符串为数组
const parseChannelStats = (stats: string) => {
if (!stats || stats === '未设置') return []
return stats.split(' ').filter(item => item.trim())
}
onMounted(() => {
getDoctorList()
getStatistics()
})
</script>
<style scoped lang="scss">
.statistics-page {
padding: 20px;
}
.filter-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.dept-stats,
.channel-stats {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.stat-tag {
font-weight: 500;
}
</style>