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,
label=body.label,
user_id=user_id,
preferred_value=body.preferredValue,
)
db.commit()
return success_response(data, message="已保存")
+4
View File
@@ -15,10 +15,14 @@ SHIFT_LABELS = {
}
DICT_SHIFT_TYPE = "duty_shift_type"
DICT_WORK_PLAN = "duty_work_plan"
DICT_TIME_RANGE = "duty_time_range"
DEFAULT_SHIFT_TIMES = {
SHIFT_MORNING: ("08:00", "18:00", 0),
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_CLOSED = "closed"
SOURCE_ASSIGN = "assign"
+2 -1
View File
@@ -52,5 +52,6 @@ class DutyGrabBody(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)
preferredValue: Optional[str] = Field(default=None, max_length=64)
+63 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import calendar
import re
import secrets
from datetime import date, datetime, time
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_NAME,
DICT_SHIFT_TYPE,
DICT_TIME_RANGE,
DICT_WORK_PLAN,
LINK_CLOSED,
LINK_OPEN,
@@ -94,6 +96,44 @@ def build_dict_value_from_label(label: str) -> str:
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(
db: Session,
*,
@@ -106,9 +146,15 @@ def ensure_dict_data(
label = (label or "").strip()
if not label:
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("不支持的字典类型")
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 = (
db.query(SysDictData)
.filter(
@@ -122,7 +168,21 @@ def ensure_dict_data(
if existing_label:
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):
clash = (
db.query(SysDictData)
@@ -850,6 +910,7 @@ __all__ = [
"SHIFT_LABELS",
"DutyBizError",
"parse_hhmm",
"parse_time_range_input",
"format_hhmm",
"format_time_label",
"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")
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():
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: {
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;