feat(mes):迁移【排班日历】

pull/348/head
YunaiV 2026-05-25 01:07:52 +08:00
parent d2763dc044
commit fab333fbb7
16 changed files with 1323 additions and 0 deletions

View File

@ -0,0 +1,135 @@
<script lang="ts" setup>
import type { MesCalCalendarApi } from '#/api/mes/cal/calendar';
import { computed } from 'vue';
import { Tag } from 'ant-design-vue';
import dayjs from 'dayjs';
import { SolarDay } from 'tyme4ts';
import { MesCalShiftTypeEnum } from '#/views/mes/utils/constants';
const props = defineProps<{
calendarDayMap: Map<string, MesCalCalendarApi.CalendarDay>; //
day: string; // yyyy-MM-dd
holidaySet: Set<string>; //
}>();
const dayNumber = computed(() => props.day.split('-')[2] || ''); // ""
const isHoliday = computed(() => props.holidaySet.has(props.day));
const isWeekend = computed(() => {
const weekday = dayjs(props.day).day();
return weekday === 0 || weekday === 6; // 0=6=
});
const calDay = computed(() => props.calendarDayMap.get(props.day));
const teamShifts = computed(() => calDay.value?.teamShifts || []);
const shiftType = computed(() => calDay.value?.shiftType);
/**
* 班次标签展示数据根据 sort shiftType 推导背景色
*
* 配色规则sort 对应轮班方式中的班次顺序
* sort=1 白班 绿色#95d475
* sort=2 中班 三班倒用橙色#f0a020两班倒用灰色#909399
* sort=3 夜班 灰色#909399
*/
const displayShifts = computed(() => {
const isThreeShift = shiftType.value === MesCalShiftTypeEnum.THREE;
const colorMap: Record<number, string> = {
1: 'bg-[#95d475]',
2: isThreeShift ? 'bg-[#f0a020]' : 'bg-[#909399]',
3: 'bg-[#909399]',
};
return teamShifts.value
.map((item) => {
const bgClass = colorMap[item.sort ?? -1];
if (!bgClass) {
return null;
}
return {
bgClass,
key: `${item.teamId}-${item.shiftId}`,
label: `${item.shiftName} · ${item.teamName}`,
};
})
.filter((v): v is { bgClass: string; key: string; label: string } => !!v);
});
/** 解析当天的农历、节气、节日信息 */
const lunarInfo = computed(() => {
const [year, month, date] = props.day.split('-').map(Number);
try {
const solarDay = SolarDay.fromYmd(year!, month!, date!);
const lunarDay = solarDay.getLunarDay();
const solarFestival = solarDay.getFestival(); //
const lunarFestival = lunarDay.getFestival(); //
const termDay = solarDay.getTermDay();
const termName =
termDay.getDayIndex() === 0 ? termDay.getSolarTerm().getName() : '';
const lunarMonthName = lunarDay.getLunarMonth().getName();
const lunarDayName = lunarDay.getName();
return {
lunarFestival: lunarFestival ? lunarFestival.getName() : '',
lunarText: lunarMonthName + lunarDayName,
solarFestival: solarFestival ? solarFestival.getName() : '',
termName,
};
} catch {
return {
lunarFestival: '',
lunarText: '',
solarFestival: '',
termName: '',
};
}
});
/** 优先级:公历节日 > 农历节日 > 节气 > 农历月日 */
const lunarDisplay = computed(() => {
const info = lunarInfo.value;
return (
info.solarFestival || info.lunarFestival || info.termName || info.lunarText
);
});
/** 当天是否有节日或节气(用于高亮显示农历文字) */
const hasFestivalDay = computed(() => {
const info = lunarInfo.value;
return Boolean(info.solarFestival || info.lunarFestival || info.termName);
});
</script>
<template>
<div class="flex h-full flex-col overflow-hidden p-1">
<!-- 顶部日期数字 + 上班/休息标签 -->
<div class="flex shrink-0 items-center justify-between">
<span class="text-base font-medium" :class="{ 'text-red-500': isWeekend }">
{{ dayNumber }}
</span>
<Tag v-if="isHoliday" color="green" class="!m-0"> </Tag>
<Tag v-else color="blue" class="!m-0"> </Tag>
</div>
<!-- 农历 / 节气 / 节日显示 -->
<div
class="mt-0.5 shrink-0 truncate text-[11px]"
:class="hasFestivalDay ? 'text-green-600' : 'text-muted-foreground'"
>
{{ lunarDisplay }}
</div>
<!--
班次列表节假日不显示排班
配色规则与背景类由 displayShifts 计算
-->
<div v-if="!isHoliday" class="mt-0.5 flex flex-col gap-px overflow-hidden">
<div
v-for="shift in displayShifts"
:key="shift.key"
class="block w-full truncate rounded-sm px-1 py-px text-[11px] leading-normal text-white"
:class="shift.bgClass"
>
{{ shift.label }}
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,38 @@
<script lang="ts" setup>
import { Tag } from 'ant-design-vue';
/** 配色说明项:色块 + 文案 */
const legendItems: Array<{ color: string; label: string }> = [
{ color: 'bg-[#95d475]', label: '白班' },
{ color: 'bg-[#f0a020]', label: '中班(三班倒)' },
{ color: 'bg-[#909399]', label: '中班(两班倒)/ 夜班' },
];
</script>
<template>
<div
class="text-muted-foreground flex flex-wrap items-center gap-x-4 gap-y-1 px-1 py-2 text-xs"
>
<span class="shrink-0">配色说明</span>
<span
v-for="item in legendItems"
:key="item.label"
class="flex items-center gap-1"
>
<span
class="inline-block h-2.5 w-2.5 shrink-0 rounded-sm"
:class="item.color"
></span>
{{ item.label }}
</span>
<span class="flex items-center gap-1">
<span class="inline-block h-2.5 w-2.5 shrink-0 rounded-sm bg-[#f56c6c] opacity-60"></span>
<span class="text-red-500">红色日期</span>
= 周末
</span>
<span class="flex items-center gap-1">
<Tag color="green" class="!m-0"> </Tag>
= 节假日不显示排班
</span>
</div>
</template>

