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