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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-20 21:19:06 +08:00
1 parent a98ec0443a
commit 8df0583ad1
1 file changed
+364 -88
+364 -88
View File
@@ -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;