Browse Source

feat(duty): admin schedule UI for dict types, work plans, and time

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 tháng trước cách đây
mục cha
commit
8df0583ad1

+ 364 - 88
web-admin/src/views/duty/schedule/index.vue

@@ -20,6 +20,7 @@
           <span><i class="dot am" />早班</span>
           <span><i class="dot am" />早班</span>
           <span><i class="dot pm" />晚班</span>
           <span><i class="dot pm" />晚班</span>
           <span><i class="dot duty" />值班</span>
           <span><i class="dot duty" />值班</span>
+          <span><i class="dot other" />其他</span>
           <span class="muted">{{
           <span class="muted">{{
             viewMode === 'stats' ? '点击本月合计可返回日历' : '点击日期配置 / 复制链接'
             viewMode === 'stats' ? '点击本月合计可返回日历' : '点击日期配置 / 复制链接'
           }}</span>
           }}</span>
@@ -52,6 +53,15 @@
           >
           >
             <i class="dot duty" />值班 <em>{{ monthShiftStats.duty }}</em>
             <i class="dot duty" />值班 <em>{{ monthShiftStats.duty }}</em>
           </button>
           </button>
+          <button
+            v-if="monthShiftStats.other > 0"
+            type="button"
+            class="stat"
+            :class="{ active: viewMode === 'calendar' && filterShiftType === 'other' }"
+            @click="toggleShiftFilter('other')"
+          >
+            <i class="dot other" />其他 <em>{{ monthShiftStats.other }}</em>
+          </button>
           <button
           <button
             type="button"
             type="button"
             class="stat total"
             class="stat total"
@@ -137,7 +147,7 @@
                 <span>总次数</span>
                 <span>总次数</span>
               </div>
               </div>
             </header>
             </header>
-            <div class="stats-breakdown">
+            <div class="stats-breakdown" :class="{ 'has-other': card.other > 0 }">
               <div class="cell am">
               <div class="cell am">
                 <span><i class="dot am" />早班</span>
                 <span><i class="dot am" />早班</span>
                 <strong>{{ card.morning }}</strong>
                 <strong>{{ card.morning }}</strong>
@@ -150,6 +160,10 @@
                 <span><i class="dot duty" />值班</span>
                 <span><i class="dot duty" />值班</span>
                 <strong>{{ card.duty }}</strong>
                 <strong>{{ card.duty }}</strong>
               </div>
               </div>
+              <div v-if="card.other > 0" class="cell other">
+                <span><i class="dot other" />其他</span>
+                <strong>{{ card.other }}</strong>
+              </div>
             </div>
             </div>
             <div class="stats-bar">
             <div class="stats-bar">
               <i
               <i
@@ -167,10 +181,13 @@
                 class="seg duty"
                 class="seg duty"
                 :style="{ width: barPct(card.duty, card.total) }"
                 :style="{ width: barPct(card.duty, card.total) }"
               />
               />
+              <i
+                v-if="card.other"
+                class="seg other"
+                :style="{ width: barPct(card.other, card.total) }"
+              />
             </div>
             </div>
-            <footer class="stats-card-ft">
-              {{ year }}年{{ pad(month) }}月 排班统计
-            </footer>
+            <footer class="stats-card-ft"> {{ year }}年{{ pad(month) }}月 排班统计 </footer>
           </article>
           </article>
         </div>
         </div>
       </div>
       </div>
@@ -193,40 +210,90 @@
         <div v-for="slot in formSlots" :key="slot.shiftType" class="slot-card">
         <div v-for="slot in formSlots" :key="slot.shiftType" class="slot-card">
           <div class="slot-hd">
           <div class="slot-hd">
             <el-checkbox v-model="slot.enabled">{{ shiftLabel(slot.shiftType) }}</el-checkbox>
             <el-checkbox v-model="slot.enabled">{{ shiftLabel(slot.shiftType) }}</el-checkbox>
-            <div class="cap-field" v-if="slot.enabled">
+            <div v-if="slot.enabled" class="cap-field">
               <span>名额</span>
               <span>名额</span>
