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
@@ -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="已保存")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user