|
|
@@ -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;
|