View File

@ -0,0 +1,106 @@
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import type { MesCalCalendarApi } from '#/api/mes/cal/calendar';
import { Button, Calendar, Spin } from 'ant-design-vue';
import dayjs from 'dayjs';
import CalendarDateCell from './calendar-date-cell.vue';
import CalendarLegend from './calendar-legend.vue';
defineProps<{
calendarDayMap: Map<string, MesCalCalendarApi.CalendarDay>;
holidaySet: Set<string>;
loading?: boolean;
}>();
const currentDate = defineModel<Dayjs>('currentDate', { required: true });
/** 切换到上月 */
function handlePrevMonth() {
currentDate.value = currentDate.value.subtract(1, 'month');
}
/** 切换到下月 */
function handleNextMonth() {
currentDate.value = currentDate.value.add(1, 'month');
}
/** 切换到今天 */
function handleToday() {
currentDate.value = dayjs();
}
</script>
<template>
<div>
<CalendarLegend />
<Spin :spinning="loading" wrapper-class-name="block">
<div class="bg-card overflow-hidden rounded-md">
<Calendar v-model:value="currentDate" class="mes-calendar-panel">
<template #headerRender>
<div class="flex items-center justify-between p-3">
<div class="text-base font-medium">
{{ currentDate.format('YYYY 年 MM 月') }}
</div>
<div class="flex items-center gap-2">
<Button @click="handlePrevMonth"></Button>
<Button @click="handleToday"></Button>
<Button @click="handleNextMonth"></Button>
</div>
</div>
</template>
<template #dateFullCellRender="{ current: date }">
<div class="h-[110px] text-left">
<CalendarDateCell
v-if="date.isSame(currentDate, 'month')"
:calendar-day-map="calendarDayMap"
:day="date.format('YYYY-MM-DD')"
:holiday-set="holidaySet"
/>
<div v-else class="text-muted-foreground/50 p-1 text-base">
{{ date.format('DD') }}
</div>
</div>
</template>
</Calendar>
</div>
</Spin>
</div>
</template>
<!--
仅保留访问 Ant Design Calendar 内部 DOM 的样式
其余已通过 Tailwind 工具类实现
-->
<style lang="scss" scoped>
.mes-calendar-panel {
:deep(.ant-picker-content) {
border-top: 1px solid hsl(var(--border));
border-left: 1px solid hsl(var(--border));
th {
padding: 8px 12px;
text-align: left;
background: transparent;
border-right: 1px solid hsl(var(--border));
border-bottom: 1px solid hsl(var(--border));
}
td {
padding: 0;
border-right: 1px solid hsl(var(--border));
border-bottom: 1px solid hsl(var(--border));
}
}
:deep(.ant-picker-cell) {
padding: 0;
&::before {
display: none;
}
}
}
</style>

View File

