feat(duty): make duty time range a single-select dict like work plans

Add duty_time_range dictionary with day/night presets, parse typed ranges on ensure, and replace time pickers with allow-create select in the schedule dialog.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-20 21:58:18 +08:00
1 parent 8d9f0b259e
commit b9ff9c9d67
9 files changed
+319 -70

No files matched your search

+2 -1
View File
@@ -91,8 +91,9 @@ export function fetchDutyUserOptions() {
}
export function ensureDutyDict(data: {
dictType: 'duty_shift_type' | 'duty_work_plan'
dictType: 'duty_shift_type' | 'duty_work_plan' | 'duty_time_range'
label: string
preferredValue?: string
}) {
return request.post<{ label: string; value: string }>({
url: '/web/admin/duty/dict/ensure',
+146 -61
View File
@@ -197,7 +197,7 @@
v-model="dialogVisible"
class="dialog-form-layout duty-day-dialog"
:title="`配置 · ${editingDate}`"
width="880px"
width="960px"
align-center
destroy-on-close
:close-on-click-modal="false"
@@ -221,63 +221,29 @@
</div>
</div>
<template v-if="slot.enabled">
<div class="form-grid-2 slot-fields-row">
<el-form-item label="开始时间">
<el-time-picker
v-model="slot.startTime"
value-format="HH:mm"
format="HH:mm"
placeholder="可选"
style="width: 100%"
clearable
@change="() => syncCrossDay(slot)"
/>
</el-form-item>
<el-form-item label="结束时间">
<el-time-picker
v-model="slot.endTime"
value-format="HH:mm"
format="HH:mm"
placeholder="可选"
style="width: 100%"
clearable
@change="() => syncCrossDay(slot)"
/>
</el-form-item>
<el-form-item label="跨日">
<el-checkbox v-model="slot.crossDay">结束时间跨日</el-checkbox>
</el-form-item>
<el-form-item label="工作安排" class="form-col-span-2">
<div class="slot-fields-row">
<el-form-item label="值班时段">
<el-select
:model-value="slot.workPlans"
multiple
:model-value="slot.timeRangeValue"
filterable
allow-create
default-first-option
clearable
placeholder="选择或输入工作安排"
placeholder="选择或输入,如 09:00-17:00 / 18:00-次日07:00"
style="width: 100%"
@change="(v) => onWorkPlansChange(slot, v as string[])"
@change="(v) => onTimeRangeChange(slot, v as string)"
>
<el-option
v-for="o in workPlanOptions"
v-for="o in timeRangeOptions"
:key="o.value"
:label="o.label"
:value="o.value"
/>
</el-select>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input
v-model="slot.remark"
type="textarea"
:rows="2"
maxlength="500"
show-word-limit
placeholder="可选,填写备注(与工作安排分离)"
/>
</el-form-item>
<el-form-item label="值班安排" class="form-col-span-2">
</div>
<div class="form-grid-3 slot-fields-row">
<el-form-item label="值班安排">
<el-select
v-model="slot.pendingUserId"
filterable
@@ -294,6 +260,34 @@
/>
</el-select>
</el-form-item>
<el-form-item label="工作安排">
<el-select
:model-value="slot.workPlans"
multiple
filterable
allow-create
default-first-option
clearable
placeholder="选择或输入"
style="width: 100%"
@change="(v) => onWorkPlansChange(slot, v as string[])"
>
<el-option
v-for="o in workPlanOptions"
:key="o.value"
:label="o.label"
:value="o.value"
/>
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="slot.remark"
maxlength="500"
clearable
placeholder="可选备注"
/>
</el-form-item>
</div>
<div class="person-tags">
<el-tag
@@ -421,14 +415,19 @@
const userOptions = ref<Array<{ label: string; value: string }>>([])
const shiftTypeOptions = ref<Array<{ label: string; value: string }>>([])
const workPlanOptions = ref<Array<{ label: string; value: string }>>([])
const timeRangeOptions = ref<Array<{ label: string; value: string }>>([])
const addShiftDraft = ref('')
const TIME_RANGE_DAY = '08:00|18:00|0'
const TIME_RANGE_NIGHT = '18:00|07:00|1'
type FormSlot = {
shiftType: string
enabled: boolean
capacity: number
workPlans: string[]
remark: string
timeRangeValue: string | null
startTime: string | null
endTime: string | null
crossDay: boolean
@@ -436,12 +435,60 @@
assignments: DutySlotItem['assignments']
}
function defaultTimesFor(shiftType: string) {
if (shiftType === 'morning') return { startTime: '08:00', endTime: '18:00', crossDay: false }
if (shiftType === 'evening') return { startTime: '18:00', endTime: '07:00', crossDay: true }
function encodeTimeRange(
start: string | null | undefined,
end: string | null | undefined,
crossDay: boolean
) {
if (!start || !end) return null
return `${start}|${end}|${crossDay ? 1 : 0}`
}
function decodeTimeRange(value: string | null | undefined) {
const raw = String(value || '').trim()
if (!raw) {
return { startTime: null as string | null, endTime: null as string | null, crossDay: false }
}
const parts = raw.split('|')
if (parts.length >= 3) {
return {
startTime: parts[0] || null,
endTime: parts[1] || null,
crossDay: parts[2] === '1'
}
}
return { startTime: null as string | null, endTime: null as string | null, crossDay: false }
}
function formatTimeRangeLabel(start: string, end: string, crossDay: boolean) {
return crossDay ? `${start}–次日${end}` : `${start}–${end}`
}
function defaultTimesFor(shiftType: string) {
if (shiftType === 'morning') {
return {
timeRangeValue: TIME_RANGE_DAY,
startTime: '08:00',
endTime: '18:00',
crossDay: false
}
}
if (shiftType === 'evening') {
return {
timeRangeValue: TIME_RANGE_NIGHT,
startTime: '18:00',
endTime: '07:00',
crossDay: true
}
}
return {
timeRangeValue: null as string | null,
startTime: null as string | null,
endTime: null as string | null,
crossDay: false
}
}
function blankFormSlots(): FormSlot[] {
return PRESET_TYPES.map((t) => ({
shiftType: t,
@@ -714,9 +761,10 @@
async function loadDictOptions() {
try {
const [shifts, plans] = await Promise.all([
const [shifts, plans, times] = await Promise.all([
fetchDictDataOptions('duty_shift_type'),
fetchDictDataOptions('duty_work_plan')
fetchDictDataOptions('duty_work_plan'),
fetchDictDataOptions('duty_time_range')
])
shiftTypeOptions.value = (shifts || []).map((x) => ({
label: x.label,
@@ -726,9 +774,14 @@
label: x.label,
value: x.value
}))
timeRangeOptions.value = (times || []).map((x) => ({
label: x.label,
value: x.value
}))
} catch {
shiftTypeOptions.value = []
workPlanOptions.value = []
timeRangeOptions.value = []
}
}
@@ -776,6 +829,16 @@
fs.startTime = found.startTime || null
fs.endTime = found.endTime || null
fs.crossDay = !!found.crossDay
const encoded = encodeTimeRange(fs.startTime, fs.endTime, fs.crossDay)
fs.timeRangeValue = encoded
if (encoded && !timeRangeOptions.value.some((o) => o.value === encoded)) {
timeRangeOptions.value.push({
label:
found.timeLabel ||
formatTimeRangeLabel(fs.startTime || '', fs.endTime || '', fs.crossDay),
value: encoded
})
}
fs.pendingUserId = ''
fs.assignments = found ? [...(found.assignments || [])] : []
}
@@ -809,28 +872,47 @@
addShiftDraft.value = ''
}
function syncCrossDay(slot: FormSlot) {
const start = slot.startTime || ''
const end = slot.endTime || ''
if (start && end && end < start) {
slot.crossDay = true
}
}
async function resolveDictValue(
dictType: 'duty_shift_type' | 'duty_work_plan',
dictType: 'duty_shift_type' | 'duty_work_plan' | 'duty_time_range',
raw: string,
options: Array<{ label: string; value: string }>
options: Array<{ label: string; value: string }>,
preferredValue?: string
) {
const existing = options.find((o) => o.value === raw || o.label === raw)
if (existing) return existing.value
const res = await ensureDutyDict({ dictType, label: raw })
const res = await ensureDutyDict({
dictType,
label: raw,
preferredValue
})
if (!options.some((o) => o.value === res.value)) {
options.push({ label: res.label || raw, value: res.value })
}
return res.value
}
async function onTimeRangeChange(slot: FormSlot, raw: string) {
const text = String(raw || '').trim()
if (!text) {
slot.timeRangeValue = null
slot.startTime = null
slot.endTime = null
slot.crossDay = false
return
}
try {
const value = await resolveDictValue('duty_time_range', text, timeRangeOptions.value)
slot.timeRangeValue = value
const decoded = decodeTimeRange(value)
slot.startTime = decoded.startTime
slot.endTime = decoded.endTime
slot.crossDay = decoded.crossDay
} catch (e: any) {
ElMessage.error(e?.message || '时段无效,请输入如 09:00-17:00 或 18:00-次日07:00')
slot.timeRangeValue = null
}
}
async function onWorkPlansChange(slot: FormSlot, values: string[]) {
const next: string[] = []
for (const raw of values || []) {
@@ -1442,11 +1524,14 @@
margin-bottom: 8px;
}
.slot-fields-row {
margin-bottom: 4px;
margin-bottom: 10px;
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.slot-fields-row + .slot-fields-row {
margin-top: 0;
}
.cap-field {
display: flex;
gap: 8px;
+5 -5
View File
@@ -76,7 +76,9 @@
<p v-if="slot.workPlanLabels?.length" class="slot-remark">
工作安排:<strong>{{ slot.workPlanLabels.join('、') }}</strong>
</p>
<p v-if="slot.remark" class="slot-note">备注:{{ slot.remark }}</p>
<p v-if="slot.remark" class="slot-note">
备注:<strong>{{ slot.remark }}</strong>
</p>
<div class="bar">
<i :style="{ width: barWidth(slot) }" :class="dotClass(slot.shiftType)"></i>
</div>
@@ -421,13 +423,11 @@
word-break: break-word;
}
.slot-time strong,
.slot-remark strong {
.slot-remark strong,
.slot-note strong {
font-weight: 600;
color: #1f2a26;
}
.slot-note {
color: #8a9a93;
}
.bar {
height: 7px;
margin-top: 8px;