소스 검색

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 달 전
부모
커밋
b9ff9c9d67

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

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

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

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

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

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

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

@@ -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">
-              <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
                 <el-select
-                  :model-value="slot.workPlans"
-                  multiple
+                  :model-value="slot.timeRangeValue"
                   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">
-                <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
                 <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) {
-    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 }
     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
web-admin/src/views/public/duty/index.vue

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