@ -0,0 +1,99 @@
import type { Dayjs } from 'dayjs';
import type { MesCalCalendarApi } from '#/api/mes/cal/calendar';
import { ref, watch } from 'vue';
import dayjs from 'dayjs';
import { getCalendarList } from '#/api/mes/cal/calendar';
import { getHolidayList } from '#/api/mes/cal/holiday';
import { HolidayType } from '#/views/mes/utils/constants';
/**
* composable
*
*
*/
export function useCalendar() {
const loading = ref(false);
const currentDate = ref<Dayjs>(dayjs());
const calendarDayMap = ref<Map<string, MesCalCalendarApi.CalendarDay>>(
new Map(),
);
const holidaySet = ref(new Set<string>());
/** 计算当前月份的起止时间 */
function getMonthRange() {
const startDay = currentDate.value
.startOf('month')
.format('YYYY-MM-DD 00:00:00');
const endDay = currentDate.value
.endOf('month')
.format('YYYY-MM-DD 23:59:59');
return { endDay, startDay };
}
/**
*
*
* /
* 使
*/
async function loadHolidays() {
const { endDay, startDay } = getMonthRange();
const days = new Set<string>();
try {
const list = await getHolidayList({ endDay, startDay });
for (const item of list || []) {
const day = item.day ? dayjs(item.day).format('YYYY-MM-DD') : '';
if (day && item.type === HolidayType.HOLIDAY) {
days.add(day);
}
}
} catch {
// 没有 mes:cal-holiday:query 权限或接口异常时,仅忽略假期标记
}
holidaySet.value = days;
}
/** 查询排班日历params 由调用方提供 queryType 相关参数 */
async function fetchCalendar(params: Record<string, any>) {
loading.value = true;
try {
const { endDay, startDay } = getMonthRange();
const list = await getCalendarList({ ...params, endDay, startDay });
const map = new Map<string, MesCalCalendarApi.CalendarDay>();
for (const item of list || []) {
const day = item.day ? dayjs(item.day).format('YYYY-MM-DD') : '';
if (day) {
map.set(day, { ...item, day });
}
}
calendarDayMap.value = map;
} finally {
loading.value = false;
}
}
/** 监听月份切换,调用回调刷新数据 */
function watchMonth(callback: () => void) {
watch(
() => currentDate.value.format('YYYY-MM'),
() => {
void loadHolidays();
callback();
},
);
}
return {
calendarDayMap,
currentDate,
fetchCalendar,
holidaySet,
loadHolidays,
loading,
watchMonth,
};
}

View File

@ -0,0 +1,42 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { DocAlert, Page } from '@vben/common-ui';
import { Tabs } from 'ant-design-vue';
import dayjs from 'dayjs';
import TeamView from './modules/team-view.vue';
import TypeView from './modules/type-view.vue';
import UserView from './modules/user-view.vue';
import 'dayjs/locale/zh-cn';
dayjs.locale('zh-cn');
const activeTab = ref<string>('type');
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【排班】排班计划、排班日历"
url="https://doc.iocoder.cn/mes/cal/calendar/"
/>
</template>
<div class="bg-card rounded-md p-3">
<Tabs v-model:active-key="activeTab" type="card">
<Tabs.TabPane key="type" tab="按分类" force-render>
<TypeView />
</Tabs.TabPane>
<Tabs.TabPane key="team" tab="按班组" force-render>
<TeamView />
</Tabs.TabPane>
<Tabs.TabPane key="user" tab="按个人" force-render>
<UserView />
</Tabs.TabPane>
</Tabs>
</div>
</Page>
</template>

View File