-              <el-input-number v-model="slot.capacity" :min="1" :max="99" controls-position="right" />
+              <el-input-number
+                v-model="slot.capacity"
+                :min="1"
+                :max="99"
+                controls-position="right"
+              />
             </div>
             </div>
           </div>
           </div>
           <template v-if="slot.enabled">
           <template v-if="slot.enabled">
-            <div class="form-grid-2 slot-assign-row">
-              <el-form-item label="值班安排">
+            <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">
                 <el-select
                 <el-select
-                  v-model="slot.pendingUserId"
+                  :model-value="slot.workPlans"
+                  multiple
                   filterable
                   filterable
+                  allow-create
+                  default-first-option
                   clearable
                   clearable
-                  placeholder="选择员工后添加"
+                  placeholder="选择或输入工作安排"
                   style="width: 100%"
                   style="width: 100%"
-                  @change="(v) => addAssign(slot, String(v || ''))"
+                  @change="(v) => onWorkPlansChange(slot, v as string[])"
                 >
                 >
                   <el-option
                   <el-option
-                    v-for="u in userOptions"
-                    :key="u.value"
-                    :label="u.label"
-                    :value="u.value"
+                    v-for="o in workPlanOptions"
+                    :key="o.value"
+                    :label="o.label"
+                    :value="o.value"
                   />
                   />
                 </el-select>
                 </el-select>
               </el-form-item>
               </el-form-item>
-              <el-form-item label="工作安排">
+              <el-form-item label="备注" class="form-col-span-2">
                 <el-input
                 <el-input
                   v-model="slot.remark"
                   v-model="slot.remark"
                   type="textarea"
                   type="textarea"
                   :rows="2"
                   :rows="2"
                   maxlength="500"
                   maxlength="500"
                   show-word-limit
                   show-word-limit
-                  placeholder="可选,填写本班次工作安排"
+                  placeholder="可选,填写备注(与工作安排分离)"
                 />
                 />
               </el-form-item>
               </el-form-item>
+              <el-form-item label="值班安排" class="form-col-span-2">
+                <el-select
+                  v-model="slot.pendingUserId"
+                  filterable
+                  clearable
+                  placeholder="选择员工后添加"
+                  style="width: 100%"
+                  @change="(v) => addAssign(slot, String(v || ''))"
+                >
+                  <el-option
+                    v-for="u in userOptions"
+                    :key="u.value"
+                    :label="u.label"
+                    :value="u.value"
+                  />
+                </el-select>
+              </el-form-item>
             </div>
             </div>
             <div class="person-tags">
             <div class="person-tags">
               <el-tag
               <el-tag
@@ -244,6 +311,29 @@
           </template>
           </template>
         </div>
         </div>
 
 
+        <div class="add-shift-row">
+          <el-select
+            v-model="addShiftDraft"
+            filterable
+            allow-create
+            default-first-option
+            clearable
+            placeholder="选择或输入班次类型"
+            style="flex: 1; min-width: 180px"
+          >
+            <el-option
+              v-for="o in availableShiftOptions"
+              :key="o.value"
+              :label="o.label"
+              :value="o.value"
+            />
+          </el-select>
+          <el-button class="btn-ghost" :loading="addingShift" @click="confirmAddShift">
+            <el-icon><Plus /></el-icon>
+            添加班次
+          </el-button>
+        </div>
+
         <div v-if="dayDetail?.publicUrl" class="link-box">
         <div v-if="dayDetail?.publicUrl" class="link-box">
           <span>公开链接</span>
           <span>公开链接</span>
           <code>{{ dayDetail.publicUrl }}</code>
           <code>{{ dayDetail.publicUrl }}</code>
@@ -251,12 +341,7 @@
             <el-icon><CopyDocument /></el-icon>
             <el-icon><CopyDocument /></el-icon>
             复制
             复制
           </el-button>
           </el-button>
