feat(duty): admin schedule UI for dict types, work plans, and time
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
a98ec0443a
commit
8df0583ad1
1 file changed
+364
-88
@@ -20,6 +20,7 @@
|
||||
<span><i class="dot am" />早班</span>
|
||||
<span><i class="dot pm" />晚班</span>
|
||||
<span><i class="dot duty" />值班</span>
|
||||
<span><i class="dot other" />其他</span>
|
||||
<span class="muted">{{
|
||||
viewMode === 'stats' ? '点击本月合计可返回日历' : '点击日期配置 / 复制链接'
|
||||
}}</span>
|
||||
@@ -52,6 +53,15 @@
|
||||
>
|
||||
<i class="dot duty" />值班 <em>{{ monthShiftStats.duty }}</em>
|
||||
</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
|
||||
type="button"
|
||||
class="stat total"
|
||||
@@ -137,7 +147,7 @@
|
||||
<span>总次数</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="stats-breakdown">
|
||||
<div class="stats-breakdown" :class="{ 'has-other': card.other > 0 }">
|
||||
<div class="cell am">
|
||||
<span><i class="dot am" />早班</span>
|
||||
<strong>{{ card.morning }}</strong>
|
||||
@@ -150,6 +160,10 @@
|
||||
<span><i class="dot duty" />值班</span>
|
||||
<strong>{{ card.duty }}</strong>
|
||||
</div>
|
||||
<div v-if="card.other > 0" class="cell other">
|
||||
<span><i class="dot other" />其他</span>
|
||||
<strong>{{ card.other }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-bar">
|
||||
<i
|
||||
@@ -167,10 +181,13 @@
|
||||
class="seg duty"
|
||||
:style="{ width: barPct(card.duty, card.total) }"
|
||||
/>
|
||||
<i
|
||||
v-if="card.other"
|
||||
class="seg other"
|
||||
:style="{ width: barPct(card.other, card.total) }"
|
||||
/>
|
||||
</div>
|
||||
<footer class="stats-card-ft">
|
||||
{{ year }}年{{ pad(month) }}月 排班统计
|
||||
</footer>
|
||||
<footer class="stats-card-ft"> {{ year }}年{{ pad(month) }}月 排班统计 </footer>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,14 +210,74 @@
|
||||
<div v-for="slot in formSlots" :key="slot.shiftType" class="slot-card">
|
||||
<div class="slot-hd">
|
||||
<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>
|
||||
<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>
|
||||
<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
|
||||
: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="备注" 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">
|
||||
<el-select
|
||||
v-model="slot.pendingUserId"
|
||||
filterable
|
||||
@@ -217,16 +294,6 @@
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="工作安排">
|
||||
<el-input
|
||||
v-model="slot.remark"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="可选,填写本班次工作安排"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="person-tags">
|
||||
<el-tag
|
||||
@@ -244,6 +311,29 @@
|
||||
</template>
|
||||
</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">
|
||||
<span>公开链接</span>
|
||||
<code>{{ dayDetail.publicUrl }}</code>
|
||||
@@ -251,12 +341,7 @@
|
||||
<el-icon><CopyDocument /></el-icon>
|
||||
复制
|
||||
</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>
|
||||
{{ dayDetail.linkStatus === 'closed' ? '打开链接' : '关闭链接' }}
|
||||
</el-button>
|
||||
@@ -278,7 +363,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
@@ -286,30 +371,35 @@
|
||||
Check,
|
||||
Close,
|
||||
CopyDocument,
|
||||
Plus,
|
||||
SwitchButton
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { fetchDictDataOptions } from '@/api/admin'
|
||||
import {
|
||||
deleteDutyAssignment,
|
||||
ensureDutyDict,
|
||||
fetchDutyDay,
|
||||
fetchDutyMonth,
|
||||
fetchDutyUserOptions,
|
||||
saveDutyDay,
|
||||
setDutyLinkStatus,
|
||||
type DutyDayItem,
|
||||
type DutySlotItem,
|
||||
type ShiftType
|
||||
type DutySlotItem
|
||||
} from '@/api/duty'
|
||||
|
||||
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: '早班',
|
||||
evening: '晚班',
|
||||
duty: '值班'
|
||||
}
|
||||
const SHIFT_CLS: Record<ShiftType, string> = {
|
||||
const SHIFT_CLS: Record<string, string> = {
|
||||
morning: 'am',
|
||||
evening: 'pm',
|
||||
duty: 'duty'
|
||||
@@ -320,37 +410,63 @@
|
||||
const month = ref(now.getMonth() + 1)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const addingShift = ref(false)
|
||||
const monthDays = ref<DutyDayItem[]>([])
|
||||
const filterPersonKey = ref('')
|
||||
const filterShiftType = ref<ShiftType | ''>('')
|
||||
const filterShiftType = ref<FilterShift>('')
|
||||
const viewMode = ref<'calendar' | 'stats'>('calendar')
|
||||
const dialogVisible = ref(false)
|
||||
const editingDate = ref('')
|
||||
const dayDetail = ref<DutyDayItem | null>(null)
|
||||
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 = {
|
||||
shiftType: ShiftType
|
||||
shiftType: string
|
||||
enabled: boolean
|
||||
capacity: number
|
||||
workPlans: string[]
|
||||
remark: string
|
||||
startTime: string | null
|
||||
endTime: string | null
|
||||
crossDay: boolean
|
||||
pendingUserId: string
|
||||
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,
|
||||
enabled: false,
|
||||
enabled: t === 'morning' || t === 'evening',
|
||||
capacity: 1,
|
||||
workPlans: [],
|
||||
remark: '',
|
||||
...defaultTimesFor(t),
|
||||
pendingUserId: '',
|
||||
assignments: []
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
function shiftLabel(t: ShiftType) {
|
||||
return SHIFT_LABELS[t]
|
||||
const formSlots = ref<FormSlot[]>(blankFormSlots())
|
||||
|
||||
function isPresetShift(t: string): t is PresetShift {
|
||||
return (PRESET_TYPES as readonly string[]).includes(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) {
|
||||
@@ -361,7 +477,6 @@
|
||||
return `${y}-${pad(m)}-${pad(d)}`
|
||||
}
|
||||
|
||||
/** 统计/过滤统一按展示姓名合并,避免同人早班有 userId、晚班无 userId 时拆成两条 */
|
||||
function personDisplayName(a: { userId?: string | null; personName?: string }) {
|
||||
return (a.personName || '').trim()
|
||||
}
|
||||
@@ -374,12 +489,23 @@
|
||||
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 map = new Map<string, { key: string; name: string; count: number }>()
|
||||
const shiftFilter = filterShiftType.value
|
||||
for (const day of monthDays.value) {
|
||||
for (const slot of day.slots || []) {
|
||||
if (shiftFilter && slot.shiftType !== shiftFilter) continue
|
||||
if (!matchesShiftFilter(slot.shiftType, shiftFilter)) continue
|
||||
for (const a of slot.assignments || []) {
|
||||
const name = personDisplayName(a)
|
||||
if (!name) continue
|
||||
@@ -395,26 +521,32 @@
|
||||
})
|
||||
})
|
||||
|
||||
/** 当月各类班次占位次数(与人员统计同口径) */
|
||||
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 slot of day.slots || []) {
|
||||
const n = (slot.assignments || []).length
|
||||
if (slot.shiftType === 'morning') counts.morning += n
|
||||
else if (slot.shiftType === 'evening') counts.evening += n
|
||||
else if (slot.shiftType === 'duty') counts.duty += n
|
||||
else counts.other += n
|
||||
}
|
||||
}
|
||||
return {
|
||||
...counts,
|
||||
total: counts.morning + counts.evening + counts.duty
|
||||
total: counts.morning + counts.evening + counts.duty + counts.other
|
||||
}
|
||||
})
|
||||
|
||||
/** 统计视图:每人各班次次数卡片 */
|
||||
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>()
|
||||
for (const day of monthDays.value) {
|
||||
for (const slot of day.slots || []) {
|
||||
@@ -423,19 +555,20 @@
|
||||
if (!name) continue
|
||||
let row = map.get(name)
|
||||
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)
|
||||
}
|
||||
if (slot.shiftType === 'morning') row.morning += 1
|
||||
else if (slot.shiftType === 'evening') row.evening += 1
|
||||
else if (slot.shiftType === 'duty') row.duty += 1
|
||||
else row.other += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(map.values())
|
||||
.map((row) => ({
|
||||
...row,
|
||||
total: row.morning + row.evening + row.duty,
|
||||
total: row.morning + row.evening + row.duty + row.other,
|
||||
initial: row.name.slice(0, 1)
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
@@ -502,29 +635,28 @@
|
||||
const chips: Array<{ text: string; cls: string; title?: string }> = []
|
||||
if (summary?.slots?.length) {
|
||||
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
|
||||
const names = matched.map((a) => a.personName).filter(Boolean)
|
||||
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
|
||||
if (filterKey) {
|
||||
text = `${s.shiftLabel || SHIFT_LABELS[s.shiftType]} ${names.join('、')}`
|
||||
text = `${label} ${names.join('、')}`
|
||||
} 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 {
|
||||
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({
|
||||
text,
|
||||
cls: SHIFT_CLS[s.shiftType] || 'am',
|
||||
title: remark || undefined
|
||||
cls: shiftChipCls(s.shiftType),
|
||||
title: planText || undefined
|
||||
})
|
||||
}
|
||||
if (!hasFilter && !chips.length) {
|
||||
@@ -560,7 +692,7 @@
|
||||
filterPersonKey.value = filterPersonKey.value === key ? '' : key
|
||||
}
|
||||
|
||||
function toggleShiftFilter(shift: ShiftType) {
|
||||
function toggleShiftFilter(shift: Exclude<FilterShift, ''>) {
|
||||
if (viewMode.value === 'stats') {
|
||||
viewMode.value = 'calendar'
|
||||
}
|
||||
@@ -580,6 +712,26 @@
|
||||
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() {
|
||||
loading.value = true
|
||||
filterPersonKey.value = ''
|
||||
@@ -616,21 +768,113 @@
|
||||
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) {
|
||||
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 || [])] : []
|
||||
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)
|
||||
}
|
||||
}
|
||||
if (!detail?.slots?.length) {
|
||||
// 默认启用早班晚班各 1
|
||||
formSlots[0].enabled = true
|
||||
formSlots[0].capacity = 1
|
||||
formSlots[1].enabled = true
|
||||
formSlots[1].capacity = 1
|
||||
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() {
|
||||
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) {
|
||||
ElMessage.warning('请至少启用一个班次')
|
||||
return
|
||||
@@ -744,6 +996,7 @@
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDictOptions()
|
||||
try {
|
||||
const opts = await fetchDutyUserOptions()
|
||||
userOptions.value = (Array.isArray(opts) ? opts : []) as Array<{
|
||||
@@ -804,6 +1057,9 @@
|
||||
.dot.duty {
|
||||
background: #ef4444;
|
||||
}
|
||||
.dot.other {
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
@@ -957,6 +1213,9 @@
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.stats-breakdown.has-other {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
.stats-breakdown .cell {
|
||||
padding: 10px 8px;
|
||||
text-align: center;
|
||||
@@ -986,6 +1245,9 @@
|
||||
.stats-breakdown .cell.duty {
|
||||
background: #fef2f2;
|
||||
}
|
||||
.stats-breakdown .cell.other {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
height: 8px;
|
||||
@@ -1006,6 +1268,9 @@
|
||||
.stats-bar .seg.duty {
|
||||
background: #ef4444;
|
||||
}
|
||||
.stats-bar .seg.other {
|
||||
background: #64748b;
|
||||
}
|
||||
.stats-card-ft {
|
||||
margin-top: 10px;
|
||||
font-size: 11px;
|
||||
@@ -1144,6 +1409,10 @@
|
||||
color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
}
|
||||
.chip.other {
|
||||
color: #475569;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
.chip.empty {
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
background: #f1f5f9;
|
||||
@@ -1172,7 +1441,7 @@
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.slot-assign-row {
|
||||
.slot-fields-row {
|
||||
margin-bottom: 4px;
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
@@ -1194,6 +1463,13 @@
|
||||
font-size: 12px;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in new issue
Block a user