@ -0,0 +1,84 @@
<script lang="ts" setup>
import type { MesCalTeamApi } from '#/api/mes/cal/team';
import { onMounted, ref } from 'vue';
import { getTeamList } from '#/api/mes/cal/team';
import CalendarPanel from '../components/calendar-panel.vue';
import { useCalendar } from '../components/use-calendar';
const {
calendarDayMap,
currentDate,
fetchCalendar,
holidaySet,
loadHolidays,
loading,
watchMonth,
} = useCalendar();
const teamList = ref<MesCalTeamApi.Team[]>([]);
const selectedTeamId = ref<number>();
/** 查询当前月份的排班日历,按选中班组过滤 */
function doFetch() {
if (!selectedTeamId.value) {
return;
}
fetchCalendar({ queryType: 'TEAM', teamId: selectedTeamId.value });
}
/** 点击左侧班组后切换并刷新日历 */
function onSelectTeam(id: number) {
selectedTeamId.value = id;
doFetch();
}
/** 监听月份切换,重新加载当月排班 */
watchMonth(() => {
if (selectedTeamId.value) {
doFetch();
}
});
onMounted(async () => {
//
void loadHolidays();
teamList.value = await getTeamList();
if (teamList.value.length > 0 && teamList.value[0]?.id) {
onSelectTeam(teamList.value[0].id);
}
});
</script>
<template>
<div class="flex">
<!-- 左侧班组列表选择 -->
<div class="border-border mr-3 w-[150px] shrink-0 overflow-hidden rounded border">
<div
v-for="team in teamList"
:key="team.id"
class="text-foreground border-border hover:bg-muted/50 cursor-pointer border-b px-4 py-2.5 text-sm transition-colors last:border-b-0"
:class="
selectedTeamId === team.id
? 'bg-primary/10 text-primary font-medium'
: ''
"
@click="onSelectTeam(team.id!)"
>
{{ team.name }}
</div>
</div>
<!-- 右侧日历 -->
<div class="flex-1">
<CalendarPanel
v-model:current-date="currentDate"
:calendar-day-map="calendarDayMap"
:holiday-set="holidaySet"
:loading="loading"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,85 @@
<script lang="ts" setup>
import type { DictDataType } from '@vben/hooks';
import { onMounted, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import CalendarPanel from '../components/calendar-panel.vue';
import { useCalendar } from '../components/use-calendar';
const {
calendarDayMap,
currentDate,
fetchCalendar,
holidaySet,
loadHolidays,
loading,
watchMonth,
} = useCalendar();
const typeOptions = ref<DictDataType[]>([]);
const selectedType = ref<number>();
/** 查询当前月份的排班日历,按选中分类过滤 */
function doFetch() {
if (selectedType.value === undefined) {
return;
}
fetchCalendar({ calendarType: selectedType.value, queryType: 'TYPE' });
}
/** 点击左侧分类后切换并刷新日历 */
function onSelectType(value: number) {
selectedType.value = value;
doFetch();
}
/** 监听月份切换,重新加载当月排班 */
watchMonth(() => {
if (selectedType.value !== undefined) {
doFetch();
}
});
onMounted(() => {
// /
void loadHolidays();
typeOptions.value = getDictOptions(DICT_TYPE.MES_CAL_CALENDAR_TYPE, 'number');
if (typeOptions.value.length > 0) {
onSelectType(typeOptions.value[0]!.value as number);
}
});
</script>
<template>
<div class="flex">
<!-- 左侧班组类型选择 -->
<div class="border-border mr-3 w-[150px] shrink-0 overflow-hidden rounded border">
<div
v-for="item in typeOptions"
:key="item.value as number"
class="text-foreground border-border hover:bg-muted/50 cursor-pointer border-b px-4 py-2.5 text-sm transition-colors last:border-b-0"
:class="
selectedType === item.value
? 'bg-primary/10 text-primary font-medium'
: ''
"
@click="onSelectType(item.value as number)"
>
{{ item.label }}
</div>
</div>
<!-- 右侧日历 -->
<div class="flex-1">
<CalendarPanel
v-model:current-date="currentDate"
:calendar-day-map="calendarDayMap"
:holiday-set="holidaySet"
:loading="loading"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,93 @@
<script lang="ts" setup>
import type { SystemUserApi } from '#/api/system/user';
import { onMounted, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Button, Form, FormItem, Select } from 'ant-design-vue';
import { getSimpleUserList } from '#/api/system/user';
import CalendarPanel from '../components/calendar-panel.vue';
import { useCalendar } from '../components/use-calendar';
const {
calendarDayMap,
currentDate,
fetchCalendar,
holidaySet,
loadHolidays,
loading,
watchMonth,
} = useCalendar();
const userId = ref<number>();
const userOptions = ref<SystemUserApi.User[]>([]);
/** 查询当前月份的排班日历,按选中人员过滤 */
function doFetch() {
if (!userId.value) {
return;
}
fetchCalendar({ queryType: 'USER', userId: userId.value });
}
/** 查询按钮 / 下拉选人后刷新日历 */
function onUserQuery() {
doFetch();
}
/** 监听月份切换,重新加载当月排班 */
watchMonth(() => {
if (userId.value) {
doFetch();
}
});
onMounted(async () => {
//
void loadHolidays();
userOptions.value = await getSimpleUserList();
});
</script>
<template>
<div>
<!-- 顶部人员选择 -->
<Form layout="inline" class="mb-2.5">
<FormItem label="人员">
<Select
v-model:value="userId"
allow-clear
show-search
placeholder="请输入人员姓名搜索"
class="!w-[200px]"
:options="userOptions"
:field-names="{ label: 'nickname', value: 'id' }"
:filter-option="
(input: string, option: any) =>
(option?.nickname ?? '').includes(input)
"
@change="onUserQuery"
/>
</FormItem>
<FormItem>
<Button type="primary" @click="onUserQuery">
<template #icon>
<IconifyIcon icon="lucide:search" />
</template>
查询
</Button>
</FormItem>
</Form>
<!-- 日历 -->
<CalendarPanel
v-model:current-date="currentDate"
:calendar-day-map="calendarDayMap"
:holiday-set="holidaySet"
:loading="loading"
/>
</div>
</template>

View File

@ -0,0 +1,135 @@
<script lang="ts" setup>
import type { MesCalCalendarApi } from '#/api/mes/cal/calendar';
import { computed } from 'vue';
import dayjs from 'dayjs';
import { ElTag } from 'element-plus';
import { SolarDay } from 'tyme4ts';
import { MesCalShiftTypeEnum } from '#/views/mes/utils/constants';
const props = defineProps<{
calendarDayMap: Map<string, MesCalCalendarApi.CalendarDay>; //
day: string; // yyyy-MM-dd
holidaySet: Set<string>; //
}>();
const dayNumber = computed(() => props.day.split('-')[2] || '');
const isHoliday = computed(() => props.holidaySet.has(props.day));
const isWeekend = computed(() => {
const weekday = dayjs(props.day).day();
return weekday === 0 || weekday === 6; // 0=6=
});
const calDay = computed(() => props.calendarDayMap.get(props.day));
const teamShifts = computed(() => calDay.value?.teamShifts || []);
const shiftType = computed(() => calDay.value?.shiftType);
/**
* 班次标签展示数据根据 sort shiftType 推导背景色
*
* 配色规则sort 对应轮班方式中的班次顺序
* sort=1 白班 绿色#95d475
* sort=2 中班 三班倒用橙色#f0a020两班倒用灰色#909399
* sort=3 夜班 灰色#909399
*/
const displayShifts = computed(() => {
const isThreeShift = shiftType.value === MesCalShiftTypeEnum.THREE;
const colorMap: Record<number, string> = {
1: 'bg-[#95d475]',
2: isThreeShift ? 'bg-[#f0a020]' : 'bg-[#909399]',
3: 'bg-[#909399]',
};
return teamShifts.value
.map((item) => {
const bgClass = colorMap[item.sort ?? -1];
if (!bgClass) {
return null;
}
return {
bgClass,
key: `${item.teamId}-${item.shiftId}`,
label: `${item.shiftName} · ${item.teamName}`,
};
})
.filter((v): v is { bgClass: string; key: string; label: string } => !!v);
});
/** 解析当天的农历、节气、节日信息 */
const lunarInfo = computed(() => {
const [year, month, date] = props.day.split('-').map(Number);
try {
const solarDay = SolarDay.fromYmd(year!, month!, date!);
const lunarDay = solarDay.getLunarDay();
const solarFestival = solarDay.getFestival();
const lunarFestival = lunarDay.getFestival();
const termDay = solarDay.getTermDay();
const termName =
termDay.getDayIndex() === 0 ? termDay.getSolarTerm().getName() : '';
const lunarMonthName = lunarDay.getLunarMonth().getName();
const lunarDayName = lunarDay.getName();
return {
lunarFestival: lunarFestival ? lunarFestival.getName() : '',
lunarText: lunarMonthName + lunarDayName,
solarFestival: solarFestival ? solarFestival.getName() : '',
termName,
};
} catch {
return {
lunarFestival: '',
lunarText: '',
solarFestival: '',
termName: '',
};
}
});
const lunarDisplay = computed(() => {
const info = lunarInfo.value;
return (
info.solarFestival || info.lunarFestival || info.termName || info.lunarText
);
});
const hasFestivalDay = computed(() => {
const info = lunarInfo.value;
return Boolean(info.solarFestival || info.lunarFestival || info.termName);
});
</script>
<template>
<div class="flex h-full flex-col overflow-hidden p-1">
<!-- 顶部日期数字 + 上班/休息标签 -->
<div class="flex shrink-0 items-center justify-between">
<span class="text-base font-medium" :class="{ 'text-red-500': isWeekend }">
{{ dayNumber }}
</span>
<ElTag v-if="isHoliday" effect="dark" size="small" type="success">
</ElTag>
<ElTag v-else effect="dark" size="small"></ElTag>
</div>
<!-- 农历 / 节气 / 节日显示 -->
<div
class="mt-0.5 shrink-0 truncate text-[11px]"
:class="hasFestivalDay ? 'text-green-600' : 'text-muted-foreground'"
>
{{ lunarDisplay }}
</div>
<!--
班次列表节假日不显示排班
配色规则与背景类由 displayShifts 计算
-->
<div v-if="!isHoliday" class="mt-0.5 flex flex-col gap-px overflow-hidden">
<div
v-for="shift in displayShifts"
:key="shift.key"
class="block w-full truncate rounded-sm px-1 py-px text-[11px] leading-normal text-white"
:class="shift.bgClass"
>
{{ shift.label }}
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,38 @@
<script lang="ts" setup>
import { ElTag } from 'element-plus';
/** 配色说明项:色块 + 文案 */
const legendItems: Array<{ color: string; label: string }> = [
{ color: 'bg-[#95d475]', label: '白班' },
{ color: 'bg-[#f0a020]', label: '中班(三班倒)' },
{ color: 'bg-[#909399]', label: '中班(两班倒)/ 夜班' },
];
</script>
<template>
<div
class="text-muted-foreground flex flex-wrap items-center gap-x-4 gap-y-1 px-1 py-2 text-xs"
>
<span class="shrink-0">配色说明</span>
<span
v-for="item in legendItems"
:key="item.label"
class="flex items-center gap-1"
>
<span
class="inline-block h-2.5 w-2.5 shrink-0 rounded-sm"
:class="item.color"
></span>
{{ item.label }}
</span>
<span class="flex items-center gap-1">
<span class="inline-block h-2.5 w-2.5 shrink-0 rounded-sm bg-[#f56c6c] opacity-60"></span>
<span class="text-red-500">红色日期</span>
= 周末
</span>
<span class="flex items-center gap-1">
<ElTag effect="dark" size="small" type="success"> </ElTag>
= 节假日不显示排班
</span>
</div>
</template>

View File

@ -0,0 +1,60 @@
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import type { MesCalCalendarApi } from '#/api/mes/cal/calendar';
import { computed } from 'vue';
import dayjs from 'dayjs';
import { ElCalendar } from 'element-plus';
import CalendarDateCell from './calendar-date-cell.vue';
import CalendarLegend from './calendar-legend.vue';
defineProps<{
calendarDayMap: Map<string, MesCalCalendarApi.CalendarDay>;
holidaySet: Set<string>;
loading?: boolean;
}>();
const currentDate = defineModel<Dayjs>('currentDate', { required: true });
/** ElCalendar v-model 桥接:内部用 Dayjs组件需要 Date */
const calendarValue = computed({
get: () => currentDate.value.toDate(),
set: (val: Date) => {
currentDate.value = dayjs(val);
},
});
</script>
<template>
<div>
<CalendarLegend />
<div v-loading="loading" class="bg-card overflow-hidden rounded-md">
<ElCalendar v-model="calendarValue" class="mes-calendar-panel">
<template #date-cell="{ data }">
<CalendarDateCell
v-if="data.type === 'current-month'"
:calendar-day-map="calendarDayMap"
:day="data.day"
:holiday-set="holidaySet"
/>
<div v-else class="text-muted-foreground/50 p-1 text-base">
{{ data.day.split('-')[2] }}
</div>
</template>
</ElCalendar>
</div>
</div>
</template>
<style lang="scss" scoped>
/* 收紧 ElCalendar 默认日期单元高度,使用自定义单元 */
.mes-calendar-panel {
:deep(.el-calendar-table .el-calendar-day) {
height: 110px;
padding: 0;
}
}
</style>

View File

@ -0,0 +1,99 @@
import type { Dayjs } from 'dayjs';
import type { MesCalCalendarApi } from '#/api/mes/cal/calendar';
import { ref, watch } from 'vue';
import dayjs from 'dayjs';
import { getCalendarList } from '#/api/mes/cal/calendar';
import { getHolidayList } from '#/api/mes/cal/holiday';
import { HolidayType } from '#/views/mes/utils/constants';
/**
* composable
*
*
*/
export function useCalendar() {
const loading = ref(false);
const currentDate = ref<Dayjs>(dayjs());
const calendarDayMap = ref<Map<string, MesCalCalendarApi.CalendarDay>>(
new Map(),
);
const holidaySet = ref(new Set<string>());
/** 计算当前月份的起止时间 */
function getMonthRange() {
const startDay = currentDate.value
.startOf('month')
.format('YYYY-MM-DD 00:00:00');
const endDay = currentDate.value
.endOf('month')
.format('YYYY-MM-DD 23:59:59');
return { endDay, startDay };
}
/**
*
*
* /
* 使
*/
async function loadHolidays() {
const { endDay, startDay } = getMonthRange();
const days = new Set<string>();
try {
const list = await getHolidayList({ endDay, startDay });
for (const item of list || []) {
const day = item.day ? dayjs(item.day).format('YYYY-MM-DD') : '';
if (day && item.type === HolidayType.HOLIDAY) {
days.add(day);
}
}
} catch {
// 没有 mes:cal-holiday:query 权限或接口异常时,仅忽略假期标记
}
holidaySet.value = days;
}
/** 查询排班日历params 由调用方提供 queryType 相关参数 */
async function fetchCalendar(params: Record<string, any>) {
loading.value = true;
try {
const { endDay, startDay } = getMonthRange();
const list = await getCalendarList({ ...params, endDay, startDay });
const map = new Map<string, MesCalCalendarApi.CalendarDay>();
for (const item of list || []) {
const day = item.day ? dayjs(item.day).format('YYYY-MM-DD') : '';
if (day) {
map.set(day, { ...item, day });
}
}
calendarDayMap.value = map;
} finally {
loading.value = false;
}
}
/** 监听月份切换,调用回调刷新数据 */
function watchMonth(callback: () => void) {
watch(
() => currentDate.value.format('YYYY-MM'),
() => {
void loadHolidays();
callback();
},
);
}
return {
calendarDayMap,
currentDate,
fetchCalendar,
holidaySet,
loadHolidays,
loading,
watchMonth,
};
}

View File

@ -0,0 +1,42 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { DocAlert, Page } from '@vben/common-ui';
import dayjs from 'dayjs';
import { ElTabPane, ElTabs } from 'element-plus';
import TeamView from './modules/team-view.vue';
import TypeView from './modules/type-view.vue';
import UserView from './modules/user-view.vue';
import 'dayjs/locale/zh-cn';
dayjs.locale('zh-cn');
const activeTab = ref<string>('type');
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【排班】排班计划、排班日历"
url="https://doc.iocoder.cn/mes/cal/calendar/"
/>
</template>
<div class="bg-card rounded-md p-3">
<ElTabs v-model="activeTab" type="border-card">
<ElTabPane label="按分类" name="type" :force-render="true">
<TypeView />
</ElTabPane>
<ElTabPane label="按班组" name="team" :force-render="true">
<TeamView />
</ElTabPane>
<ElTabPane label="按个人" name="user" :force-render="true">
<UserView />
</ElTabPane>
</ElTabs>
</div>
</Page>
</template>

View File

@ -0,0 +1,84 @@
<script lang="ts" setup>
import type { MesCalTeamApi } from '#/api/mes/cal/team';
import { onMounted, ref } from 'vue';
import { getTeamList } from '#/api/mes/cal/team';
import CalendarPanel from '../components/calendar-panel.vue';
import { useCalendar } from '../components/use-calendar';
const {
calendarDayMap,
currentDate,
fetchCalendar,
holidaySet,
loadHolidays,
loading,
watchMonth,
} = useCalendar();
const teamList = ref<MesCalTeamApi.Team[]>([]);
const selectedTeamId = ref<number>();
/** 查询当前月份的排班日历,按选中班组过滤 */
function doFetch() {
if (!selectedTeamId.value) {
return;
}
fetchCalendar({ queryType: 'TEAM', teamId: selectedTeamId.value });
}
/** 点击左侧班组后切换并刷新日历 */
function onSelectTeam(id: number) {
selectedTeamId.value = id;
doFetch();
}
/** 监听月份切换,重新加载当月排班 */
watchMonth(() => {
if (selectedTeamId.value) {
doFetch();
}
});
onMounted(async () => {
//
void loadHolidays();
teamList.value = await getTeamList();
if (teamList.value.length > 0 && teamList.value[0]?.id) {
onSelectTeam(teamList.value[0].id);
}
});
</script>
<template>
<div class="flex">
<!-- 左侧班组列表选择 -->
<div class="border-border mr-3 w-[150px] shrink-0 overflow-hidden rounded border">
<div
v-for="team in teamList"
:key="team.id"
class="text-foreground border-border hover:bg-muted/50 cursor-pointer border-b px-4 py-2.5 text-sm transition-colors last:border-b-0"
:class="
selectedTeamId === team.id
? 'bg-primary/10 text-primary font-medium'
: ''
"
@click="onSelectTeam(team.id!)"
>
{{ team.name }}
</div>
</div>
<!-- 右侧日历 -->
<div class="flex-1">
<CalendarPanel
v-model:current-date="currentDate"
:calendar-day-map="calendarDayMap"
:holiday-set="holidaySet"
:loading="loading"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,85 @@
<script lang="ts" setup>
import type { DictDataType } from '@vben/hooks';
import { onMounted, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import CalendarPanel from '../components/calendar-panel.vue';
import { useCalendar } from '../components/use-calendar';
const {
calendarDayMap,
currentDate,
fetchCalendar,
holidaySet,
loadHolidays,
loading,
watchMonth,
} = useCalendar();
const typeOptions = ref<DictDataType[]>([]);
const selectedType = ref<number>();
/** 查询当前月份的排班日历,按选中分类过滤 */
function doFetch() {
if (selectedType.value === undefined) {
return;
}
fetchCalendar({ calendarType: selectedType.value, queryType: 'TYPE' });
}
/** 点击左侧分类后切换并刷新日历 */
function onSelectType(value: number) {
selectedType.value = value;
doFetch();
}
/** 监听月份切换,重新加载当月排班 */
watchMonth(() => {
if (selectedType.value !== undefined) {
doFetch();
}
});
onMounted(() => {
// /
void loadHolidays();
typeOptions.value = getDictOptions(DICT_TYPE.MES_CAL_CALENDAR_TYPE, 'number');
if (typeOptions.value.length > 0) {
onSelectType(typeOptions.value[0]!.value as number);
}
});
</script>
<template>
<div class="flex">
<!-- 左侧班组类型选择 -->
<div class="border-border mr-3 w-[150px] shrink-0 overflow-hidden rounded border">
<div
v-for="item in typeOptions"
:key="item.value as number"
class="text-foreground border-border hover:bg-muted/50 cursor-pointer border-b px-4 py-2.5 text-sm transition-colors last:border-b-0"
:class="
selectedType === item.value
? 'bg-primary/10 text-primary font-medium'
: ''
"
@click="onSelectType(item.value as number)"
>
{{ item.label }}
</div>
</div>
<!-- 右侧日历 -->
<div class="flex-1">
<CalendarPanel
v-model:current-date="currentDate"
:calendar-day-map="calendarDayMap"
:holiday-set="holidaySet"
:loading="loading"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,98 @@
<script lang="ts" setup>
import type { SystemUserApi } from '#/api/system/user';
import { onMounted, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import {
ElButton,
ElForm,
ElFormItem,
ElOption,
ElSelect,
} from 'element-plus';
import { getSimpleUserList } from '#/api/system/user';
import CalendarPanel from '../components/calendar-panel.vue';
import { useCalendar } from '../components/use-calendar';
const {
calendarDayMap,
currentDate,
fetchCalendar,
holidaySet,
loadHolidays,
loading,
watchMonth,
} = useCalendar();
const userId = ref<number>();
const userOptions = ref<SystemUserApi.User[]>([]);
/** 查询当前月份的排班日历,按选中人员过滤 */
function doFetch() {
if (!userId.value) {
return;
}
fetchCalendar({ queryType: 'USER', userId: userId.value });
}
/** 查询按钮 / 下拉选人后刷新日历 */
function onUserQuery() {
doFetch();
}
/** 监听月份切换,重新加载当月排班 */
watchMonth(() => {
if (userId.value) {
doFetch();
}
});
onMounted(async () => {
//
void loadHolidays();
userOptions.value = await getSimpleUserList();
});
</script>
<template>
<div>
<!-- 顶部人员选择 -->
<ElForm :inline="true" class="mb-2.5">
<ElFormItem label="人员">
<ElSelect
v-model="userId"
clearable
filterable
placeholder="请输入人员姓名搜索"
class="!w-[200px]"
@change="onUserQuery"
>
<ElOption
v-for="user in userOptions"
:key="user.id"
:label="user.nickname"
:value="user.id!"
/>
</ElSelect>
</ElFormItem>
<ElFormItem>
<ElButton type="primary" @click="onUserQuery">
<IconifyIcon icon="lucide:search" class="mr-1" />
查询
</ElButton>
</ElFormItem>
</ElForm>
<!-- 日历 -->
<CalendarPanel
v-model:current-date="currentDate"
:calendar-day-map="calendarDayMap"
:holiday-set="holidaySet"
:loading="loading"
/>
</div>
</template>