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

+1
View File
@@ -88,6 +88,7 @@ def ensure_duty_dict(
dict_type=body.dictType, dict_type=body.dictType,
label=body.label, label=body.label,
user_id=user_id, user_id=user_id,
preferred_value=body.preferredValue,
) )
db.commit() db.commit()
return success_response(data, message="已保存") return success_response(data, message="已保存")
+4
View File
@@ -15,10 +15,14 @@ SHIFT_LABELS = {
} }
DICT_SHIFT_TYPE = "duty_shift_type" DICT_SHIFT_TYPE = "duty_shift_type"
DICT_WORK_PLAN = "duty_work_plan" DICT_WORK_PLAN = "duty_work_plan"
DICT_TIME_RANGE = "duty_time_range"
DEFAULT_SHIFT_TIMES = { DEFAULT_SHIFT_TIMES = {
SHIFT_MORNING: ("08:00", "18:00", 0), SHIFT_MORNING: ("08:00", "18:00", 0),
SHIFT_EVENING: ("18:00", "07:00", 1), SHIFT_EVENING: ("18:00", "07:00", 1),
} }
# 预置时段 dict_value:start|end|cross_day
TIME_RANGE_DAY = "08:00|18:00|0"
TIME_RANGE_NIGHT = "18:00|07:00|1"
LINK_OPEN = "open" LINK_OPEN = "open"
LINK_CLOSED = "closed" LINK_CLOSED = "closed"
SOURCE_ASSIGN = "assign" SOURCE_ASSIGN = "assign"
+2 -1
View File
@@ -52,5 +52,6 @@ class DutyGrabBody(BaseModel):
class DutyDictEnsureBody(BaseModel): class DutyDictEnsureBody(BaseModel):
dictType: Literal["duty_shift_type", "duty_work_plan"] dictType: Literal["duty_shift_type", "duty_work_plan", "duty_time_range"]
label: str = Field(min_length=1, max_length=100) label: str = Field(min_length=1, max_length=100)
preferredValue: Optional[str] = Field(default=None, max_length=64)
+63 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import calendar import calendar
import re
import secrets import secrets
from datetime import date, datetime, time from datetime import date, datetime, time
from typing import Any, Dict, List, Optional, Sequence from typing import Any, Dict, List, Optional, Sequence
@@ -16,6 +17,7 @@ from app.models.duty_models import (
DUTY_STAFF_ROLE_ID, DUTY_STAFF_ROLE_ID,
DUTY_STAFF_ROLE_NAME, DUTY_STAFF_ROLE_NAME,
DICT_SHIFT_TYPE, DICT_SHIFT_TYPE,
DICT_TIME_RANGE,
DICT_WORK_PLAN, DICT_WORK_PLAN,
LINK_CLOSED, LINK_CLOSED,
LINK_OPEN, LINK_OPEN,
@@ -94,6 +96,44 @@ def build_dict_value_from_label(label: str) -> str:
return base return base
def _pad_hhmm(h: int, m: int) -> str:
return f"{h:02d}:{m:02d}"
def parse_time_range_input(raw: str) -> Dict[str, Any]:
"""解析「09:00-17:00」「18:00-次日07:00」等,返回 start/end/crossDay/label/value。"""
text = (raw or "").strip().replace("—", "-").replace("–", "-").replace("~", "-")
text = re.sub(r"\s+", "", text)
if not text:
raise DutyBizError("时段不能为空")
m = re.match(
r"^(\d{1,2}):(\d{2})[-至到](次日)?(\d{1,2}):(\d{2})$",
text,
)
if not m:
raise DutyBizError("时段格式须为 09:00-17:00 或 18:00-次日07:00")
sh, sm, cross_flag, eh, em = m.groups()
try:
start_h, start_m = int(sh), int(sm)
end_h, end_m = int(eh), int(em)
except ValueError as exc:
raise DutyBizError("时段格式须为 09:00-17:00 或 18:00-次日07:00") from exc
if not (0 <= start_h <= 23 and 0 <= start_m <= 59 and 0 <= end_h <= 23 and 0 <= end_m <= 59):
raise DutyBizError("时段格式须为 09:00-17:00 或 18:00-次日07:00")
start = _pad_hhmm(start_h, start_m)
end = _pad_hhmm(end_h, end_m)
cross = 1 if cross_flag or end < start else 0
label = f"{start}–次日{end}" if cross else f"{start}–{end}"
value = f"{start}|{end}|{cross}"
return {
"startTime": start,
"endTime": end,
"crossDay": bool(cross),
"label": label,
"value": value,
}
def ensure_dict_data( def ensure_dict_data(
db: Session, db: Session,
*, *,
@@ -106,9 +146,15 @@ def ensure_dict_data(
label = (label or "").strip() label = (label or "").strip()
if not label: if not label:
raise DutyBizError("字典标签不能为空") raise DutyBizError("字典标签不能为空")
if dict_type not in (DICT_SHIFT_TYPE, DICT_WORK_PLAN): if dict_type not in (DICT_SHIFT_TYPE, DICT_WORK_PLAN, DICT_TIME_RANGE):
raise DutyBizError("不支持的字典类型") raise DutyBizError("不支持的字典类型")
preferred = (preferred_value or "").strip() or None
if dict_type == DICT_TIME_RANGE:
parsed = parse_time_range_input(label)
label = parsed["label"]
preferred = preferred or parsed["value"]
existing_label = ( existing_label = (
db.query(SysDictData) db.query(SysDictData)
.filter( .filter(
@@ -122,7 +168,21 @@ def ensure_dict_data(
if existing_label: if existing_label:
return {"label": existing_label.dict_label, "value": existing_label.dict_value} return {"label": existing_label.dict_label, "value": existing_label.dict_value}
value = (preferred_value or "").strip() or build_dict_value_from_label(label) if preferred:
by_value = (
db.query(SysDictData)
.filter(
SysDictData.tenant_no == tenant_no,
SysDictData.dict_type == dict_type,
SysDictData.dict_value == preferred,
SysDictData.del_flag == 0,
)
.first()
)
if by_value:
return {"label": by_value.dict_label, "value": by_value.dict_value}
value = preferred or build_dict_value_from_label(label)
for _ in range(8): for _ in range(8):
clash = ( clash = (
db.query(SysDictData) db.query(SysDictData)
@@ -850,6 +910,7 @@ __all__ = [
"SHIFT_LABELS", "SHIFT_LABELS",
"DutyBizError", "DutyBizError",
"parse_hhmm", "parse_hhmm",
"parse_time_range_input",
"format_hhmm", "format_hhmm",
"format_time_label", "format_time_label",
"split_work_plans", "split_work_plans",
@@ -0,0 +1,74 @@
-- backend/scripts/migrate_20260820_duty_time_range_dict.sql
-- 值班排班:值班时段字典(可单选 + allow-create)
-- 可重复执行
SET NAMES utf8mb4;
INSERT INTO `sys_dict_type` (
`id`, `dict_name`, `dict_type`, `tenant_no`, `order_num`, `remark`,
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
)
SELECT
'DUTYDICTTYPE00000000000000000003',
'值班时段',
'duty_time_range',
'shanxia2.0',
92,
'值班排班时段选项',
'',
'',
NOW(),
'',
NOW(),
0
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `sys_dict_type`
WHERE `dict_type` = 'duty_time_range'
AND `tenant_no` = 'shanxia2.0'
AND `del_flag` = 0
);
INSERT INTO `sys_dict_data` (
`id`, `dict_label`, `dict_value`, `tag_type`, `dict_type`, `tenant_no`, `order_num`, `remark`,
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
)
SELECT * FROM (
SELECT
'DUTYDICTDATA0000000000000000006' AS id,
'08:00–18:00' AS dict_label,
'08:00|18:00|0' AS dict_value,
'' AS tag_type,
'duty_time_range' AS dict_type,
'shanxia2.0' AS tenant_no,
1 AS order_num,
NULL AS remark,
'' AS create_by,
'' AS create_dept_id,
NOW() AS create_time,
'' AS update_by,
NOW() AS update_time,
0 AS del_flag
UNION ALL SELECT
'DUTYDICTDATA0000000000000000007',
'18:00–次日07:00',
'18:00|07:00|1',
'',
'duty_time_range',
'shanxia2.0',
2,
NULL,
'',
'',
NOW(),
'',
NOW(),
0
) AS t
WHERE NOT EXISTS (
SELECT 1 FROM `sys_dict_data` d
WHERE d.tenant_no = t.tenant_no
AND d.dict_type = t.dict_type
AND d.dict_value = t.dict_value
AND d.del_flag = 0
);
+22
View File
@@ -47,6 +47,28 @@ def test_parse_hhmm_invalid_raises():
parse_hhmm("08:xx") parse_hhmm("08:xx")
def test_parse_time_range_input_day():
from app.services.duty_schedule import parse_time_range_input
r = parse_time_range_input("09:00-17:00")
assert r["startTime"] == "09:00"
assert r["endTime"] == "17:00"
assert r["crossDay"] is False
assert r["value"] == "09:00|17:00|0"
assert r["label"] == "09:00–17:00"
def test_parse_time_range_input_cross_day():
from app.services.duty_schedule import parse_time_range_input
r = parse_time_range_input("18:00-次日07:00")
assert r["startTime"] == "18:00"
assert r["endTime"] == "07:00"
assert r["crossDay"] is True
assert r["value"] == "18:00|07:00|1"
assert "次日" in r["label"]
def test_format_time_label_same_day(): def test_format_time_label_same_day():
assert format_time_label(parse_hhmm("08:00"), parse_hhmm("18:00"), False) == "08:00–18:00" assert format_time_label(parse_hhmm("08:00"), parse_hhmm("18:00"), False) == "08:00–18:00"
+2 -1
View File
@@ -91,8 +91,9 @@ export function fetchDutyUserOptions() {
} }
export function ensureDutyDict(data: { export function ensureDutyDict(data: {
dictType: 'duty_shift_type' | 'duty_work_plan' dictType: 'duty_shift_type' | 'duty_work_plan' | 'duty_time_range'
label: string label: string
preferredValue?: string
}) { }) {
return request.post<{ label: string; value: string }>({ return request.post<{ label: string; value: string }>({
url: '/web/admin/duty/dict/ensure', url: '/web/admin/duty/dict/ensure',
+146 -61
View File
@@ -197,7 +197,7 @@
v-model="dialogVisible" v-model="dialogVisible"
class="dialog-form-layout duty-day-dialog" class="dialog-form-layout duty-day-dialog"
:title="`配置 · ${editingDate}`" :title="`配置 · ${editingDate}`"
width="880px" width="960px"
align-center align-center
destroy-on-close destroy-on-close
:close-on-click-modal="false" :close-on-click-modal="false"
@@ -221,63 +221,29 @@
</div> </div>
</div> </div>
<template v-if="slot.enabled"> <template v-if="slot.enabled">
<div class="form-grid-2 slot-fields-row"> <div class="slot-fields-row">
<el-form-item label="开始时间"> <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">
<el-select <el-select
:model-value="slot.workPlans" :model-value="slot.timeRangeValue"
multiple
filterable filterable
allow-create allow-create
default-first-option default-first-option
clearable clearable
placeholder="选择或输入工作安排" placeholder="选择或输入,如 09:00-17:00 / 18:00-次日07:00"
style="width: 100%" style="width: 100%"
@change="(v) => onWorkPlansChange(slot, v as string[])" @change="(v) => onTimeRangeChange(slot, v as string)"
> >
<el-option <el-option
v-for="o in workPlanOptions" v-for="o in timeRangeOptions"
:key="o.value" :key="o.value"
:label="o.label" :label="o.label"
:value="o.value" :value="o.value"
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="备注" class="form-col-span-2"> </div>
<el-input <div class="form-grid-3 slot-fields-row">
v-model="slot.remark" <el-form-item label="值班安排">
type="textarea"
:rows="2"
maxlength="500"
show-word-limit
placeholder="可选,填写备注(与工作安排分离)"
/>
</el-form-item>
<el-form-item label="值班安排" class="form-col-span-2">
<el-select <el-select
v-model="slot.pendingUserId" v-model="slot.pendingUserId"
filterable filterable
@@ -294,6 +260,34 @@
/> />
</el-select> </el-select>
</el-form-item> </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>
<div class="person-tags"> <div class="person-tags">
<el-tag <el-tag
@@ -421,14 +415,19 @@
const userOptions = ref<Array<{ label: string; value: string }>>([]) const userOptions = ref<Array<{ label: string; value: string }>>([])
const shiftTypeOptions = ref<Array<{ label: string; value: string }>>([]) const shiftTypeOptions = ref<Array<{ label: string; value: string }>>([])
const workPlanOptions = 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 addShiftDraft = ref('')
const TIME_RANGE_DAY = '08:00|18:00|0'
const TIME_RANGE_NIGHT = '18:00|07:00|1'
type FormSlot = { type FormSlot = {
shiftType: string shiftType: string
enabled: boolean enabled: boolean
capacity: number capacity: number
workPlans: string[] workPlans: string[]
remark: string remark: string
timeRangeValue: string | null
startTime: string | null startTime: string | null
endTime: string | null endTime: string | null
crossDay: boolean crossDay: boolean
@@ -436,12 +435,60 @@
assignments: DutySlotItem['assignments'] assignments: DutySlotItem['assignments']
} }
function defaultTimesFor(shiftType: string) { function encodeTimeRange(
if (shiftType === 'morning') return { startTime: '08:00', endTime: '18:00', crossDay: false } start: string | null | undefined,
if (shiftType === 'evening') return { startTime: '18:00', endTime: '07:00', crossDay: true } 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 } 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[] { function blankFormSlots(): FormSlot[] {
return PRESET_TYPES.map((t) => ({ return PRESET_TYPES.map((t) => ({
shiftType: t, shiftType: t,
@@ -714,9 +761,10 @@
async function loadDictOptions() { async function loadDictOptions() {
try { try {
const [shifts, plans] = await Promise.all([ const [shifts, plans, times] = await Promise.all([
fetchDictDataOptions('duty_shift_type'), fetchDictDataOptions('duty_shift_type'),
fetchDictDataOptions('duty_work_plan') fetchDictDataOptions('duty_work_plan'),
fetchDictDataOptions('duty_time_range')
]) ])
shiftTypeOptions.value = (shifts || []).map((x) => ({ shiftTypeOptions.value = (shifts || []).map((x) => ({
label: x.label, label: x.label,
@@ -726,9 +774,14 @@
label: x.label, label: x.label,
value: x.value value: x.value
})) }))
timeRangeOptions.value = (times || []).map((x) => ({
label: x.label,
value: x.value
}))
} catch { } catch {
shiftTypeOptions.value = [] shiftTypeOptions.value = []
workPlanOptions.value = [] workPlanOptions.value = []
timeRangeOptions.value = []
} }
} }
@@ -776,6 +829,16 @@
fs.startTime = found.startTime || null fs.startTime = found.startTime || null
fs.endTime = found.endTime || null fs.endTime = found.endTime || null
fs.crossDay = !!found.crossDay 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.pendingUserId = ''
fs.assignments = found ? [...(found.assignments || [])] : [] fs.assignments = found ? [...(found.assignments || [])] : []
} }
@@ -809,28 +872,47 @@
addShiftDraft.value = '' 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( async function resolveDictValue(
dictType: 'duty_shift_type' | 'duty_work_plan', dictType: 'duty_shift_type' | 'duty_work_plan' | 'duty_time_range',
raw: string, 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) const existing = options.find((o) => o.value === raw || o.label === raw)
if (existing) return existing.value 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)) { if (!options.some((o) => o.value === res.value)) {
options.push({ label: res.label || raw, value: res.value }) options.push({ label: res.label || raw, value: res.value })
} }
return 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[]) { async function onWorkPlansChange(slot: FormSlot, values: string[]) {
const next: string[] = [] const next: string[] = []
for (const raw of values || []) { for (const raw of values || []) {
@@ -1442,11 +1524,14 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
.slot-fields-row { .slot-fields-row {
margin-bottom: 4px; margin-bottom: 10px;
:deep(.el-form-item) { :deep(.el-form-item) {
margin-bottom: 0; margin-bottom: 0;
} }
} }
.slot-fields-row + .slot-fields-row {
margin-top: 0;
}
.cap-field { .cap-field {
display: flex; display: flex;
gap: 8px; gap: 8px;
+5 -5
View File
@@ -76,7 +76,9 @@
<p v-if="slot.workPlanLabels?.length" class="slot-remark"> <p v-if="slot.workPlanLabels?.length" class="slot-remark">
工作安排:<strong>{{ slot.workPlanLabels.join('、') }}</strong> 工作安排:<strong>{{ slot.workPlanLabels.join('、') }}</strong>
</p> </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"> <div class="bar">
<i :style="{ width: barWidth(slot) }" :class="dotClass(slot.shiftType)"></i> <i :style="{ width: barWidth(slot) }" :class="dotClass(slot.shiftType)"></i>
</div> </div>
@@ -421,13 +423,11 @@
word-break: break-word; word-break: break-word;
} }
.slot-time strong, .slot-time strong,
.slot-remark strong { .slot-remark strong,
.slot-note strong {
font-weight: 600; font-weight: 600;
color: #1f2a26; color: #1f2a26;
} }
.slot-note {
color: #8a9a93;
}
.bar { .bar {
height: 7px; height: 7px;
margin-top: 8px; margin-top: 8px;