-          <el-button
-            v-if="dayDetail.id"
-            class="btn-ghost"
-            size="small"
-            @click="toggleLink"
-          >
+          <el-button v-if="dayDetail.id" class="btn-ghost" size="small" @click="toggleLink">
             <el-icon><SwitchButton /></el-icon>
             <el-icon><SwitchButton /></el-icon>
             {{ dayDetail.linkStatus === 'closed' ? '打开链接' : '关闭链接' }}
             {{ dayDetail.linkStatus === 'closed' ? '打开链接' : '关闭链接' }}
           </el-button>
           </el-button>
@@ -278,7 +363,7 @@
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-  import { computed, onMounted, reactive, ref } from 'vue'
+  import { computed, onMounted, ref } from 'vue'
   import {
   import {
     ArrowLeft,
     ArrowLeft,
     ArrowRight,
     ArrowRight,
@@ -286,30 +371,35 @@
     Check,
     Check,
     Close,
     Close,
     CopyDocument,
     CopyDocument,
+    Plus,
     SwitchButton
     SwitchButton
   } from '@element-plus/icons-vue'
   } from '@element-plus/icons-vue'
   import { ElMessage, ElMessageBox } from 'element-plus'
   import { ElMessage, ElMessageBox } from 'element-plus'
+  import { fetchDictDataOptions } from '@/api/admin'
   import {
   import {
     deleteDutyAssignment,
     deleteDutyAssignment,
+    ensureDutyDict,
     fetchDutyDay,
     fetchDutyDay,
     fetchDutyMonth,
     fetchDutyMonth,
     fetchDutyUserOptions,
     fetchDutyUserOptions,
     saveDutyDay,
     saveDutyDay,
     setDutyLinkStatus,
     setDutyLinkStatus,
     type DutyDayItem,
     type DutyDayItem,
-    type DutySlotItem,
-    type ShiftType
+    type DutySlotItem
   } from '@/api/duty'
   } from '@/api/duty'
 
 
   defineOptions({ name: 'DutySchedule' })
   defineOptions({ name: 'DutySchedule' })
 
 
-  const SHIFT_TYPES: ShiftType[] = ['morning', 'evening', 'duty']
-  const SHIFT_LABELS: Record<ShiftType, string> = {
+  const PRESET_TYPES = ['morning', 'evening', 'duty'] as const
+  type PresetShift = (typeof PRESET_TYPES)[number]
+  type FilterShift = PresetShift | 'other' | ''
+
+  const SHIFT_LABELS: Record<string, string> = {
     morning: '早班',
     morning: '早班',
     evening: '晚班',
     evening: '晚班',
     duty: '值班'
     duty: '值班'
   }
   }
