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:
1 parent
8d9f0b259e
commit
b9ff9c9d67
9 files changed
+319
-70
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user