<template>
|
<!--
|
预约时段维护(queue_sequence)
|
|
入口:检查项目管理列表 → 操作列「预约时段」按钮
|
功能:对**某一个检查项目**的预约时段规则做增 / 删 / 改
|
|
背景:该表原先每天自动生成,现已改为手工维护,此前没有维护界面。
|
-->
|
<Dialog :title="dialogTitle" v-model="dialogVisible" width="65%" >
|
<!-- 顶部说明:这些字段的含义与"每日重置"的关系 -->
|
<el-alert type="info" :closable="false" class="mb-10px">
|
<template #title>
|
<div class="text-13px leading-6">
|
正在维护检查项目:<b>{{ checkTypeName }}</b>
|
<span class="text-gray-500">(检查项目值 {{ checkTypeValue }},共 {{ slotList.length }} 个时段)</span>
|
</div>
|
</template>
|
<div class="text-12px leading-5">
|
· <b>号段规则</b>:每个时段按顺序占用一段连续号 —— 正常预约号在前,预留号紧跟其后(号更大),
|
下一段从上一段的最大号继续,因此<b>号随时段递增、段内连续</b>。<br />
|
· <b>起始序号</b>:由系统自动计算(上一段的最大号),<b>不可手工修改</b>。<br />
|
· <b>上限</b>:该号池「号满的最大序号」;<b>当前序号</b>:已发到几号(每日重置回起始序号)。<br />
|
· <b>取号顺序</b>:普通患者 → 本时段正常预约号;满了 → 本时段<b>预留号</b>(给临时加号);
|
再满 → <b>总预留池</b>(无预约时段的患者也直接走总预留池)。<b>总预留池不设上限,保证一定能签到。</b><br />
|
· <b>预定额度</b> = 上限 − 起始序号(只读)。上限 = 起始序号 表示该号池无名额(允许保存,显示 0 并标红)。<br />
|
· 上限 <b>不能小于</b>起始序号;同一检查项目下<b>同一时段只能有一条规则</b>。
|
</div>
|
</el-alert>
|
|
<div class="mb-10px">
|
<el-button type="primary" @click="openForm('create')" :disabled="!checkTypeValue">
|
<Icon icon="ep:plus" class="mr-5px" /> 新增时段
|
</el-button>
|
<el-button @click="getList" :loading="loading">
|
<Icon icon="ep:refresh" class="mr-5px" /> 刷新
|
</el-button>
|
<span v-if="changedCount > 0" class="ml-10px text-13px text-orange-500">
|
本次已修改 {{ changedCount }} 个时段,点「刷新」可放弃未保存的展示改动
|
</span>
|
</div>
|
|
<el-table
|
v-loading="loading"
|
:data="slotList"
|
:stripe="true"
|
border
|
class="qs-table"
|
height="calc(100vh - 300px)"
|
>
|
<!-- ============ ① 时段 ============ -->
|
<el-table-column label="时段" align="center" width="96">
|
<template #default="scope">
|
<b class="text-12px" :title="String(scope.row.timeSlot ?? '')">
|
{{ formatTimeSlot(scope.row.timeSlot) }}
|
</b>
|
</template>
|
</el-table-column>
|
|
<!-- ============ ② 正常预约 ============ -->
|
<el-table-column label="正常预约" align="center">
|
<el-table-column label="起始序号" align="center" width="90">
|
<template #default="scope">
|
<!-- 【本需求】起始序号 = 号段起点,由系统按顺序自动计算,不允许手工改 -->
|
<span class="qs-fixed" :title="'系统自动计算(上一段的最大号)'">
|
{{ scope.row.queueSet ?? 0 }}
|
</span>
|
</template>
|
</el-table-column>
|
<el-table-column label="上限" align="center" width="80">
|
<template #default="scope">
|
<el-input-number
|
v-model="scope.row.queueFull"
|
:min="0"
|
:controls="false"
|
size="small"
|
style="width: 100%"
|
@change="markChanged(scope.row)"
|
/>
|
</template>
|
</el-table-column>
|
<el-table-column label="预定额度" align="center" width="90">
|
<template #default="scope">
|
<span
|
class="qs-quota"
|
:class="{ 'qs-quota--zero': quotaOf(scope.row.queueSet, scope.row.queueFull) <= 0 }"
|
:title="`上限 ${scope.row.queueFull ?? 0} − 起始 ${scope.row.queueSet ?? 0}`"
|
>
|
{{ quotaOf(scope.row.queueSet, scope.row.queueFull) }}
|
</span>
|
</template>
|
</el-table-column>
|
<el-table-column label="当前序号" align="center" width="90">
|
<template #default="scope">
|
<el-input-number
|
v-model="scope.row.queueNo"
|
:min="0"
|
:controls="false"
|
size="small"
|
style="width: 100%"
|
@change="markChanged(scope.row)"
|
/>
|
</template>
|
</el-table-column>
|
<el-table-column label="状态" align="center" width="90">
|
<template #default="scope">
|
<el-tag v-if="isFull(scope.row)" type="danger" size="small">已满</el-tag>
|
<el-tag v-else type="success" size="small">可取号</el-tag>
|
</template>
|
</el-table-column>
|
</el-table-column>
|
|
<!-- ============ ③ 预留 ============ -->
|
<el-table-column label="预留" align="center">
|
<el-table-column label="起始序号" align="center" width="90">
|
<template #default="scope">
|
<!-- 【本需求】预留号段起点,同样由系统自动计算 -->
|
<span class="qs-fixed" :title="'系统自动计算(正常预约的上限)'">
|
{{ scope.row.queueVipSet ?? 0 }}
|
</span>
|
</template>
|
</el-table-column>
|
<el-table-column label="上限" align="center" width="80">
|
<template #default="scope">
|
<el-input-number
|
v-model="scope.row.queueVipFull"
|
:min="0"
|
:controls="false"
|
size="small"
|
style="width: 100%"
|
@change="markChanged(scope.row)"
|
/>
|
</template>
|
</el-table-column>
|
<el-table-column label="预定额度" align="center" width="90">
|
<template #default="scope">
|
<span
|
class="qs-quota"
|
:class="{ 'qs-quota--zero': quotaOf(scope.row.queueVipSet, scope.row.queueVipFull) <= 0 }"
|
:title="`上限 ${scope.row.queueVipFull ?? 0} − 起始 ${scope.row.queueVipSet ?? 0}`"
|
>
|
{{ quotaOf(scope.row.queueVipSet, scope.row.queueVipFull) }}
|
</span>
|
</template>
|
</el-table-column>
|
<el-table-column label="当前序号" align="center" width="90">
|
<template #default="scope">
|
<el-input-number
|
v-model="scope.row.queueVipNo"
|
:min="0"
|
:controls="false"
|
size="small"
|
style="width: 100%"
|
@change="markChanged(scope.row)"
|
/>
|
</template>
|
</el-table-column>
|
<el-table-column label="状态" align="center" width="90">
|
<template #default="scope">
|
<el-tag v-if="isFullVip(scope.row)" type="danger" size="small">已满</el-tag>
|
<el-tag v-else type="success" size="small">可取号</el-tag>
|
</template>
|
</el-table-column>
|
</el-table-column>
|
|
<!-- ============ ④ 操作 ============ -->
|
<el-table-column label="操作" align="center" width="160" fixed="right">
|
<template #default="scope">
|
<el-button
|
link
|
type="primary"
|
:loading="savingId === scope.row.id"
|
|
@click="handleRowSave(scope.row)"
|
>
|
保存
|
</el-button>
|
<el-button link type="primary" @click="openForm('update', scope.row)">
|
编辑
|
</el-button>
|
<el-button link type="danger" @click="handleDelete(scope.row)">
|
删除
|
</el-button>
|
</template>
|
</el-table-column>
|
</el-table>
|
|
<!--
|
【本需求】总预留池(time_slot = 0)
|
· 本段普通号 + 本段预留号都发完,或患者没有预约时段时,从总预留池取号;
|
· 号段放在当天所有时段之后(号最大 ⇒ 排全天最后),且**不设上限**(永远不会满)。
|
-->
|
<el-alert type="warning" :closable="false" class="mt-10px">
|
<template #title>
|
<div class="text-13px">
|
总预留池(无时段 / 所有号源用尽时的兜底):
|
<template v-if="totalPool">
|
起始 <b>{{ totalPool.queueVipSet ?? 0 }}</b> ·
|
已发 <b>{{ totalPool.queueVipNo ?? 0 }}</b> ·
|
上限 <b>{{ totalPool.queueVipFull ?? 0 }}</b>
|
<span class="text-gray-500">(不设上限,满后自动继续发号,保证一定能签到)</span>
|
</template>
|
<template v-else>
|
<span class="text-gray-500">尚未初始化(首次签到时系统会自动创建,号段接在当天最后一段之后)</span>
|
</template>
|
</div>
|
</template>
|
</el-alert>
|
|
<!-- 新增 / 编辑表单 -->
|
<Dialog v-model="formVisible" :title="formTitle" width="520px" append-to-body>
|
<el-form
|
ref="formRef"
|
:model="formData"
|
:rules="formRules"
|
label-width="120px"
|
v-loading="formLoading"
|
>
|
<el-form-item label="时段" prop="timeSlot">
|
<el-select
|
v-model="formData.timeSlot"
|
placeholder="请选择预约时段"
|
class="w-full"
|
filterable
|
allow-create
|
default-first-option
|
>
|
<el-option
|
v-for="opt in timeSlotOptions"
|
:key="opt.value"
|
:label="opt.label"
|
:value="opt.value"
|
/>
|
</el-select>
|
<div class="text-12px text-gray-500 leading-5 mt-1">
|
选项来自字典 <code>ecg_book_timeslot</code>;也支持直接输入起始/结束时间后回车新建。
|
</div>
|
</el-form-item>
|
<el-form-item label="正常预约-起始序号" prop="queueSet">
|
<!-- 【本需求】起始序号 = 号段起点,由系统按「检查类型 → 时段顺序」自动计算,不可手工改 -->
|
<el-input-number v-model="formData.queueSet" :min="0" class="w-full" disabled />
|
<div class="text-12px text-gray-500 leading-5 mt-1">
|
系统自动计算(= 上一段的最大号),不需要填写。
|
</div>
|
</el-form-item>
|
<el-form-item label="正常预约-上限" prop="queueFull">
|
<!-- 【本次需求】上限允许等于起始序号(=该时段无名额),故 min 改为 0 -->
|
<el-input-number v-model="formData.queueFull" :min="0" class="w-full" />
|
</el-form-item>
|
<el-form-item label="预留-起始序号" prop="queueVipSet">
|
<!-- 【本需求】预留号段紧跟正常预约号段之后,由系统自动计算 -->
|
<el-input-number v-model="formData.queueVipSet" :min="0" class="w-full" disabled />
|
<div class="text-12px text-gray-500 leading-5 mt-1">
|
系统自动计算(= 正常预约的上限),不需要填写。
|
</div>
|
</el-form-item>
|
<el-form-item label="预留-上限" prop="queueVipFull">
|
<!-- 【本次需求】同普通上限:允许等于起始序号 -->
|
<el-input-number v-model="formData.queueVipFull" :min="0" class="w-full" />
|
</el-form-item>
|
<el-form-item label="正常预约-当前序号">
|
<el-input-number v-model="formData.queueNo" :min="0" class="w-full" />
|
<div class="text-12px text-gray-500 leading-5 mt-1">
|
新增时段时建议与「正常预约-起始序号」一致。
|
</div>
|
</el-form-item>
|
<el-form-item label="预留-当前序号">
|
<el-input-number v-model="formData.queueVipNo" :min="0" class="w-full" />
|
</el-form-item>
|
</el-form>
|
<template #footer>
|
<el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
|
<el-button @click="formVisible = false">取 消</el-button>
|
</template>
|
</Dialog>
|
</Dialog>
|
</template>
|
|
<script setup lang="ts">
|
import { ElMessageBox } from 'element-plus'
|
import {
|
QueueSequenceApi,
|
QueueSequenceVO,
|
formatTimeSlot,
|
parseTimeSlot
|
} from '@/api/ecg/queuesequence'
|
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
|
|
/** 预约时段维护 弹窗 */
|
defineOptions({ name: 'QueueSequenceForm' })
|
|
const message = useMessage() // 消息弹窗
|
const dialogVisible = ref(false) // 主弹窗
|
const loading = ref(false) // 列表加载中
|
const list = ref<QueueSequenceVO[]>([]) // 某检查项目下的全部时段
|
const checkTypeValue = ref<number>() // 当前维护的检查项目值
|
const checkTypeName = ref<string>('') // 检查项目名称(仅用于展示)
|
const changedRows = ref<Set<number>>(new Set()) // 已被改动但未保存的行 id
|
const savingId = ref<number>() // 正在保存的行 id
|
const dialogTitle = computed(
|
() => `预约时段维护${checkTypeName.value ? ' - ' + checkTypeName.value : ''}`
|
)
|
const changedCount = computed(() => changedRows.value.size)
|
|
/**
|
* 【本需求】表格只显示"真实时段"(time_slot <> 0);
|
* time_slot = 0 的那一行是「总预留池」(无时段 / 全满时的兜底号池),单独在表格下方展示。
|
*/
|
const slotList = computed(() =>
|
(list.value || []).filter((r: QueueSequenceVO) => Number(r.timeSlot ?? 0) !== 0)
|
)
|
|
/** 总预留池行(time_slot = 0);未初始化时为 undefined */
|
const totalPool = computed(() =>
|
(list.value || []).find((r: QueueSequenceVO) => Number(r.timeSlot ?? 0) === 0)
|
)
|
|
/** 时段下拉选项:取自字典 ecg_book_timeslot(value 为 HHMMHHMM 数字字符串,label 形如 08:00-08:30) */
|
const timeSlotOptions = computed(() => {
|
const opts = getIntDictOptions(DICT_TYPE.ECG_BOOK_TIMESLOT) || []
|
return opts
|
.map((d: any) => {
|
const value = Number(d.value)
|
const label = (d.label || '').trim()
|
return {
|
value,
|
label: label ? `${label}(${value})` : `${formatTimeSlot(value)}(${value})`
|
}
|
})
|
.filter((o: any) => !Number.isNaN(o.value))
|
})
|
|
// ==================== 表单(新增/编辑) ====================
|
const formVisible = ref(false)
|
const formTitle = ref('')
|
const formLoading = ref(false)
|
const formRef = ref()
|
const formData = ref<QueueSequenceVO>({})
|
|
const formRules = reactive({
|
timeSlot: [{ required: true, message: '时段不能为空', trigger: 'change' }],
|
queueSet: [{ required: true, message: '普通起始序号不能为空', trigger: 'blur' }],
|
queueFull: [
|
{ required: true, message: '普通序号上限不能为空', trigger: 'blur' },
|
{
|
validator: (_rule: any, value: any, callback: any) => {
|
const set = formData.value.queueSet
|
// 【本次需求】上限 = 起始序号 是**合法**的:表示该时段没有名额(预定额度 0),允许保存。
|
// 只有「上限 < 起始序号」才是配置错误(该时段永远无法取号)。
|
if (value !== undefined && value !== null && set !== undefined && set !== null && value < set) {
|
callback(new Error('上限不能小于起始序号(等于表示该时段没有名额)'))
|
} else {
|
callback()
|
}
|
},
|
trigger: 'blur'
|
}
|
],
|
queueVipFull: [
|
{
|
validator: (_rule: any, value: any, callback: any) => {
|
const set = formData.value.queueVipSet
|
if (value !== undefined && value !== null && set !== undefined && set !== null && value < set) {
|
callback(new Error('预留上限不能小于预留起始序号(等于表示预留没有名额)'))
|
} else {
|
callback()
|
}
|
},
|
trigger: 'blur'
|
}
|
]
|
})
|
|
/** 打开维护弹窗 */
|
const open = async (checkTypeVO: any) => {
|
checkTypeValue.value = checkTypeVO?.value
|
checkTypeName.value = checkTypeVO?.name ?? ''
|
changedRows.value = new Set()
|
dialogVisible.value = true
|
await getList()
|
}
|
|
defineExpose({ open })
|
|
/** 加载该检查项目下的全部时段 */
|
const getList = async () => {
|
if (!checkTypeValue.value) {
|
list.value = []
|
return
|
}
|
loading.value = true
|
try {
|
const data = await QueueSequenceApi.getTimeslotByCheckType(checkTypeValue.value)
|
// 后端按 id 返回;这里按 timeSlot 升序,便于按时间顺序检视
|
list.value = (data || []).sort(
|
(a: QueueSequenceVO, b: QueueSequenceVO) => (a.timeSlot ?? 0) - (b.timeSlot ?? 0)
|
)
|
changedRows.value = new Set()
|
} finally {
|
loading.value = false
|
}
|
}
|
|
/** 行内改动标记 */
|
const markChanged = (row: QueueSequenceVO) => {
|
if (row.id) {
|
const s = new Set(changedRows.value)
|
s.add(row.id)
|
changedRows.value = s
|
}
|
}
|
|
/**
|
* 预定额度 = 上限 − 起始序号(小于 0 按 0 显示)。
|
*
|
* 【本次需求】该列只读,值为 0 时单元格浅红底 —— 表示这个时段**没有名额**。
|
* 「上限 = 起始序号」是合法配置(无名额时段),不再当作错误。
|
*/
|
const quotaOf = (set?: number | null, full?: number | null) =>
|
Math.max(0, (full ?? 0) - (set ?? 0))
|
|
/**
|
* 该时段是否已满(正常预约)。
|
*
|
* 满的两种情形:
|
* ① 预定额度 <= 0(上限 <= 起始序号)→ 本来就没有名额;
|
* ② 当前序号已达到上限。
|
*/
|
const isFull = (row: QueueSequenceVO) => {
|
if (quotaOf(row.queueSet, row.queueFull) <= 0) return true
|
return (row.queueNo ?? 0) >= (row.queueFull ?? 0)
|
}
|
|
/** 该时段是否已满(预留组),口径同上 */
|
const isFullVip = (row: QueueSequenceVO) => {
|
if (quotaOf(row.queueVipSet, row.queueVipFull) <= 0) return true
|
return (row.queueVipNo ?? 0) >= (row.queueVipFull ?? 0)
|
}
|
|
/** 行内保存(只改数值,不动时段) */
|
const handleRowSave = async (row: QueueSequenceVO) => {
|
if (!row.id) return
|
// 【本次需求】只有「上限 < 起始序号」才是错误;相等表示该时段没有名额,允许保存。
|
if ((row.queueFull ?? 0) < (row.queueSet ?? 0)) {
|
message.error(
|
`时段 ${formatTimeSlot(row.timeSlot)}:上限不能小于起始序号(等于表示该时段没有名额)`
|
)
|
return
|
}
|
if ((row.queueVipFull ?? 0) < (row.queueVipSet ?? 0)) {
|
message.error(
|
`时段 ${formatTimeSlot(row.timeSlot)}:预留上限不能小于预留起始序号(等于表示预留没有名额)`
|
)
|
return
|
}
|
savingId.value = row.id
|
try {
|
await QueueSequenceApi.updateQueueSequence({ ...row, checkType: checkTypeValue.value })
|
message.success('保存成功')
|
const s = new Set(changedRows.value)
|
s.delete(row.id)
|
changedRows.value = s
|
} finally {
|
savingId.value = undefined
|
}
|
}
|
|
/** 打开新增/编辑表单 */
|
const openForm = (type: string, row?: QueueSequenceVO) => {
|
formVisible.value = true
|
formTitle.value = type === 'create' ? '新增预约时段' : '编辑预约时段'
|
if (type === 'update' && row) {
|
formData.value = { ...row }
|
} else {
|
formData.value = {
|
checkType: checkTypeValue.value,
|
timeSlot: undefined,
|
queueSet: 0,
|
queueFull: 10,
|
queueNo: 0,
|
queueVipSet: 0,
|
queueVipFull: 10,
|
queueVipNo: 0
|
}
|
}
|
}
|
|
/** 提交表单 */
|
const submitForm = async () => {
|
if (!formRef.value) return
|
await formRef.value.validate()
|
formLoading.value = true
|
try {
|
const data = { ...formData.value, checkType: checkTypeValue.value }
|
if (data.id) {
|
await QueueSequenceApi.updateQueueSequence(data)
|
message.success('修改成功')
|
} else {
|
await QueueSequenceApi.createQueueSequence(data)
|
message.success('新增成功')
|
}
|
formVisible.value = false
|
await getList()
|
} finally {
|
formLoading.value = false
|
}
|
}
|
|
/** 删除时段 */
|
const handleDelete = async (row: QueueSequenceVO) => {
|
if (!row.id) return
|
try {
|
await ElMessageBox.confirm(
|
`确认删除时段【${formatTimeSlot(row.timeSlot)}】的预约规则吗?` +
|
`删除后 HIS 若仍下发该时段,签到将自动顺延到后续可用时段。`,
|
'提示',
|
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
|
)
|
} catch {
|
return // 用户取消
|
}
|
await QueueSequenceApi.deleteQueueSequence(row.id)
|
message.success('删除成功')
|
await getList()
|
}
|
</script>
|
|
<style scoped lang="scss">
|
/*
|
【本次调整】字号恢复为系统默认,**允许横向滚动**:
|
· 不再压缩字号与内边距 —— 表体恢复默认字号(14px)、输入框默认字号、状态标签用 small 默认样式;
|
· 列宽由模板固定(时段 96 + 正常预约 5 列 + 预留 5 列 + 操作 160);
|
总宽超过弹窗宽度时表格出现**横向滚动条**,操作列 fixed="right" 始终可见;
|
· 表格高度 calc(100vh - 300px):竖向多显示几行,页面本身不出现滚动条。
|
*/
|
.qs-table {
|
/* 行内数字输入占满单元格(不缩小字号,只控制宽度) */
|
:deep(.el-input-number) {
|
width: 100%;
|
}
|
}
|
|
/* 预定额度(只读):值为 0 时浅红底,表示该时段没有名额 */
|
/* 【本需求】号段起点(系统自动计算,只读展示) */
|
.qs-fixed {
|
display: inline-block;
|
min-width: 34px;
|
padding: 1px 4px;
|
border-radius: 3px;
|
color: var(--el-text-color-secondary);
|
background-color: var(--el-fill-color-light);
|
font-variant-numeric: tabular-nums;
|
}
|
.qs-quota {
|
display: inline-block;
|
padding: 1px 6px;
|
border-radius: 3px;
|
font-weight: 600;
|
font-variant-numeric: tabular-nums;
|
color: var(--el-text-color-primary);
|
}
|
|
.qs-quota--zero {
|
color: var(--el-color-danger);
|
background-color: var(--el-color-danger-light-9);
|
box-shadow: inset 0 0 0 1px var(--el-color-danger-light-5);
|
}
|
</style>
|