-  const SHIFT_CLS: Record<ShiftType, string> = {
+  const SHIFT_CLS: Record<string, string> = {
     morning: 'am',
     morning: 'am',
     evening: 'pm',
     evening: 'pm',
     duty: 'duty'
     duty: 'duty'
@@ -320,37 +410,63 @@
   const month = ref(now.getMonth() + 1)
   const month = ref(now.getMonth() + 1)
   const loading = ref(false)
   const loading = ref(false)
   const saving = ref(false)
   const saving = ref(false)
+  const addingShift = ref(false)
   const monthDays = ref<DutyDayItem[]>([])
   const monthDays = ref<DutyDayItem[]>([])
   const filterPersonKey = ref('')
   const filterPersonKey = ref('')
-  const filterShiftType = ref<ShiftType | ''>('')
+  const filterShiftType = ref<FilterShift>('')
   const viewMode = ref<'calendar' | 'stats'>('calendar')
   const viewMode = ref<'calendar' | 'stats'>('calendar')
   const dialogVisible = ref(false)
   const dialogVisible = ref(false)
   const editingDate = ref('')
   const editingDate = ref('')
   const dayDetail = ref<DutyDayItem | null>(null)
   const dayDetail = ref<DutyDayItem | null>(null)
   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 workPlanOptions = ref<Array<{ label: string; value: string }>>([])
+  const addShiftDraft = ref('')
 
 
   type FormSlot = {
   type FormSlot = {
-    shiftType: ShiftType
+    shiftType: string
     enabled: boolean
     enabled: boolean
     capacity: number
     capacity: number
+    workPlans: string[]
     remark: string
     remark: string
+    startTime: string | null
+    endTime: string | null
+    crossDay: boolean
     pendingUserId: string
     pendingUserId: string
     assignments: DutySlotItem['assignments']
     assignments: DutySlotItem['assignments']
   }
   }
 
 
-  const formSlots = reactive<FormSlot[]>(
-    SHIFT_TYPES.map((t) => ({
+  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 }
+    return { startTime: null as string | null, endTime: null as string | null, crossDay: false }
+  }
+
+  function blankFormSlots(): FormSlot[] {
+    return PRESET_TYPES.map((t) => ({
       shiftType: t,
       shiftType: t,
-      enabled: false,
+      enabled: t === 'morning' || t === 'evening',
       capacity: 1,
       capacity: 1,
+      workPlans: [],
       remark: '',
       remark: '',
+      ...defaultTimesFor(t),
       pendingUserId: '',
       pendingUserId: '',
       assignments: []
       assignments: []
     }))
     }))
-  )
+  }
+
+  const formSlots = ref<FormSlot[]>(blankFormSlots())
+
+  function isPresetShift(t: string): t is PresetShift {
+    return (PRESET_TYPES as readonly string[]).includes(t)
+  }
 
 
-  function shiftLabel(t: ShiftType) {
-    return SHIFT_LABELS[t]
+  function shiftChipCls(t: string) {
+    return SHIFT_CLS[t] || 'other'
+  }
+
+  function shiftLabel(t: string) {
+    return shiftTypeOptions.value.find((o) => o.value === t)?.label || SHIFT_LABELS[t] || t
   }
   }
 
 
   function pad(n: number) {
   function pad(n: number) {
@@ -361,7 +477,6 @@
     return `${y}-${pad(m)}-${pad(d)}`
     return `${y}-${pad(m)}-${pad(d)}`
   }
   }
 
 
-  /** 统计/过滤统一按展示姓名合并,避免同人早班有 userId、晚班无 userId 时拆成两条 */
   function personDisplayName(a: { userId?: string | null; personName?: string }) {
   function personDisplayName(a: { userId?: string | null; personName?: string }) {
     return (a.personName || '').trim()
     return (a.personName || '').trim()
   }
   }
@@ -374,12 +489,23 @@
     return personDisplayName(a) === nameKey
     return personDisplayName(a) === nameKey
   }
   }
 
 
