418 lines
11 KiB
Vue
418 lines
11 KiB
Vue
|
|
<template>
|
||
|
|
<div class="calendar-view">
|
||
|
|
<div class="page-header">
|
||
|
|
<div class="header-left">
|
||
|
|
<h2>日历视图</h2>
|
||
|
|
<a-radio-group v-model:value="viewType" button-style="solid" @change="handleViewChange">
|
||
|
|
<a-radio-button value="dayGridMonth">月</a-radio-button>
|
||
|
|
<a-radio-button value="timeGridWeek">周</a-radio-button>
|
||
|
|
<a-radio-button value="timeGridDay">日</a-radio-button>
|
||
|
|
</a-radio-group>
|
||
|
|
</div>
|
||
|
|
<a-space>
|
||
|
|
<a-button @click="router.push('/school/schedule')">
|
||
|
|
<template #icon><PlusOutlined /></template>
|
||
|
|
新建排课
|
||
|
|
</a-button>
|
||
|
|
<a-button @click="router.push('/school/schedule')">
|
||
|
|
<template #icon><CopyOutlined /></template>
|
||
|
|
排课模板
|
||
|
|
</a-button>
|
||
|
|
<a-dropdown>
|
||
|
|
<a-button>
|
||
|
|
<template #icon><CalendarOutlined /></template>
|
||
|
|
视图切换
|
||
|
|
<DownOutlined />
|
||
|
|
</a-button>
|
||
|
|
<template #overlay>
|
||
|
|
<a-menu @click="handleSwitchView">
|
||
|
|
<a-menu-item key="list">
|
||
|
|
<UnorderedListOutlined /> 列表视图
|
||
|
|
</a-menu-item>
|
||
|
|
<a-menu-item key="timetable">
|
||
|
|
<TableOutlined /> 课表视图
|
||
|
|
</a-menu-item>
|
||
|
|
<a-menu-item key="calendar">
|
||
|
|
<CalendarOutlined /> 日历视图
|
||
|
|
</a-menu-item>
|
||
|
|
</a-menu>
|
||
|
|
</template>
|
||
|
|
</a-dropdown>
|
||
|
|
<a-divider type="vertical" />
|
||
|
|
<a-select
|
||
|
|
v-model:value="selectedClassId"
|
||
|
|
placeholder="筛选班级"
|
||
|
|
allowClear
|
||
|
|
style="width: 150px;"
|
||
|
|
@change="loadEvents"
|
||
|
|
>
|
||
|
|
<a-select-option v-for="cls in classes" :key="cls.id" :value="cls.id">
|
||
|
|
{{ cls.name }}
|
||
|
|
</a-select-option>
|
||
|
|
</a-select>
|
||
|
|
<a-select
|
||
|
|
v-model:value="selectedTeacherId"
|
||
|
|
placeholder="筛选教师"
|
||
|
|
allowClear
|
||
|
|
style="width: 150px;"
|
||
|
|
@change="loadEvents"
|
||
|
|
>
|
||
|
|
<a-select-option v-for="teacher in teachers" :key="teacher.id" :value="teacher.id">
|
||
|
|
{{ teacher.name }}
|
||
|
|
</a-select-option>
|
||
|
|
</a-select>
|
||
|
|
</a-space>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="calendar-container">
|
||
|
|
<FullCalendar
|
||
|
|
ref="calendarRef"
|
||
|
|
:options="calendarOptions"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- 编辑排课弹窗 -->
|
||
|
|
<a-modal
|
||
|
|
v-model:open="editModalVisible"
|
||
|
|
title="编辑排课"
|
||
|
|
@ok="handleSaveEdit"
|
||
|
|
@cancel="editModalVisible = false"
|
||
|
|
>
|
||
|
|
<a-form :model="editForm" layout="vertical">
|
||
|
|
<a-form-item label="排课日期">
|
||
|
|
<a-date-picker v-model:value="editForm.scheduledDate" style="width: 100%;" />
|
||
|
|
</a-form-item>
|
||
|
|
<a-form-item label="时间段">
|
||
|
|
<a-input v-model:value="editForm.scheduledTime" placeholder="如: 09:00-09:30" />
|
||
|
|
</a-form-item>
|
||
|
|
<a-form-item label="备注">
|
||
|
|
<a-textarea v-model:value="editForm.note" :rows="3" />
|
||
|
|
</a-form-item>
|
||
|
|
</a-form>
|
||
|
|
</a-modal>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<script setup lang="ts">
|
||
|
|
import { ref, reactive, onMounted, computed } from 'vue';
|
||
|
|
import { useRouter } from 'vue-router';
|
||
|
|
import { message } from 'ant-design-vue';
|
||
|
|
import FullCalendar from '@fullcalendar/vue3';
|
||
|
|
import dayGridPlugin from '@fullcalendar/daygrid';
|
||
|
|
import timeGridPlugin from '@fullcalendar/timegrid';
|
||
|
|
import interactionPlugin from '@fullcalendar/interaction';
|
||
|
|
import type { EventClickArg, EventDropArg } from '@fullcalendar/core';
|
||
|
|
import dayjs from 'dayjs';
|
||
|
|
import { PlusOutlined, CopyOutlined, CalendarOutlined, DownOutlined, UnorderedListOutlined, TableOutlined } from '@ant-design/icons-vue';
|
||
|
|
import {
|
||
|
|
getTimetable,
|
||
|
|
updateSchedule,
|
||
|
|
getClasses,
|
||
|
|
getTeachers,
|
||
|
|
} from '@/api/school';
|
||
|
|
import type { SchedulePlan, ClassInfo } from '@/api/school';
|
||
|
|
|
||
|
|
const router = useRouter();
|
||
|
|
|
||
|
|
const calendarRef = ref();
|
||
|
|
const viewType = ref('timeGridWeek');
|
||
|
|
const selectedClassId = ref<number | undefined>();
|
||
|
|
const selectedTeacherId = ref<number | undefined>();
|
||
|
|
const classes = ref<ClassInfo[]>([]);
|
||
|
|
const teachers = ref<any[]>([]);
|
||
|
|
const schedules = ref<SchedulePlan[]>([]);
|
||
|
|
|
||
|
|
// 编辑弹窗
|
||
|
|
const editModalVisible = ref(false);
|
||
|
|
const editForm = reactive({
|
||
|
|
id: 0,
|
||
|
|
scheduledDate: dayjs(),
|
||
|
|
scheduledTime: '',
|
||
|
|
note: '',
|
||
|
|
});
|
||
|
|
|
||
|
|
// 日历配置
|
||
|
|
const calendarOptions = computed(() => ({
|
||
|
|
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
||
|
|
initialView: viewType.value,
|
||
|
|
headerToolbar: {
|
||
|
|
left: 'prev,next today',
|
||
|
|
center: 'title',
|
||
|
|
right: '',
|
||
|
|
},
|
||
|
|
locale: 'zh-cn',
|
||
|
|
firstDay: 1,
|
||
|
|
slotMinTime: '07:00:00',
|
||
|
|
slotMaxTime: '19:00:00',
|
||
|
|
allDaySlot: false,
|
||
|
|
editable: true,
|
||
|
|
selectable: true,
|
||
|
|
selectMirror: true,
|
||
|
|
dayMaxEvents: true,
|
||
|
|
weekends: true,
|
||
|
|
events: calendarEvents.value,
|
||
|
|
eventClick: handleEventClick,
|
||
|
|
eventDrop: handleEventDrop,
|
||
|
|
eventResize: handleEventResize,
|
||
|
|
datesSet: handleDatesSet,
|
||
|
|
}));
|
||
|
|
|
||
|
|
const calendarEvents = computed(() => {
|
||
|
|
return schedules.value.map((schedule) => {
|
||
|
|
// 解析时间 "09:00-09:30"
|
||
|
|
const [startTime, endTime] = (schedule.scheduledTime || '09:00-09:30').split('-');
|
||
|
|
const date = schedule.scheduledDate
|
||
|
|
? dayjs(schedule.scheduledDate).format('YYYY-MM-DD')
|
||
|
|
: dayjs().format('YYYY-MM-DD');
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: String(schedule.id),
|
||
|
|
title: `${schedule.courseName} - ${schedule.className}`,
|
||
|
|
start: `${date}T${startTime || '09:00'}:00`,
|
||
|
|
end: `${date}T${endTime || '09:30'}:00`,
|
||
|
|
extendedProps: {
|
||
|
|
schedule,
|
||
|
|
},
|
||
|
|
backgroundColor: schedule.teacherName ? '#FF8C42' : '#1890ff',
|
||
|
|
borderColor: schedule.teacherName ? '#E67635' : '#096dd9',
|
||
|
|
};
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// 当前显示的日期范围
|
||
|
|
const currentDateRange = ref({ start: '', end: '' });
|
||
|
|
|
||
|
|
const handleDatesSet = (arg: any) => {
|
||
|
|
currentDateRange.value = {
|
||
|
|
start: dayjs(arg.start).format('YYYY-MM-DD'),
|
||
|
|
end: dayjs(arg.end).format('YYYY-MM-DD'),
|
||
|
|
};
|
||
|
|
loadEvents();
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleViewChange = () => {
|
||
|
|
const calendarApi = calendarRef.value?.getApi();
|
||
|
|
if (calendarApi) {
|
||
|
|
calendarApi.changeView(viewType.value);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 切换到其他视图
|
||
|
|
const handleSwitchView = (e: any) => {
|
||
|
|
const key = e.key;
|
||
|
|
if (key === 'list') {
|
||
|
|
router.push('/school/schedule');
|
||
|
|
} else if (key === 'timetable') {
|
||
|
|
router.push('/school/schedule/timetable');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const loadEvents = async () => {
|
||
|
|
try {
|
||
|
|
const { start, end } = currentDateRange.value;
|
||
|
|
if (!start || !end) return;
|
||
|
|
|
||
|
|
const params: any = {
|
||
|
|
startDate: start,
|
||
|
|
endDate: end,
|
||
|
|
};
|
||
|
|
|
||
|
|
if (selectedClassId.value) {
|
||
|
|
params.classId = selectedClassId.value;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (selectedTeacherId.value) {
|
||
|
|
params.teacherId = selectedTeacherId.value;
|
||
|
|
}
|
||
|
|
|
||
|
|
const data = await getTimetable(params);
|
||
|
|
|
||
|
|
// 扁平化所有排课
|
||
|
|
const allSchedules: SchedulePlan[] = [];
|
||
|
|
data.forEach((item) => {
|
||
|
|
allSchedules.push(...item.schedules);
|
||
|
|
});
|
||
|
|
|
||
|
|
schedules.value = allSchedules;
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Failed to load events:', error);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const loadClasses = async () => {
|
||
|
|
try {
|
||
|
|
classes.value = await getClasses();
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Failed to load classes:', error);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const loadTeachers = async () => {
|
||
|
|
try {
|
||
|
|
const data = await getTeachers({});
|
||
|
|
teachers.value = data.items || [];
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Failed to load teachers:', error);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 点击事件
|
||
|
|
const handleEventClick = (arg: EventClickArg) => {
|
||
|
|
const schedule = arg.event.extendedProps.schedule as SchedulePlan;
|
||
|
|
if (!schedule) return;
|
||
|
|
|
||
|
|
editForm.id = schedule.id;
|
||
|
|
editForm.scheduledDate = dayjs(schedule.scheduledDate);
|
||
|
|
editForm.scheduledTime = schedule.scheduledTime || '';
|
||
|
|
editForm.note = schedule.note || '';
|
||
|
|
editModalVisible.value = true;
|
||
|
|
};
|
||
|
|
|
||
|
|
// 拖拽事件
|
||
|
|
const handleEventDrop = async (arg: EventDropArg) => {
|
||
|
|
const schedule = arg.event.extendedProps.schedule as SchedulePlan;
|
||
|
|
if (!schedule) return;
|
||
|
|
|
||
|
|
const newDate = dayjs(arg.event.start).format('YYYY-MM-DD');
|
||
|
|
const newTime = arg.event.start && arg.event.end
|
||
|
|
? `${dayjs(arg.event.start).format('HH:mm')}-${dayjs(arg.event.end).format('HH:mm')}`
|
||
|
|
: schedule.scheduledTime;
|
||
|
|
|
||
|
|
try {
|
||
|
|
await updateSchedule(schedule.id, {
|
||
|
|
scheduledDate: newDate,
|
||
|
|
scheduledTime: newTime,
|
||
|
|
});
|
||
|
|
message.success('调整成功');
|
||
|
|
await loadEvents();
|
||
|
|
} catch (error) {
|
||
|
|
message.error('调整失败');
|
||
|
|
arg.revert();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 调整大小
|
||
|
|
const handleEventResize = async (arg: any) => {
|
||
|
|
const schedule = arg.event.extendedProps.schedule as SchedulePlan;
|
||
|
|
if (!schedule) return;
|
||
|
|
|
||
|
|
const newTime = arg.event.start && arg.event.end
|
||
|
|
? `${dayjs(arg.event.start).format('HH:mm')}-${dayjs(arg.event.end).format('HH:mm')}`
|
||
|
|
: schedule.scheduledTime;
|
||
|
|
|
||
|
|
try {
|
||
|
|
await updateSchedule(schedule.id, {
|
||
|
|
scheduledTime: newTime,
|
||
|
|
});
|
||
|
|
message.success('调整成功');
|
||
|
|
await loadEvents();
|
||
|
|
} catch (error) {
|
||
|
|
message.error('调整失败');
|
||
|
|
arg.revert();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 保存编辑
|
||
|
|
const handleSaveEdit = async () => {
|
||
|
|
try {
|
||
|
|
await updateSchedule(editForm.id, {
|
||
|
|
scheduledDate: editForm.scheduledDate.format('YYYY-MM-DD'),
|
||
|
|
scheduledTime: editForm.scheduledTime,
|
||
|
|
note: editForm.note,
|
||
|
|
});
|
||
|
|
message.success('保存成功');
|
||
|
|
editModalVisible.value = false;
|
||
|
|
await loadEvents();
|
||
|
|
} catch (error) {
|
||
|
|
message.error('保存失败');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
onMounted(() => {
|
||
|
|
loadClasses();
|
||
|
|
loadTeachers();
|
||
|
|
});
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<style scoped>
|
||
|
|
.calendar-view {
|
||
|
|
background: white;
|
||
|
|
border-radius: 16px;
|
||
|
|
padding: 24px;
|
||
|
|
min-height: calc(100vh - 200px);
|
||
|
|
}
|
||
|
|
|
||
|
|
.page-header {
|
||
|
|
display: flex;
|
||
|
|
justify-content: space-between;
|
||
|
|
align-items: center;
|
||
|
|
margin-bottom: 24px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.header-left {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 20px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.header-left h2 {
|
||
|
|
margin: 0;
|
||
|
|
font-size: 20px;
|
||
|
|
font-weight: 600;
|
||
|
|
color: #2D3436;
|
||
|
|
}
|
||
|
|
|
||
|
|
.calendar-container {
|
||
|
|
min-height: 600px;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* FullCalendar 样式覆盖 */
|
||
|
|
:deep(.fc) {
|
||
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||
|
|
}
|
||
|
|
|
||
|
|
:deep(.fc-toolbar-title) {
|
||
|
|
font-size: 18px !important;
|
||
|
|
font-weight: 600 !important;
|
||
|
|
}
|
||
|
|
|
||
|
|
:deep(.fc-button) {
|
||
|
|
background: #FF8C42 !important;
|
||
|
|
border-color: #FF8C42 !important;
|
||
|
|
}
|
||
|
|
|
||
|
|
:deep(.fc-button:hover) {
|
||
|
|
background: #E67635 !important;
|
||
|
|
border-color: #E67635 !important;
|
||
|
|
}
|
||
|
|
|
||
|
|
:deep(.fc-button-active) {
|
||
|
|
background: #E67635 !important;
|
||
|
|
border-color: #E67635 !important;
|
||
|
|
}
|
||
|
|
|
||
|
|
:deep(.fc-event) {
|
||
|
|
cursor: pointer;
|
||
|
|
border-radius: 4px;
|
||
|
|
padding: 2px 4px;
|
||
|
|
}
|
||
|
|
|
||
|
|
:deep(.fc-daygrid-event) {
|
||
|
|
white-space: nowrap;
|
||
|
|
overflow: hidden;
|
||
|
|
text-overflow: ellipsis;
|
||
|
|
}
|
||
|
|
|
||
|
|
:deep(.fc-timegrid-event) {
|
||
|
|
font-size: 12px;
|
||
|
|
}
|
||
|
|
|
||
|
|
:deep(.fc-col-header-cell) {
|
||
|
|
background: #FFF8F0;
|
||
|
|
}
|
||
|
|
|
||
|
|
:deep(.fc .fc-daygrid-day.fc-day-today) {
|
||
|
|
background-color: #FFF0E6;
|
||
|
|
}
|
||
|
|
</style>
|