- 排课计划参考:对齐管理端课程包详情,支持时间/课程类型/课程名称/区域活动/备注五列 - 支持两种 schedule_ref_data 格式(周排课表、课程类型说明) - 新建排课弹窗样式提取为 CreateScheduleModal.scss 修复 SASS 编译错误 - 切换视图(列表/课表/日历)时自动刷新数据 - 排课列表、课表、日历视图增加课程类型 tag 展示 - 后端:timetable/lesson-types 接口修复,LessonTypeEnum 补充类型 Made-with: Cursor
604 lines
15 KiB
Vue
604 lines
15 KiB
Vue
<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>
|