Ver código fonte

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>
zlwu 2 meses atrás
pai
commit
b9ff9c9d67

+ 1 - 0
backend/app/api/duty.py

@@ -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 - 0
backend/app/models/duty_models.py

@@ -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
backend/app/schemas/duty.py

@@ -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
backend/app/services/duty_schedule.py

@@ -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",

+ 74 - 0
backend/scripts/migrate_20260820_duty_time_range_dict.sql

@@ -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 - 0
backend/tests/test_duty_schedule.py

@@ -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
web-admin/src/api/duty.ts

@@ -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
web-admin/src/views/duty/schedule/index.vue

@@ -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
web-admin/src/views/public/duty/index.vue

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