kindergarten_java/reading-platform-frontend/src/views/school/schedule/CalendarView.vue
zhonghua 829a70e448 feat: 学校端课程排期功能完善
- 排课计划参考:对齐管理端课程包详情,支持时间/课程类型/课程名称/区域活动/备注五列
- 支持两种 schedule_ref_data 格式(周排课表、课程类型说明)
- 新建排课弹窗样式提取为 CreateScheduleModal.scss 修复 SASS 编译错误
- 切换视图(列表/课表/日历)时自动刷新数据
- 排课列表、课表、日历视图增加课程类型 tag 展示
- 后端:timetable/lesson-types 接口修复,LessonTypeEnum 补充类型

Made-with: Cursor
2026-03-19 16:34:48 +08:00

604 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="calendar-view">
<div class="page-header">
<a-space>
<a-radio-group v-model:value="viewType" button-style="solid" size="small">
<a-radio-button value="month"></a-radio-button>
<a-radio-button value="week"></a-radio-button>
</a-radio-group>
<a-divider type="vertical" />
<a-button @click="prevNav">
<template #icon><LeftOutlined /></template>
</a-button>
<span class="current-range-label">{{ currentRangeLabel }}</span>
<a-button @click="nextNav">
<template #icon><RightOutlined /></template>
</a-button>
</a-space>
<a-space>
<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 v-if="viewType === 'month'" class="calendar-month">
<div class="weekday-header">
<div v-for="day in weekdayNames" :key="day" class="weekday-item">{{ day }}</div>
</div>
<div class="calendar-grid">
<div
v-for="date in calendarDays"
:key="date.key"
:class="['calendar-day', { today: date.isToday, 'other-month': date.isOtherMonth }]"
@click="handleDayClick(date)"
>
<div class="day-number">{{ date.dayNumber }}</div>
<div v-if="date.hasSchedule" class="schedule-indicator"></div>
<div v-if="date.scheduleCount > 0" class="schedule-count">{{ date.scheduleCount }}</div>
</div>
</div>
</div>
<!-- 周视图 -->
<div v-else class="calendar-week">
<div class="week-grid">
<div
v-for="day in weekDaysData"
:key="day.date"
class="week-day"
>
<div class="week-day-header" :class="{ 'is-today': day.isToday }">
<div class="weekday">{{ day.weekday }}</div>
<div class="day-number">{{ day.dayNumber }}</div>
</div>
<div class="week-day-content">
<div
v-for="item in day.schedules"
:key="item.id"
class="schedule-item"
@click="showScheduleDetail(item)"
>
<div class="schedule-time">{{ item.scheduledTime }}</div>
<div class="schedule-info">
<div class="schedule-class">{{ item.className }}</div>
<div class="schedule-lesson">{{ item.coursePackageName || item.courseName }}</div>
<a-tag v-if="item.lessonType" size="small" class="schedule-lesson-type"
:style="getLessonTagStyle(item.lessonType)">{{ getLessonTypeName(item.lessonType) }}</a-tag>
</div>
</div>
<div v-if="day.schedules.length === 0" class="no-schedule">无排课</div>
</div>
</div>
</div>
</div>
<!-- 日排课详情弹窗 -->
<a-modal
v-model:open="dayDetailVisible"
:title="selectedDateLabel"
@cancel="dayDetailVisible = false"
:footer="null"
width="500px"
>
<div class="day-schedules">
<div
v-for="item in selectedDaySchedules"
:key="item.id"
class="day-schedule-item"
>
<div class="item-time">{{ item.scheduledTime || '待定' }}</div>
<div class="item-info">
<div class="item-class">{{ item.className }}</div>
<div class="item-lesson">{{ item.coursePackageName || item.courseName }}</div>
<a-tag v-if="item.lessonType" size="small" :style="getLessonTagStyle(item.lessonType)">
{{ getLessonTypeName(item.lessonType) }}
</a-tag>
<div class="item-teacher">{{ item.teacherName || '未分配' }}</div>
</div>
<div class="item-status">
<a-tag :color="item.status === 'ACTIVE' || item.status === 'scheduled' ? 'success' : 'default'">
{{ item.status === 'ACTIVE' || item.status === 'scheduled' ? '有效' : '已取消' }}
</a-tag>
</div>
</div>
<a-empty v-if="selectedDaySchedules.length === 0" description="当日无排课" />
</div>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { LeftOutlined, RightOutlined } from '@ant-design/icons-vue';
import {
getCalendarViewData,
getClasses,
getTeachers,
type ClassInfo,
type CalendarViewResponse,
type DayScheduleItem,
} from '@/api/school';
import { getLessonTypeName, getLessonTagStyle } from '@/utils/tagMaps';
const viewType = ref<'month' | 'week'>('month');
const selectedClassId = ref<number | undefined>();
const selectedTeacherId = ref<number | undefined>();
const classes = ref<ClassInfo[]>([]);
const teachers = ref<any[]>([]);
// 当前日期范围
const currentStartDate = ref(dayjs().startOf('month'));
const currentEndDate = ref(dayjs().endOf('month'));
// 日历数据
const calendarData = ref<CalendarViewResponse | null>(null);
// 选中日期详情
const dayDetailVisible = ref(false);
const selectedDate = ref<string>('');
const selectedDaySchedules = ref<DayScheduleItem[]>([]);
// 星期标题
const weekdayNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
// 计算属性:当前范围标签
const currentRangeLabel = computed(() => {
if (viewType.value === 'month') {
return currentStartDate.value.format('YYYY年MM月');
} else {
const start = currentStartDate.value.format('MM-DD');
const end = currentEndDate.value.format('MM-DD');
return `${start} ~ ${end}`;
}
});
// 计算属性:选中日期标签
const selectedDateLabel = computed(() => {
if (!selectedDate.value) return '';
return dayjs(selectedDate.value).format('YYYY年MM月DD日');
});
// 计算属性:月日历天数
const calendarDays = computed(() => {
const days: any[] = [];
const startDate = currentStartDate.value.startOf('month');
const endDate = currentEndDate.value.endOf('month');
const today = dayjs().format('YYYY-MM-DD');
// 填充月初空白
const startWeekday = startDate.day();
for (let i = 0; i < startWeekday; i++) {
const date = startDate.subtract(startWeekday - i, 'day');
days.push({
key: `prev-${i}`,
date: date.format('YYYY-MM-DD'),
dayNumber: date.date(),
isOtherMonth: true,
isToday: date.format('YYYY-MM-DD') === today,
hasSchedule: hasScheduleOnDate(date.format('YYYY-MM-DD')),
scheduleCount: getScheduleCount(date.format('YYYY-MM-DD')),
});
}
// 当月日期
for (let d = dayjs(startDate); d.isBefore(endDate.add(1, 'day')); d = d.add(1, 'day')) {
days.push({
key: d.format('YYYY-MM-DD'),
date: d.format('YYYY-MM-DD'),
dayNumber: d.date(),
isOtherMonth: false,
isToday: d.format('YYYY-MM-DD') === today,
hasSchedule: hasScheduleOnDate(d.format('YYYY-MM-DD')),
scheduleCount: getScheduleCount(d.format('YYYY-MM-DD')),
});
}
// 填充月末空白保持6行
const remainingDays = 42 - days.length;
for (let i = 0; i < remainingDays; i++) {
const date = endDate.add(1, 'day').add(i, 'day');
days.push({
key: `next-${i}`,
date: date.format('YYYY-MM-DD'),
dayNumber: date.date(),
isOtherMonth: true,
isToday: date.format('YYYY-MM-DD') === today,
hasSchedule: hasScheduleOnDate(date.format('YYYY-MM-DD')),
scheduleCount: getScheduleCount(date.format('YYYY-MM-DD')),
});
}
return days;
});
// 计算属性:周日历天数
const weekDaysData = computed(() => {
const days: any[] = [];
const startDate = currentStartDate.value;
const today = dayjs().format('YYYY-MM-DD');
for (let i = 0; i < 7; i++) {
const date = startDate.add(i, 'day');
const dateStr = date.format('YYYY-MM-DD');
days.push({
date: dateStr,
weekday: weekdayNames[date.day()],
dayNumber: date.date(),
isToday: dateStr === today,
schedules: getScheduleForDate(dateStr),
});
}
return days;
});
// 检查某日期是否有排课
const hasScheduleOnDate = (dateStr: string): boolean => {
if (!calendarData.value?.schedules) return false;
const schedules = calendarData.value.schedules[dateStr];
return schedules && schedules.length > 0;
};
// 获取某日期的排课数量
const getScheduleCount = (dateStr: string): number => {
if (!calendarData.value?.schedules) return 0;
const schedules = calendarData.value.schedules[dateStr];
return schedules ? schedules.length : 0;
};
// 获取某日期的排课列表
const getScheduleForDate = (dateStr: string): DayScheduleItem[] => {
if (!calendarData.value?.schedules) return [];
return calendarData.value.schedules[dateStr] || [];
};
// 加载日历数据
const loadEvents = async () => {
try {
const params: any = {
startDate: currentStartDate.value.format('YYYY-MM-DD'),
endDate: currentEndDate.value.format('YYYY-MM-DD'),
};
if (selectedClassId.value) {
params.classId = selectedClassId.value;
}
if (selectedTeacherId.value) {
params.teacherId = selectedTeacherId.value;
}
calendarData.value = await getCalendarViewData(params);
} catch (error) {
console.error('Failed to load events:', error);
message.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.list || [];
} catch (error) {
console.error('Failed to load teachers:', error);
}
};
// 导航操作
const prevNav = () => {
if (viewType.value === 'month') {
currentStartDate.value = currentStartDate.value.subtract(1, 'month');
currentEndDate.value = currentEndDate.value.subtract(1, 'month');
} else {
currentStartDate.value = currentStartDate.value.subtract(1, 'week');
currentEndDate.value = currentEndDate.value.subtract(1, 'week');
}
loadEvents();
};
const nextNav = () => {
if (viewType.value === 'month') {
currentStartDate.value = currentStartDate.value.add(1, 'month');
currentEndDate.value = currentEndDate.value.add(1, 'month');
} else {
currentStartDate.value = currentStartDate.value.add(1, 'week');
currentEndDate.value = currentEndDate.value.add(1, 'week');
}
loadEvents();
};
// 点击日期
const handleDayClick = (day: any) => {
selectedDate.value = day.date;
selectedDaySchedules.value = getScheduleForDate(day.date);
dayDetailVisible.value = true;
};
// 显示排课详情(周视图)
const showScheduleDetail = (item: DayScheduleItem) => {
selectedDaySchedules.value = [item];
selectedDate.value = ''; // 周视图不需要显示具体日期
dayDetailVisible.value = true;
};
// 监听视图类型变化
watch(viewType, (newType) => {
if (newType === 'month') {
currentStartDate.value = dayjs().startOf('month');
currentEndDate.value = dayjs().endOf('month');
} else {
currentStartDate.value = dayjs().startOf('week').add(1, 'day');
currentEndDate.value = currentStartDate.value.add(6, 'day');
}
loadEvents();
});
// 暴露刷新方法给父组件
defineExpose({
refresh: loadEvents,
});
onMounted(() => {
loadClasses();
loadTeachers();
loadEvents();
});
</script>
<style scoped lang="scss">
.calendar-view {
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.current-range-label {
font-weight: 500;
color: #333;
min-width: 120px;
text-align: center;
}
}
}
// 月视图
.calendar-month {
.weekday-header {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background: #F0F0F0;
margin-bottom: 8px;
.weekday-item {
padding: 12px;
text-align: center;
font-weight: 500;
color: #666;
}
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background: #E0E0E0;
border: 1px solid #E0E0E0;
}
.calendar-day {
background: white;
min-height: 80px;
padding: 8px;
cursor: pointer;
transition: background 0.2s;
position: relative;
&:hover {
background: #FFF8F0;
}
&.today {
background: #FFF0E6;
}
&.other-month {
color: #CCC;
background: #FAFAFA;
}
.day-number {
font-size: 14px;
color: #333;
margin-bottom: 4px;
}
.schedule-indicator {
width: 6px;
height: 6px;
background: #FF8C42;
border-radius: 50%;
margin: 0 auto;
}
.schedule-count {
position: absolute;
top: 4px;
right: 4px;
background: #FF8C42;
color: white;
font-size: 10px;
padding: 2px 4px;
border-radius: 8px;
min-width: 16px;
text-align: center;
}
}
}
// 周视图
.calendar-week {
.week-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
}
.week-day {
border: 1px solid #E0E0E0;
border-radius: 8px;
overflow: hidden;
}
.week-day-header {
padding: 12px;
background: #FFF8F0;
text-align: center;
border-bottom: 1px solid #E0E0E0;
&.is-today {
background: #FFE8CC;
}
.weekday {
font-size: 12px;
color: #999;
}
.day-number {
font-size: 20px;
font-weight: 600;
color: #2D3436;
}
}
.week-day-content {
padding: 8px;
min-height: 300px;
max-height: 400px;
overflow-y: auto;
}
.schedule-item {
padding: 8px;
margin-bottom: 6px;
background: #FFF0E6;
border-left: 3px solid #FF8C42;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #FFE0D0;
transform: translateX(2px);
}
.schedule-time {
font-size: 12px;
color: #FF8C42;
font-weight: 500;
}
.schedule-info {
margin-top: 4px;
.schedule-class {
font-size: 13px;
color: #2D3436;
}
.schedule-lesson {
font-size: 11px;
color: #999;
}
.schedule-lesson-type {
margin-top: 4px;
}
}
}
.no-schedule {
text-align: center;
color: #CCC;
font-size: 12px;
margin-top: 20px;
}
}
// 日详情弹窗
.day-schedules {
.day-schedule-item {
display: flex;
align-items: center;
padding: 12px;
margin-bottom: 8px;
background: #FAFAFA;
border-radius: 8px;
.item-time {
min-width: 100px;
font-weight: 500;
color: #FF8C42;
}
.item-info {
flex: 1;
.item-class {
font-size: 14px;
color: #2D3436;
}
.item-lesson {
font-size: 12px;
color: #999;
}
.item-teacher {
font-size: 12px;
color: #666;
}
}
.item-status {
margin-left: 12px;
}
}
}
</style>