+  function matchesShiftFilter(shiftType: string, filter: FilterShift) {
+    if (!filter) return true
+    if (filter === 'other') return !isPresetShift(shiftType)
+    return shiftType === filter
+  }
+
+  const availableShiftOptions = computed(() => {
+    const used = new Set(formSlots.value.map((s) => s.shiftType))
+    return shiftTypeOptions.value.filter((o) => !used.has(o.value))
+  })
+
   const personStats = computed(() => {
   const personStats = computed(() => {
     const map = new Map<string, { key: string; name: string; count: number }>()
     const map = new Map<string, { key: string; name: string; count: number }>()
     const shiftFilter = filterShiftType.value
     const shiftFilter = filterShiftType.value
     for (const day of monthDays.value) {
     for (const day of monthDays.value) {
       for (const slot of day.slots || []) {
       for (const slot of day.slots || []) {
-        if (shiftFilter && slot.shiftType !== shiftFilter) continue
+        if (!matchesShiftFilter(slot.shiftType, shiftFilter)) continue
         for (const a of slot.assignments || []) {
         for (const a of slot.assignments || []) {
           const name = personDisplayName(a)
           const name = personDisplayName(a)
           if (!name) continue
           if (!name) continue
@@ -395,26 +521,32 @@
     })
     })
   })
   })
 
 
-  /** 当月各类班次占位次数(与人员统计同口径) */
   const monthShiftStats = computed(() => {
   const monthShiftStats = computed(() => {
-    const counts = { morning: 0, evening: 0, duty: 0 }
+    const counts = { morning: 0, evening: 0, duty: 0, other: 0 }
     for (const day of monthDays.value) {
     for (const day of monthDays.value) {
       for (const slot of day.slots || []) {
       for (const slot of day.slots || []) {
         const n = (slot.assignments || []).length
         const n = (slot.assignments || []).length
         if (slot.shiftType === 'morning') counts.morning += n
         if (slot.shiftType === 'morning') counts.morning += n
         else if (slot.shiftType === 'evening') counts.evening += n
         else if (slot.shiftType === 'evening') counts.evening += n
         else if (slot.shiftType === 'duty') counts.duty += n
         else if (slot.shiftType === 'duty') counts.duty += n
+        else counts.other += n
       }
       }
     }
     }
     return {
     return {
       ...counts,
       ...counts,
-      total: counts.morning + counts.evening + counts.duty
+      total: counts.morning + counts.evening + counts.duty + counts.other
     }
     }
   })
   })
 
 
-  /** 统计视图:每人各班次次数卡片 */
   const personCards = computed(() => {
   const personCards = computed(() => {
-    type Acc = { key: string; name: string; morning: number; evening: number; duty: number }
+    type Acc = {
+      key: string
+      name: string
+      morning: number
+      evening: number
+      duty: number
+      other: number
+    }
     const map = new Map<string, Acc>()
     const map = new Map<string, Acc>()
     for (const day of monthDays.value) {
     for (const day of monthDays.value) {
       for (const slot of day.slots || []) {
       for (const slot of day.slots || []) {
@@ -423,19 +555,20 @@
           if (!name) continue
           if (!name) continue
           let row = map.get(name)
           let row = map.get(name)
           if (!row) {
           if (!row) {
-            row = { key: name, name, morning: 0, evening: 0, duty: 0 }
+            row = { key: name, name, morning: 0, evening: 0, duty: 0, other: 0 }
             map.set(name, row)
             map.set(name, row)
           }
           }
           if (slot.shiftType === 'morning') row.morning += 1
           if (slot.shiftType === 'morning') row.morning += 1
           else if (slot.shiftType === 'evening') row.evening += 1
           else if (slot.shiftType === 'evening') row.evening += 1
           else if (slot.shiftType === 'duty') row.duty += 1
           else if (slot.shiftType === 'duty') row.duty += 1
+          else row.other += 1
         }
         }
       }
       }
     }
     }
     return Array.from(map.values())
     return Array.from(map.values())
       .map((row) => ({
       .map((row) => ({
         ...row,
         ...row,
-        total: row.morning + row.evening + row.duty,
+        total: row.morning + row.evening + row.duty + row.other,
         initial: row.name.slice(0, 1)
         initial: row.name.slice(0, 1)
       }))
       }))
       .sort((a, b) => {
       .sort((a, b) => {
@@ -502,29 +635,28 @@
       const chips: Array<{ text: string; cls: string; title?: string }> = []
       const chips: Array<{ text: string; cls: string; title?: string }> = []
       if (summary?.slots?.length) {
       if (summary?.slots?.length) {
         for (const s of summary.slots) {
         for (const s of summary.slots) {
-          if (shiftFilter && s.shiftType !== shiftFilter) continue
-          const matched = (s.assignments || []).filter((a) =>
-            assignmentMatchesFilter(a, filterKey)
-          )
+          if (!matchesShiftFilter(s.shiftType, shiftFilter)) continue
+          const matched = (s.assignments || []).filter((a) => assignmentMatchesFilter(a, filterKey))
           if (filterKey && !matched.length) continue
           if (filterKey && !matched.length) continue
           const names = matched.map((a) => a.personName).filter(Boolean)
           const names = matched.map((a) => a.personName).filter(Boolean)
           const taken = s.taken ?? (s.assignments || []).length
           const taken = s.taken ?? (s.assignments || []).length
-          const remark = (s.remark || '').trim()
+          const label = s.shiftLabel || shiftLabel(s.shiftType)
+          const planText = (s.workPlanLabels || []).filter(Boolean).join('、')
           let text: string
           let text: string
           if (filterKey) {
           if (filterKey) {
-            text = `${s.shiftLabel || SHIFT_LABELS[s.shiftType]} ${names.join('、')}`
+            text = `${label} ${names.join('、')}`
           } else if (names.length) {
           } else if (names.length) {
-            text = `${s.shiftLabel || SHIFT_LABELS[s.shiftType]} ${names.slice(0, 2).join('、')}${names.length > 2 ? '…' : ''}`
+            text = `${label} ${names.slice(0, 2).join('、')}${names.length > 2 ? '…' : ''}`
           } else {
           } else {
-            text = `${s.shiftLabel || SHIFT_LABELS[s.shiftType]} ${taken}/${s.capacity}`
+            text = `${label} ${taken}/${s.capacity}`
           }
           }
-          if (remark) {
-            text = `${text} · ${remark}`
+          if (planText) {
+            text = `${text} · ${planText}`
           }
           }
           chips.push({
           chips.push({
             text,
             text,
-            cls: SHIFT_CLS[s.shiftType] || 'am',
-            title: remark || undefined
+            cls: shiftChipCls(s.shiftType),
+            title: planText || undefined
           })
           })
         }
         }
         if (!hasFilter && !chips.length) {
         if (!hasFilter && !chips.length) {
@@ -560,7 +692,7 @@
     filterPersonKey.value = filterPersonKey.value === key ? '' : key
     filterPersonKey.value = filterPersonKey.value === key ? '' : key
   }
   }
 
 
-  function toggleShiftFilter(shift: ShiftType) {
+  function toggleShiftFilter(shift: Exclude<FilterShift, ''>) {
     if (viewMode.value === 'stats') {
     if (viewMode.value === 'stats') {
       viewMode.value = 'calendar'
       viewMode.value = 'calendar'
     }
     }
@@ -580,6 +712,26 @@
     viewMode.value = 'stats'
     viewMode.value = 'stats'
   }
   }
 
 
+  async function loadDictOptions() {
+    try {
+      const [shifts, plans] = await Promise.all([
+        fetchDictDataOptions('duty_shift_type'),
+        fetchDictDataOptions('duty_work_plan')
+      ])
+      shiftTypeOptions.value = (shifts || []).map((x) => ({
+        label: x.label,
+        value: x.value
+      }))
+      workPlanOptions.value = (plans || []).map((x) => ({
+        label: x.label,
+        value: x.value
+      }))
+    } catch {
+      shiftTypeOptions.value = []
+      workPlanOptions.value = []
+    }
+  }
+
   async function loadMonth() {
   async function loadMonth() {
     loading.value = true
     loading.value = true
     filterPersonKey.value = ''
     filterPersonKey.value = ''
@@ -616,21 +768,113 @@
     void loadMonth()
     void loadMonth()
   }
   }
 
 
+  function applyFoundToFormSlot(fs: FormSlot, found: DutySlotItem) {
+    fs.enabled = true
+    fs.capacity = found.capacity || 1
+    fs.workPlans = [...(found.workPlans || [])]
+    fs.remark = (found.remark || '').trim()
+    fs.startTime = found.startTime || null
+    fs.endTime = found.endTime || null
+    fs.crossDay = !!found.crossDay
+    fs.pendingUserId = ''
+    fs.assignments = found ? [...(found.assignments || [])] : []
+  }
+
   function resetFormSlots(detail: DutyDayItem | null) {
   function resetFormSlots(detail: DutyDayItem | null) {
-    for (const fs of formSlots) {
-      const found = detail?.slots?.find((s) => s.shiftType === fs.shiftType)
-      fs.enabled = !!found
-      fs.capacity = found?.capacity || 1
-      fs.remark = (found?.remark || '').trim()
-      fs.pendingUserId = ''
-      fs.assignments = found ? [...(found.assignments || [])] : []
-    }
-    if (!detail?.slots?.length) {
-      // 默认启用早班晚班各 1
-      formSlots[0].enabled = true
-      formSlots[0].capacity = 1
-      formSlots[1].enabled = true
-      formSlots[1].capacity = 1
+    const slots = blankFormSlots()
+    if (detail?.slots?.length) {
+      for (const fs of slots) {
+        const found = detail.slots.find((s) => s.shiftType === fs.shiftType)
+        if (found) applyFoundToFormSlot(fs, found)
+        else fs.enabled = false
+      }
+      for (const s of detail.slots) {
+        if (isPresetShift(s.shiftType)) continue
+        if (slots.some((x) => x.shiftType === s.shiftType)) continue
+        const custom: FormSlot = {
+          shiftType: s.shiftType,
+          enabled: true,
+          capacity: 1,
+          workPlans: [],
+          remark: '',
+          ...defaultTimesFor(s.shiftType),
+          pendingUserId: '',
+          assignments: []
+        }
+        applyFoundToFormSlot(custom, s)
+        slots.push(custom)
+      }
+    }
+    formSlots.value = slots
+    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',
+    raw: string,
+    options: Array<{ label: string; value: string }>
+  ) {
+    const existing = options.find((o) => o.value === raw || o.label === raw)
+    if (existing) return existing.value
+    const res = await ensureDutyDict({ dictType, label: raw })
+    if (!options.some((o) => o.value === res.value)) {
+      options.push({ label: res.label || raw, value: res.value })
+    }
+    return res.value
+  }
+
+  async function onWorkPlansChange(slot: FormSlot, values: string[]) {
+    const next: string[] = []
+    for (const raw of values || []) {
+      const v = String(raw || '').trim()
+      if (!v) continue
+      try {
+        const value = await resolveDictValue('duty_work_plan', v, workPlanOptions.value)
+        if (!next.includes(value)) next.push(value)
+      } catch (e: any) {
+        ElMessage.error(e?.message || '创建工作安排失败')
+      }
+    }
+    slot.workPlans = next
+  }
+
+  async function confirmAddShift() {
+    const raw = String(addShiftDraft.value || '').trim()
+    if (!raw) {
+      ElMessage.warning('请选择或输入班次类型')
+      return
+    }
+    addingShift.value = true
+    try {
+      const shiftType = await resolveDictValue('duty_shift_type', raw, shiftTypeOptions.value)
+      if (formSlots.value.some((s) => s.shiftType === shiftType)) {
+        ElMessage.warning('该班次类型当日已存在,不可重复')
+        addShiftDraft.value = ''
+        return
+      }
+      formSlots.value.push({
+        shiftType,
+        enabled: true,
+        capacity: 1,
+        workPlans: [],
+        remark: '',
+        ...defaultTimesFor(shiftType),
+        pendingUserId: '',
+        assignments: []
+      })
+      addShiftDraft.value = ''
+    } catch (e: any) {
+      ElMessage.error(e?.message || '添加班次失败')
+    } finally {
+      addingShift.value = false
     }
     }
   }
   }
 
 
@@ -684,20 +928,28 @@
   }
   }
 
 
   async function handleSave() {
   async function handleSave() {
-    const slots = formSlots
-      .filter((s) => s.enabled)
-      .map((s) => ({
-        shiftType: s.shiftType,
-        capacity: s.capacity,
-        remark: s.remark.trim() || null,
-        assignments: s.assignments
-          .filter((a) => a.source === 'assign')
-          .map((a) => ({
-            source: 'assign' as const,
-            userId: a.userId || undefined,
-            personName: a.personName
-          }))
-      }))
+    const enabled = formSlots.value.filter((s) => s.enabled)
+    const types = enabled.map((s) => s.shiftType)
+    if (new Set(types).size !== types.length) {
+      ElMessage.warning('同一天不能重复配置相同班次类型')
+      return
+    }
+    const slots = enabled.map((s) => ({
+      shiftType: s.shiftType,
+      capacity: s.capacity,
+      workPlans: s.workPlans || [],
+      remark: s.remark.trim() || null,
+      startTime: s.startTime || null,
+      endTime: s.endTime || null,
+      crossDay: !!s.crossDay,
+      assignments: s.assignments
+        .filter((a) => a.source === 'assign')
+        .map((a) => ({
+          source: 'assign' as const,
+          userId: a.userId || undefined,
+          personName: a.personName
+        }))
+    }))
     if (!slots.length) {
     if (!slots.length) {
       ElMessage.warning('请至少启用一个班次')
       ElMessage.warning('请至少启用一个班次')
       return
       return
@@ -744,6 +996,7 @@
   }
   }
 
 
   onMounted(async () => {
   onMounted(async () => {
+    await loadDictOptions()
     try {
     try {
       const opts = await fetchDutyUserOptions()
       const opts = await fetchDutyUserOptions()
       userOptions.value = (Array.isArray(opts) ? opts : []) as Array<{
       userOptions.value = (Array.isArray(opts) ? opts : []) as Array<{
@@ -804,6 +1057,9 @@
     .dot.duty {
     .dot.duty {
       background: #ef4444;
       background: #ef4444;
     }
     }
+    .dot.other {
+      background: #64748b;
+    }
 
 
     .filter-row {
     .filter-row {
       display: flex;
       display: flex;
@@ -957,6 +1213,9 @@
       gap: 8px;
       gap: 8px;
       margin-bottom: 12px;
       margin-bottom: 12px;
     }
     }
+    .stats-breakdown.has-other {
+      grid-template-columns: repeat(4, 1fr);
+    }
     .stats-breakdown .cell {
     .stats-breakdown .cell {
       padding: 10px 8px;
       padding: 10px 8px;
       text-align: center;
       text-align: center;
@@ -986,6 +1245,9 @@
     .stats-breakdown .cell.duty {
     .stats-breakdown .cell.duty {
       background: #fef2f2;
       background: #fef2f2;
     }
     }
+    .stats-breakdown .cell.other {
+      background: #f1f5f9;
+    }
     .stats-bar {
     .stats-bar {
       display: flex;
       display: flex;
       height: 8px;
       height: 8px;
@@ -1006,6 +1268,9 @@
     .stats-bar .seg.duty {
     .stats-bar .seg.duty {
       background: #ef4444;
       background: #ef4444;
     }
     }
+    .stats-bar .seg.other {
+      background: #64748b;
+    }
     .stats-card-ft {
     .stats-card-ft {
       margin-top: 10px;
       margin-top: 10px;
       font-size: 11px;
       font-size: 11px;
@@ -1144,6 +1409,10 @@
       color: #b91c1c;
       color: #b91c1c;
       background: #fef2f2;
       background: #fef2f2;
     }
     }
+    .chip.other {
+      color: #475569;
+      background: #f1f5f9;
+    }
     .chip.empty {
     .chip.empty {
       color: var(--mint-text-muted, #64748b);
       color: var(--mint-text-muted, #64748b);
       background: #f1f5f9;
       background: #f1f5f9;
@@ -1172,7 +1441,7 @@
       justify-content: space-between;
       justify-content: space-between;
       margin-bottom: 8px;
       margin-bottom: 8px;
     }
     }
-    .slot-assign-row {
+    .slot-fields-row {
       margin-bottom: 4px;
       margin-bottom: 4px;
       :deep(.el-form-item) {
       :deep(.el-form-item) {
         margin-bottom: 0;
         margin-bottom: 0;
@@ -1194,6 +1463,13 @@
       font-size: 12px;
       font-size: 12px;
       color: var(--mint-text-muted, #94a3b8);
       color: var(--mint-text-muted, #94a3b8);
     }
     }
+    .add-shift-row {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 8px;
+      align-items: center;
+      margin: 4px 0 12px;
+    }
     .link-box {
     .link-box {
       display: flex;
       display: flex;
       flex-wrap: wrap;
       flex-wrap: wrap;