kindergarten_java/reading-platform-frontend/src/views/school/schedule/TimetableView.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

415 lines
11 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="timetable-view">
<div class="page-header">
<a-space>
<a-button @click="goToPrevWeek">
<template #icon><LeftOutlined /></template>
上一周
</a-button>
<a-button @click="goToCurrentWeek">本周</a-button>
<a-button @click="goToNextWeek">
<template #icon><RightOutlined /></template>
下一周
</a-button>
<a-divider type="vertical" />
<span class="week-range">周次{{ weekRangeText }}</span>
</a-space>
</div>
<!-- 筛选区 -->
<div class="filter-section">
<a-space>
<a-select
v-model:value="filters.classId"
placeholder="选择班级"
allowClear
style="width: 150px"
@change="loadTimetable"
>
<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="filters.teacherId"
placeholder="选择教师"
allowClear
style="width: 150px"
@change="loadTimetable"
>
<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="timetable-container">
<div class="timetable-header">
<div
v-for="day in weekDays"
:key="day.date"
class="day-header"
:class="{ 'is-today': day.isToday }"
>
<div class="day-name">{{ day.dayName }}</div>
<div class="day-date">{{ day.dateDisplay }}</div>
</div>
</div>
<div class="timetable-body">
<a-spin :spinning="loading">
<div class="timetable-grid">
<div
v-for="day in weekDays"
:key="day.date"
class="day-column"
:class="{ 'is-today': day.isToday }"
>
<div
v-for="schedule in day.schedules"
:key="schedule.id"
class="schedule-card"
:class="{
'school-schedule': schedule.source === 'SCHOOL',
'teacher-schedule': schedule.source === 'TEACHER',
'cancelled': schedule.status === 'cancelled' || schedule.status === 'CANCELLED',
}"
@click="showScheduleDetail(schedule)"
>
<div class="schedule-time">{{ schedule.scheduledTime || '待定' }}</div>
<div class="schedule-course">{{ schedule.courseName || '课程' }}</div>
<div class="schedule-class">{{ schedule.className || '班级' }}</div>
<div v-if="schedule.teacherName" class="schedule-teacher">{{ schedule.teacherName }}</div>
<a-tag v-if="schedule.lessonType" size="small" class="schedule-lesson-type"
:style="getLessonTagStyle(schedule.lessonType)">{{ getLessonTypeName(schedule.lessonType) }}</a-tag>
<a-tag v-if="schedule.status === 'cancelled' || schedule.status === 'CANCELLED'" color="error" size="small">已取消</a-tag>
</div>
<div v-if="!day.schedules.length" class="empty-day">
暂无排课
</div>
</div>
</div>
</a-spin>
</div>
</div>
<!-- 排课详情弹窗 -->
<a-modal
v-model:open="detailVisible"
title="排课详情"
:footer="null"
width="500px"
>
<template v-if="selectedSchedule">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="班级">{{ selectedSchedule.className || '-' }}</a-descriptions-item>
<a-descriptions-item label="课程">{{ selectedSchedule.courseName || '-' }}</a-descriptions-item>
<a-descriptions-item label="课程类型">
<a-tag v-if="selectedSchedule.lessonType" size="small" :style="getLessonTagStyle(selectedSchedule.lessonType)">
{{ getLessonTypeName(selectedSchedule.lessonType) }}
</a-tag>
<span v-else>-</span>
</a-descriptions-item>
<a-descriptions-item label="授课教师">{{ selectedSchedule.teacherName || '未分配' }}</a-descriptions-item>
<a-descriptions-item label="排课日期">{{ formatDate(selectedSchedule.scheduledDate) }}</a-descriptions-item>
<a-descriptions-item label="时间段">{{ selectedSchedule.scheduledTime || '待定' }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag v-if="selectedSchedule.status === 'ACTIVE' || selectedSchedule.status === 'scheduled'" color="success">有效</a-tag>
<a-tag v-else color="error">已取消</a-tag>
</a-descriptions-item>
</a-descriptions>
</template>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import dayjs from 'dayjs';
import {
LeftOutlined,
RightOutlined,
} from '@ant-design/icons-vue';
import {
getTimetable,
getClasses,
getTeachers,
type TimetableItem,
type SchedulePlan,
type ClassInfo,
type Teacher,
} from '@/api/school';
import { getLessonTypeName, getLessonTagStyle } from '@/utils/tagMaps';
// 数据
const loading = ref(false);
const timetableData = ref<{
byDate: Record<string, SchedulePlan[]>;
byWeekDay: Record<number, SchedulePlan[]>;
total: number;
}>({ byDate: {}, byWeekDay: {}, total: 0 });
const classes = ref<ClassInfo[]>([]);
const teachers = ref<Teacher[]>([]);
// 当前周
const currentWeekStart = ref(dayjs().startOf('week').add(1, 'day')); // 周一
// 筛选
const filters = reactive({
classId: undefined as number | undefined,
teacherId: undefined as number | undefined,
});
// 详情弹窗
const detailVisible = ref(false);
const selectedSchedule = ref<SchedulePlan | null>(null);
// 计算属性
const weekRangeText = computed(() => {
const start = currentWeekStart.value;
const end = start.add(6, 'day');
return `${start.format('MM-DD')} 至 ${end.format('MM-DD')}`;
});
const weekDays = computed(() => {
const days = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
const today = dayjs().format('YYYY-MM-DD');
return Array.from({ length: 7 }, (_, i) => {
const date = currentWeekStart.value.add(i, 'day');
const dateStr = date.format('YYYY-MM-DD');
const schedules = timetableData.value.byDate[dateStr] || [];
return {
date: dateStr,
dayName: days[i],
dateDisplay: date.format('MM-DD'),
isToday: dateStr === today,
schedules: schedules,
};
});
});
// 加载数据
const loadTimetable = async () => {
loading.value = true;
try {
const start = currentWeekStart.value;
const res = await getTimetable({
startDate: start.format('YYYY-MM-DD'),
endDate: start.add(6, 'day').format('YYYY-MM-DD'),
...filters,
});
timetableData.value = res;
} catch (error) {
message.error('加载课表失败');
} finally {
loading.value = false;
}
};
const loadBaseData = async () => {
try {
const [classesRes, teachersRes] = await Promise.all([
getClasses(),
getTeachers({ pageNum: 1, pageSize: 100 }),
]);
classes.value = classesRes;
teachers.value = teachersRes.list;
} catch (error) {
message.error('加载基础数据失败');
}
};
// 周次导航
const goToPrevWeek = () => {
currentWeekStart.value = currentWeekStart.value.subtract(7, 'day');
loadTimetable();
};
const goToNextWeek = () => {
currentWeekStart.value = currentWeekStart.value.add(7, 'day');
loadTimetable();
};
const goToCurrentWeek = () => {
currentWeekStart.value = dayjs().startOf('week').add(1, 'day');
loadTimetable();
};
// 详情
const showScheduleDetail = (schedule: SchedulePlan) => {
selectedSchedule.value = schedule;
detailVisible.value = true;
};
const formatDate = (date: string | undefined) => {
if (!date) return '-';
return dayjs(date).format('YYYY-MM-DD');
};
// 暴露刷新方法给父组件
defineExpose({
refresh: loadTimetable,
});
onMounted(() => {
loadBaseData();
loadTimetable();
});
</script>
<style scoped lang="scss">
.timetable-view {
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.week-range {
font-weight: 500;
color: #333;
}
}
.filter-section {
margin-bottom: 20px;
padding: 16px;
background: #fafafa;
border-radius: 8px;
}
.timetable-container {
border: 1px solid #e8e8e8;
border-radius: 8px;
overflow: hidden;
}
.timetable-header {
display: table;
width: 100%;
table-layout: fixed;
background: linear-gradient(135deg, #FF8C42 0%, #E67635 100%);
color: white;
.day-header {
display: table-cell;
padding: 12px 8px;
text-align: center;
border-right: 1px solid rgba(255, 255, 255, 0.2);
vertical-align: middle;
&:last-child {
border-right: none;
}
&.is-today {
background: rgba(255, 255, 255, 0.2);
}
.day-name {
font-weight: 500;
}
.day-date {
font-size: 12px;
opacity: 0.8;
}
}
}
.timetable-body {
min-height: 400px;
}
.timetable-grid {
display: table;
width: 100%;
table-layout: fixed;
.day-column {
display: table-cell;
min-height: 300px;
padding: 8px;
border-right: 1px solid #e8e8e8;
background: #fafafa;
vertical-align: top;
&:last-child {
border-right: none;
}
&.is-today {
background: #fff4ec;
}
}
}
.schedule-card {
background: white;
border-radius: 8px;
padding: 10px;
margin-bottom: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.3s;
border-left: 3px solid #FF8C42;
&:hover {
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
&.teacher-schedule {
border-left-color: #722ed1;
}
&.cancelled {
opacity: 0.5;
border-left-color: #999;
}
.schedule-time {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.schedule-course {
font-weight: 500;
color: #333;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.schedule-class {
font-size: 12px;
color: #666;
}
.schedule-teacher {
font-size: 12px;
color: #999;
margin-top: 4px;
}
.schedule-lesson-type {
margin-top: 4px;
}
}
.empty-day {
text-align: center;
color: #999;
padding: 20px;
font-size: 12px;
}
}
</style>