<script lang="ts" setup>
|
import type { HrmEmployeeApi } from '#/api/hrm/employee';
|
import type { HrmEmployeeScheduleApi } from '#/api/hrm/schedule';
|
import type { HrmShiftApi } from '#/api/hrm/shift';
|
|
import { computed, onMounted, ref, watch } from 'vue';
|
|
import dayjs from 'dayjs';
|
|
import {
|
Button,
|
Checkbox,
|
DatePicker,
|
Empty,
|
Input,
|
message,
|
Modal,
|
Pagination,
|
Radio,
|
Segmented,
|
Select,
|
TreeSelect,
|
} from 'ant-design-vue';
|
|
import { Page } from '#/packages/effects/common-ui/src';
|
|
import { getEmployeePage } from '#/api/hrm/employee';
|
import { getShiftList } from '#/api/hrm/shift';
|
import { getEmployeeScheduleList, saveEmployeeSchedule } from '#/api/hrm/schedule';
|
import { getDeptList } from '#/api/system/dept';
|
import { handleTree } from '#/packages/utils/src';
|
|
import {
|
REST_MODE,
|
REST_MODE_OPTIONS,
|
formatDate,
|
getDaysOfMonth,
|
} from './data';
|
import type { RestMode } from './data';
|
|
defineOptions({ name: 'HrmSchedule' });
|
|
// ========== 查询条件 ==========
|
const month = ref<string>('');
|
const deptId = ref<number>();
|
const deptTree = ref<any[]>([]);
|
|
/** 员工分页(排班网格每次只显示一页员工) */
|
const employeePageNo = ref(1);
|
const employeePageSize = ref(20);
|
const employeeTotal = ref(0);
|
|
/** 按员工编号/姓名/手机号搜索单个员工 */
|
const keyword = ref('');
|
|
/** 保存时单次请求的排班条目数(防止大批量请求过大) */
|
const SAVE_BATCH_SIZE = 1000;
|
|
// ========== 数据 ==========
|
const employees = ref<HrmEmployeeApi.Employee[]>([]);
|
const shiftList = ref<HrmShiftApi.Shift[]>([]);
|
const loading = ref(false);
|
const saving = ref(false);
|
/** 是否已执行过查询 */
|
const loaded = ref(false);
|
|
// ========== 员工勾选 ==========
|
/** Checkbox 变更事件(仅取 checked 字段) */
|
interface CheckChangeEvent {
|
target: { checked: boolean };
|
}
|
|
/** 勾选的员工ID集合,为空时自动排班作用于全部员工 */
|
const checkedEmployeeIds = ref<Set<number>>(new Set());
|
|
const isAllChecked = computed(
|
() =>
|
employees.value.length > 0 &&
|
checkedEmployeeIds.value.size === employees.value.length,
|
);
|
|
function isChecked(id: number): boolean {
|
return checkedEmployeeIds.value.has(id);
|
}
|
|
function handleCheck(id: number, e: CheckChangeEvent) {
|
const set = new Set(checkedEmployeeIds.value);
|
if (e.target.checked) {
|
set.add(id);
|
} else {
|
set.delete(id);
|
}
|
checkedEmployeeIds.value = set;
|
}
|
|
function handleCheckAll(e: CheckChangeEvent) {
|
if (e.target.checked) {
|
checkedEmployeeIds.value = new Set(
|
employees.value
|
.map((item) => item.id)
|
.filter((id): id is number => id != null),
|
);
|
} else {
|
checkedEmployeeIds.value = new Set();
|
}
|
}
|
|
// ========== 自动排班 ==========
|
const autoOpen = ref(false);
|
const autoShiftId = ref<number>();
|
const autoRestMode = ref<RestMode>(REST_MODE.DOUBLE);
|
|
/** 自动排班班次选择选项 */
|
const autoShiftOptions = computed(() =>
|
shiftList.value.map((shift) => ({
|
label: `${shift.name} (${shift.startTime}~${shift.endTime})`,
|
value: shift.id,
|
})),
|
);
|
|
/** 按休息模式为当前员工生成整月排班(休息日清除,工作日填班次) */
|
function applyAutoSchedule() {
|
if (!employees.value.length || !days.value.length) {
|
message.warning('请先查询出员工');
|
return;
|
}
|
if (autoShiftId.value == null) {
|
message.warning('请选择排班班次');
|
return;
|
}
|
// 勾选了员工则只对勾选人生效,未勾选则作用于全部员工
|
const targets =
|
checkedEmployeeIds.value.size > 0
|
? employees.value.filter(
|
(item) =>
|
item.id != null && checkedEmployeeIds.value.has(item.id),
|
)
|
: employees.value;
|
if (!targets.length) {
|
message.warning('请先勾选员工');
|
return;
|
}
|
const map = new Map(scheduleMap.value);
|
for (const emp of targets) {
|
if (emp.id == null) {
|
continue;
|
}
|
for (const day of days.value) {
|
const date = formatDate(month.value, day);
|
const week = new Date(
|
`${month.value}-${String(day).padStart(2, '0')}`,
|
).getDay();
|
let rest = false;
|
if (autoRestMode.value === REST_MODE.DOUBLE) {
|
rest = week === 0 || week === 6;
|
} else if (autoRestMode.value === REST_MODE.SINGLE) {
|
rest = week === 0;
|
}
|
map.set(`${emp.id}_${date}`, rest ? null : autoShiftId.value);
|
}
|
}
|
scheduleMap.value = map;
|
autoOpen.value = false;
|
message.success('自动排班已生成,可手动微调后保存');
|
}
|
|
/** 班次 id → 班次 映射 */
|
const shiftMap = computed(() => {
|
const map = new Map<number, HrmShiftApi.Shift>();
|
for (const shift of shiftList.value) {
|
if (shift.id != null) {
|
map.set(shift.id, shift);
|
}
|
}
|
return map;
|
});
|
|
/**
|
* 排班数据。key = `${employeeId}_${date}`,value = shiftId(null 表示休息)
|
*/
|
const scheduleMap = ref(new Map<string, number | null>());
|
|
/** 当月天数数组 */
|
const days = computed(() => (month.value ? getDaysOfMonth(month.value) : []));
|
|
// ========== 视图切换(周/月)==========
|
const WEEK_LABELS = ['日', '一', '二', '三', '四', '五', '六'];
|
|
interface ViewDay {
|
day: number;
|
date: string;
|
weekLabel: string;
|
isWeekend: boolean;
|
}
|
|
const viewMode = ref<'week' | 'month'>('week');
|
/** 月视图是否显示周末列(默认隐藏,减列缓解拥挤) */
|
const showWeekend = ref(false);
|
/** 周视图锚点日期 */
|
const viewAnchor = ref<Date>(new Date());
|
|
/** 当前视图要展示的日期列表 */
|
const viewDays = computed<ViewDay[]>(() => {
|
if (viewMode.value === 'week') {
|
// 以周一为一周起点
|
const base = dayjs(viewAnchor.value).day(1);
|
return Array.from({ length: 7 }, (_, i) => {
|
const date = base.add(i, 'day');
|
const week = date.day();
|
return {
|
day: date.date(),
|
date: date.format('YYYY-MM-DD'),
|
weekLabel: WEEK_LABELS[week] ?? '',
|
isWeekend: week === 0 || week === 6,
|
};
|
});
|
}
|
return days.value
|
.filter((day) => {
|
if (showWeekend.value) {
|
return true;
|
}
|
const week = new Date(
|
`${month.value}-${String(day).padStart(2, '0')}`,
|
).getDay();
|
return week !== 0 && week !== 6;
|
})
|
.map((day) => {
|
const week = new Date(
|
`${month.value}-${String(day).padStart(2, '0')}`,
|
).getDay();
|
return {
|
day,
|
date: formatDate(month.value, day),
|
weekLabel: WEEK_LABELS[week] ?? '',
|
isWeekend: week === 0 || week === 6,
|
};
|
});
|
});
|
|
/** 周视图当前显示范围标签 */
|
const weekRangeLabel = computed(() => {
|
const list = viewDays.value;
|
const first = list[0];
|
const last = list[list.length - 1];
|
if (!first || !last) {
|
return '';
|
}
|
return `${first.date} ~ ${last.date}`;
|
});
|
|
function shiftWeek(delta: number) {
|
viewAnchor.value = dayjs(viewAnchor.value).add(delta, 'week').toDate();
|
}
|
|
// 月份变化时,周视图锚点落到该月 1 号所在周
|
watch(month, (value) => {
|
if (value) {
|
viewAnchor.value = new Date(`${value}-01`);
|
}
|
});
|
|
function getShiftByCell(employeeId: number, date: string): HrmShiftApi.Shift | undefined {
|
const shiftId = scheduleMap.value.get(`${employeeId}_${date}`);
|
if (shiftId == null) {
|
return undefined;
|
}
|
return shiftMap.value.get(shiftId);
|
}
|
|
// ========== 班次选择弹窗 ==========
|
const pickerOpen = ref(false);
|
const pickerEmployee = ref<HrmEmployeeApi.Employee>();
|
const pickerDate = ref<string>('');
|
|
const pickerTitle = computed(() => {
|
if (!pickerEmployee.value || !pickerDate.value) {
|
return '选择班次';
|
}
|
return `选择班次 · ${pickerEmployee.value.name} · ${pickerDate.value}`;
|
});
|
|
function openPicker(emp: HrmEmployeeApi.Employee, date: string) {
|
pickerEmployee.value = emp;
|
pickerDate.value = date;
|
pickerOpen.value = true;
|
}
|
|
/** 当前弹窗格子是否已选该班次 */
|
function isCurrentPick(shiftId: number): boolean {
|
if (!pickerEmployee.value || !pickerDate.value) {
|
return false;
|
}
|
return (
|
scheduleMap.value.get(`${pickerEmployee.value.id}_${pickerDate.value}`) ===
|
shiftId
|
);
|
}
|
|
function pickShift(shiftId: number | null) {
|
if (!pickerEmployee.value || !pickerDate.value) {
|
return;
|
}
|
scheduleMap.value.set(`${pickerEmployee.value.id}_${pickerDate.value}`, shiftId);
|
pickerOpen.value = false;
|
}
|
|
// ========== 加载 ==========
|
async function loadDeptTree() {
|
const data = await getDeptList();
|
deptTree.value = handleTree(data);
|
}
|
|
/**
|
* 分页拉取部门下的全部员工(单页上限 200,循环拼全),供排班网格整表展示
|
*/
|
/** 加载当前页员工(翻页时只重新拉员工,排班数据保留) */
|
async function loadEmployeePage() {
|
loading.value = true;
|
try {
|
const res = await getEmployeePage({
|
pageNo: employeePageNo.value,
|
pageSize: employeePageSize.value,
|
deptId: deptId.value,
|
keyword: keyword.value || undefined,
|
});
|
employees.value = res.list ?? [];
|
employeeTotal.value = res.total ?? 0;
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
/** 员工翻页 */
|
function handlePageChange(page: number, pageSize: number) {
|
if (pageSize !== employeePageSize.value) {
|
employeePageSize.value = pageSize;
|
employeePageNo.value = 1;
|
} else {
|
employeePageNo.value = page;
|
}
|
loadEmployeePage();
|
}
|
|
/** 按关键词搜索员工 */
|
function handleSearch() {
|
employeePageNo.value = 1;
|
loadEmployeePage();
|
}
|
|
async function loadData() {
|
if (!month.value) {
|
message.warning('请选择月份');
|
return;
|
}
|
if (!deptId.value) {
|
message.warning('请先选择部门');
|
return;
|
}
|
loading.value = true;
|
try {
|
const [shifts, schedules] = await Promise.all([
|
getShiftList(),
|
getEmployeeScheduleList({
|
yearMonth: month.value,
|
deptId: deptId.value,
|
}),
|
]);
|
shiftList.value = shifts;
|
const map = new Map<string, number | null>();
|
for (const item of schedules) {
|
if (item.employeeId != null && item.date) {
|
map.set(`${item.employeeId}_${item.date}`, item.shiftId ?? null);
|
}
|
}
|
scheduleMap.value = map;
|
loaded.value = true;
|
} finally {
|
loading.value = false;
|
}
|
// 加载第一页员工
|
employeePageNo.value = 1;
|
await loadEmployeePage();
|
}
|
|
// ========== 保存(整月全量) ==========
|
async function handleSave() {
|
if (!month.value || !employees.value.length) {
|
return;
|
}
|
Modal.confirm({
|
title: '确认保存',
|
content: `将保存当前页 ${employees.value.length} 名员工在 ${month.value} 的全部排班,确定继续吗?`,
|
okText: '确定',
|
cancelText: '取消',
|
onOk: async () => {
|
saving.value = true;
|
try {
|
const items: HrmEmployeeScheduleApi.ScheduleItem[] = [];
|
for (const emp of employees.value) {
|
if (emp.id == null) {
|
continue;
|
}
|
for (const day of days.value) {
|
const date = formatDate(month.value, day);
|
items.push({
|
employeeId: emp.id,
|
date,
|
shiftId: scheduleMap.value.get(`${emp.id}_${date}`) ?? null,
|
});
|
}
|
}
|
// 大批量时分批提交,避免单次请求过大
|
for (let i = 0; i < items.length; i += SAVE_BATCH_SIZE) {
|
await saveEmployeeSchedule(items.slice(i, i + SAVE_BATCH_SIZE));
|
}
|
message.success('排班保存成功');
|
} finally {
|
saving.value = false;
|
}
|
},
|
});
|
}
|
|
onMounted(() => {
|
month.value = dayjs().format('YYYY-MM');
|
loadDeptTree();
|
});
|
</script>
|
|
<template>
|
<Page auto-content-height>
|
<!-- 查询工具栏 -->
|
<div
|
class="mb-4 flex flex-wrap items-center gap-3 rounded-lg bg-white p-4"
|
>
|
<DatePicker
|
v-model:value="month"
|
picker="month"
|
:allow-clear="false"
|
value-format="YYYY-MM"
|
format="YYYY-MM"
|
style="width: 140px"
|
/>
|
<TreeSelect
|
v-model:value="deptId"
|
:tree-data="deptTree"
|
:field-names="{ label: 'name', value: 'id', children: 'children' }"
|
allow-clear
|
placeholder="请选择部门"
|
tree-default-expand-all
|
style="width: 200px"
|
/>
|
<Input
|
v-model:value="keyword"
|
allow-clear
|
placeholder="员工编号/姓名/手机号"
|
style="width: 200px"
|
@pressEnter="handleSearch"
|
/>
|
<Button type="primary" :loading="loading" @click="loadData">
|
查询排班
|
</Button>
|
<Button :disabled="!employees.length" @click="autoOpen = true">
|
自动排班
|
</Button>
|
<Button
|
type="primary"
|
:loading="saving"
|
:disabled="!employees.length"
|
@click="handleSave"
|
>
|
保存排班
|
</Button>
|
<Segmented
|
v-model:value="viewMode"
|
:options="[
|
{ label: '周视图', value: 'week' },
|
{ label: '月视图', value: 'month' },
|
]"
|
/>
|
<template v-if="viewMode === 'week'">
|
<Button @click="shiftWeek(-1)">上一周</Button>
|
<span class="text-sm text-gray-500">{{ weekRangeLabel }}</span>
|
<Button @click="shiftWeek(1)">下一周</Button>
|
</template>
|
<template v-else>
|
<Checkbox v-model:checked="showWeekend">显示周末</Checkbox>
|
</template>
|
</div>
|
|
<!-- 班次图例 -->
|
<div
|
v-if="shiftList.length"
|
class="mb-4 flex flex-wrap items-center gap-4 rounded-lg bg-white px-4 py-3"
|
>
|
<span class="text-sm text-gray-500">班次图例:</span>
|
<span
|
v-for="shift in shiftList"
|
:key="shift.id"
|
class="inline-flex items-center gap-1.5 text-sm"
|
>
|
<span
|
class="inline-block h-3 w-3 rounded"
|
:style="{ backgroundColor: shift.color || '#1677ff' }"
|
/>
|
<span>{{ shift.name }}</span>
|
<span class="text-xs text-gray-400"
|
>{{ shift.startTime }}~{{ shift.endTime }}</span
|
>
|
</span>
|
</div>
|
|
<!-- 排班网格 -->
|
<div class="overflow-auto rounded-lg bg-white">
|
<Empty
|
v-if="!employees.length && !loading"
|
:description="
|
loaded
|
? '该部门暂无符合条件的员工,请调整部门或员工状态'
|
: '请选择月份、部门后,点击「查询排班」'
|
"
|
class="py-20"
|
/>
|
<table v-else class="schedule-table w-full text-sm">
|
<thead>
|
<tr>
|
<th class="schedule-check-col bg-gray-100">
|
<Checkbox :checked="isAllChecked" @change="handleCheckAll" />
|
</th>
|
<th class="schedule-name-col bg-gray-100 text-left">员工</th>
|
<th
|
v-for="vd in viewDays"
|
:key="vd.date"
|
:class="[
|
'schedule-date-col text-center',
|
vd.isWeekend ? 'bg-red-50' : 'bg-gray-50',
|
]"
|
>
|
<div class="font-medium">{{ vd.day }}</div>
|
<div class="text-xs text-gray-400">{{ vd.weekLabel }}</div>
|
</th>
|
</tr>
|
</thead>
|
<tbody>
|
<tr v-for="emp in employees" :key="emp.id">
|
<td class="schedule-check-col" @click.stop>
|
<Checkbox
|
:checked="isChecked(emp.id!)"
|
@change="handleCheck(emp.id!, $event)"
|
/>
|
</td>
|
<td class="schedule-name-col">
|
<div class="font-medium">{{ emp.name }}</div>
|
<div class="text-xs text-gray-400">{{ emp.employeeNo }}</div>
|
</td>
|
<td
|
v-for="vd in viewDays"
|
:key="vd.date"
|
class="schedule-cell"
|
:class="[vd.isWeekend ? 'bg-red-50/40' : '']"
|
@click="openPicker(emp, vd.date)"
|
>
|
<span
|
v-if="getShiftByCell(emp.id!, vd.date)"
|
class="shift-chip"
|
:title="getShiftByCell(emp.id!, vd.date)!.name"
|
:style="{
|
backgroundColor:
|
getShiftByCell(emp.id!, vd.date)!.color || '#1677ff',
|
}"
|
>
|
{{ getShiftByCell(emp.id!, vd.date)!.name }}
|
</span>
|
</td>
|
</tr>
|
</tbody>
|
</table>
|
</div>
|
|
<!-- 员工分页 -->
|
<div
|
v-if="loaded && employees.length"
|
class="mt-4 flex items-center justify-end rounded-lg bg-white p-3"
|
>
|
<Pagination
|
:current="employeePageNo"
|
:page-size="employeePageSize"
|
:total="employeeTotal"
|
show-size-changer
|
show-quick-jumper
|
:show-total="(total: number) => `共 ${total} 人`"
|
@change="handlePageChange"
|
/>
|
</div>
|
|
<!-- 班次选择弹窗 -->
|
<Modal
|
v-model:open="pickerOpen"
|
:title="pickerTitle"
|
:footer="null"
|
:width="440"
|
>
|
<div class="grid grid-cols-2 gap-3">
|
<button
|
v-for="shift in shiftList"
|
:key="shift.id"
|
type="button"
|
class="flex cursor-pointer flex-col items-center gap-1 rounded-lg border px-2 py-3 transition-colors hover:border-blue-500"
|
:class="[
|
isCurrentPick(shift.id!)
|
? 'border-blue-500 bg-blue-50'
|
: 'border-gray-200',
|
]"
|
@click="pickShift(shift.id!)"
|
>
|
<span class="inline-flex items-center gap-2 text-sm">
|
<span
|
class="inline-block h-3 w-3 rounded"
|
:style="{ backgroundColor: shift.color || '#1677ff' }"
|
/>
|
<span class="font-medium">{{ shift.name }}</span>
|
</span>
|
<span class="text-xs text-gray-400"
|
>{{ shift.startTime }} ~ {{ shift.endTime }}</span
|
>
|
</button>
|
</div>
|
<div class="mt-4 flex items-center justify-between">
|
<Button danger @click="pickShift(null)">清除排班(休息)</Button>
|
<Button @click="pickerOpen = false">取消</Button>
|
</div>
|
</Modal>
|
|
<!-- 自动排班弹窗 -->
|
<Modal v-model:open="autoOpen" title="自动排班" :footer="null" :width="440">
|
<div class="space-y-4">
|
<div>
|
<div class="mb-1.5 text-sm text-gray-500">排班班次</div>
|
<Select
|
v-model:value="autoShiftId"
|
:options="autoShiftOptions"
|
placeholder="请选择排班班次"
|
style="width: 100%"
|
/>
|
</div>
|
<div>
|
<div class="mb-1.5 text-sm text-gray-500">休息模式</div>
|
<Radio.Group
|
v-model:value="autoRestMode"
|
:options="REST_MODE_OPTIONS"
|
option-type="button"
|
/>
|
</div>
|
<div class="text-xs text-gray-400">
|
将按所选模式为{{
|
checkedEmployeeIds.size > 0
|
? checkedEmployeeIds.size
|
: employees.length
|
}}
|
名员工生成整月排班{{
|
checkedEmployeeIds.size > 0
|
? '(仅勾选的员工)'
|
: '(未勾选时为当前页全部员工)'
|
}},生成后可再手动调整。
|
</div>
|
<div class="flex justify-end gap-2">
|
<Button @click="autoOpen = false">取消</Button>
|
<Button type="primary" @click="applyAutoSchedule">生成排班</Button>
|
</div>
|
</div>
|
</Modal>
|
</Page>
|
</template>
|
|
<style lang="scss" scoped>
|
.schedule-table {
|
width: 100%;
|
min-width: max-content;
|
border-collapse: separate;
|
border-spacing: 0;
|
|
th,
|
td {
|
height: 44px;
|
border: 1px solid #f0f0f0;
|
}
|
|
.schedule-date-col {
|
width: 52px;
|
min-width: 52px;
|
}
|
|
.schedule-check-col,
|
.schedule-name-col {
|
position: sticky;
|
z-index: 10;
|
background-color: #fafafa;
|
}
|
|
.schedule-check-col {
|
left: 0;
|
width: 40px;
|
min-width: 40px;
|
padding: 0 8px;
|
text-align: center;
|
}
|
|
.schedule-name-col {
|
left: 40px;
|
min-width: 180px;
|
padding: 0 12px;
|
line-height: 1.3;
|
}
|
|
thead .schedule-check-col,
|
thead .schedule-name-col {
|
z-index: 20;
|
}
|
|
.schedule-cell {
|
padding: 2px;
|
cursor: pointer;
|
text-align: center;
|
|
&:hover {
|
background-color: #e6f4ff;
|
}
|
}
|
|
.shift-chip {
|
display: inline-block;
|
max-width: 100%;
|
padding: 1px 6px;
|
overflow: hidden;
|
color: #fff;
|
font-size: 11px;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
border-radius: 3px;
|
}
|
}
|
</style>
|