kindergarten_java/reading-platform-frontend/src/views/school/schedule/TimetableView.vue
En 6e11c874d2 chore: 忽略 target 目录和 .class 文件
- 添加 target/ 到 .gitignore
- 从 git 暂存区移除已追踪的 target 目录

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 16:50:54 +08:00

740 lines
21 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">
<h2>课表视图</h2>
<a-space>
<a-dropdown>
<a-button>
<template #icon><PlusOutlined /></template>
新建排课
<DownOutlined />
</a-button>
<template #overlay>
<a-menu @click="handleCreateMenuClick">
<a-menu-item key="single">
<PlusOutlined /> 单个新建
</a-menu-item>
<a-menu-item key="batch">
<AppstoreAddOutlined /> 批量新建
</a-menu-item>
<a-menu-item key="template">
<CopyOutlined /> 从模板创建
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-button @click="showTemplateModal">
<template #icon><CopyOutlined /></template>
排课模板
</a-button>
<a-dropdown>
<a-button>
<template #icon><CalendarOutlined /></template>
视图切换
<DownOutlined />
</a-button>
<template #overlay>
<a-menu @click="handleViewMenuClick">
<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-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-space>
</div>
<!-- 筛选区 -->
<div class="filter-section">
<a-space>
<span>周次{{ weekRangeText }}</span>
<a-divider type="vertical" />
<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',
}"
@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.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="授课教师">{{ 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.repeatType === 'NONE'" color="default">单次</a-tag>
<a-tag v-else-if="selectedSchedule.repeatType === 'DAILY'" color="blue">每日</a-tag>
<a-tag v-else-if="selectedSchedule.repeatType === 'WEEKLY'" color="green">每周</a-tag>
</a-descriptions-item>
<a-descriptions-item label="来源">
<a-tag v-if="selectedSchedule.source === 'SCHOOL'" color="orange">学校排课</a-tag>
<a-tag v-else color="purple">教师预约</a-tag>
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag v-if="selectedSchedule.status === 'ACTIVE'" color="success">有效</a-tag>
<a-tag v-else color="error">已取消</a-tag>
</a-descriptions-item>
<a-descriptions-item v-if="selectedSchedule.note" label="备注">{{ selectedSchedule.note }}</a-descriptions-item>
</a-descriptions>
</template>
</a-modal>
<!-- 排课模板管理弹窗 -->
<a-modal
v-model:open="templateModalVisible"
title="排课模板管理"
:footer="null"
width="800px"
>
<div style="margin-bottom: 16px; text-align: right;">
<a-button type="primary" size="small" @click="router.push('/school/schedule')">
<PlusOutlined /> 新建模板
</a-button>
</div>
<a-table
:columns="[
{ title: '模板名称', dataIndex: 'name' },
{ title: '课程', dataIndex: 'courseName' },
{ title: '时间', dataIndex: 'scheduledTime' },
{ title: '操作', key: 'actions', width: 100 },
]"
:data-source="templates"
:loading="templateLoading"
size="small"
rowKey="id"
:pagination="{ pageSize: 5 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'actions'">
<a-button type="link" size="small" @click="applyTemplate(record as any)">应用</a-button>
</template>
</template>
</a-table>
</a-modal>
<!-- 批量排课弹窗 -->
<a-modal
v-model:open="batchModalVisible"
title="批量新建排课"
:confirm-loading="batchLoading"
@ok="handleBatchSubmit"
width="900px"
>
<a-alert message="批量添加排课信息" type="info" show-icon style="margin-bottom: 16px" />
<div style="margin-bottom: 16px;">
<a-button type="dashed" @click="addBatchItem">
<PlusOutlined /> 添加排课
</a-button>
</div>
<a-table
:columns="[
{ title: '班级', key: 'classId', width: 120 },
{ title: '课程', key: 'courseId', width: 150 },
{ title: '教师', key: 'teacherId', width: 100 },
{ title: '日期', key: 'scheduledDate', width: 140 },
{ title: '时间', key: 'scheduledTime', width: 120 },
{ title: '', key: 'actions', width: 50 },
]"
:data-source="batchItems"
size="small"
rowKey="key"
:pagination="false"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'classId'">
<a-select v-model:value="record.classId" placeholder="班级" style="width: 100%">
<a-select-option v-for="cls in classes" :key="cls.id" :value="cls.id">
{{ cls.name }}
</a-select-option>
</a-select>
</template>
<template v-if="column.key === 'courseId'">
<a-select v-model:value="record.courseId" placeholder="课程" style="width: 100%">
<a-select-option v-for="course in courses" :key="course.id" :value="course.id">
{{ course.name }}
</a-select-option>
</a-select>
</template>
<template v-if="column.key === 'teacherId'">
<a-select v-model:value="record.teacherId" placeholder="教师" style="width: 100%" allowClear>
<a-select-option v-for="teacher in teachers" :key="teacher.id" :value="teacher.id">
{{ teacher.name }}
</a-select-option>
</a-select>
</template>
<template v-if="column.key === 'scheduledDate'">
<a-date-picker v-model:value="record.scheduledDate" style="width: 100%" />
</template>
<template v-if="column.key === 'scheduledTime'">
<a-input v-model:value="record.scheduledTime" placeholder="09:00-09:30" style="width: 100%" />
</template>
<template v-if="column.key === 'actions'">
<a-button type="link" size="small" danger @click="removeBatchItem(index)">
<DeleteOutlined />
</a-button>
</template>
</template>
</a-table>
</a-modal>
<!-- 从模板创建弹窗 -->
<a-modal
v-model:open="templateSelectModalVisible"
title="从模板创建排课"
:confirm-loading="templateSelectLoading"
@ok="handleTemplateSelectSubmit"
>
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="选择模板">
<a-select v-model:value="selectedTemplateId" placeholder="选择排课模板" style="width: 100%">
<a-select-option v-for="tpl in templates" :key="tpl.id" :value="tpl.id">
{{ tpl.name }} - {{ tpl.courseName }} ({{ tpl.scheduledTime }})
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="排课日期">
<a-date-picker v-model:value="templateApplyDate" style="width: 100%" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import dayjs from 'dayjs';
import {
LeftOutlined,
RightOutlined,
PlusOutlined,
DownOutlined,
AppstoreAddOutlined,
CopyOutlined,
CalendarOutlined,
UnorderedListOutlined,
TableOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue';
import {
getTimetable,
getClasses,
getTeachers,
getSchoolCourses,
getScheduleTemplates,
createScheduleTemplate,
updateScheduleTemplate,
deleteScheduleTemplate,
applyScheduleTemplate,
batchCreateSchedules,
createSchedule,
type TimetableItem,
type SchedulePlan,
type ClassInfo,
type Teacher,
type ScheduleTemplate,
} from '@/api/school';
const router = useRouter();
// 数据
const loading = ref(false);
const timetable = ref<TimetableItem[]>([]);
const classes = ref<ClassInfo[]>([]);
const teachers = ref<Teacher[]>([]);
const courses = ref<any[]>([]);
// 当前周
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 dayData = timetable.value.find(t => t.date === dateStr);
return {
date: dateStr,
dayName: days[i],
dateDisplay: date.format('MM-DD'),
isToday: dateStr === today,
schedules: dayData?.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,
});
timetable.value = res;
} catch (error) {
message.error('加载课表失败');
} finally {
loading.value = false;
}
};
const loadBaseData = async () => {
try {
const [classesRes, teachersRes, coursesRes] = await Promise.all([
getClasses(),
getTeachers({ pageNum: 1, pageSize: 100 }),
getSchoolCourses(),
]);
classes.value = classesRes;
teachers.value = teachersRes.list;
courses.value = coursesRes;
} 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 handleViewMenuClick = (e: any) => {
const key = e.key;
if (key === 'list') {
router.push('/school/schedule');
} else if (key === 'calendar') {
router.push('/school/schedule/calendar');
}
};
// 排课模板
const templateModalVisible = ref(false);
const templateLoading = ref(false);
const templates = ref<ScheduleTemplate[]>([]);
const templateFormModalVisible = ref(false);
const templateFormLoading = ref(false);
const editingTemplate = ref<ScheduleTemplate | null>(null);
const templateForm = reactive({
name: '',
courseId: undefined as number | undefined,
classId: undefined as number | undefined,
teacherId: undefined as number | undefined,
scheduledTime: '09:00-09:30',
weekDay: undefined as number | undefined,
duration: 30,
isDefault: false,
});
const showTemplateModal = async () => {
templateModalVisible.value = true;
await loadTemplates();
};
const loadTemplates = async () => {
templateLoading.value = true;
try {
templates.value = await getScheduleTemplates();
} catch (error) {
message.error('加载模板失败');
} finally {
templateLoading.value = false;
}
};
// 批量排课
const batchModalVisible = ref(false);
const batchLoading = ref(false);
const batchItems = ref<any[]>([]);
let batchKey = 0;
const showBatchModal = () => {
batchItems.value = [];
batchKey = 0;
addBatchItem();
batchModalVisible.value = true;
};
const addBatchItem = () => {
batchItems.value.push({
key: ++batchKey,
classId: undefined,
courseId: undefined,
teacherId: undefined,
scheduledDate: dayjs(),
scheduledTime: '09:00-09:30',
});
};
const removeBatchItem = (index: number) => {
batchItems.value.splice(index, 1);
};
const handleBatchSubmit = async () => {
const validItems = batchItems.value.filter(item =>
item.classId && item.courseId && item.scheduledDate && item.scheduledTime
);
if (validItems.length === 0) {
message.warning('请至少填写一条完整的排课信息');
return;
}
batchLoading.value = true;
try {
const schedules = validItems.map(item => ({
classId: item.classId,
courseId: item.courseId,
teacherId: item.teacherId,
scheduledDate: item.scheduledDate.format('YYYY-MM-DD'),
scheduledTime: item.scheduledTime,
}));
const result = await batchCreateSchedules(schedules);
message.success(`成功创建 ${result.success} 条排课`);
batchModalVisible.value = false;
loadTimetable();
} catch (error) {
message.error('批量创建失败');
} finally {
batchLoading.value = false;
}
};
// 从模板创建
const templateSelectModalVisible = ref(false);
const templateSelectLoading = ref(false);
const selectedTemplateId = ref<number | undefined>();
const templateApplyDate = ref(dayjs());
const applyTemplate = (record: ScheduleTemplate) => {
selectedTemplateId.value = record.id;
templateApplyDate.value = dayjs();
templateSelectModalVisible.value = true;
};
const handleTemplateSelectSubmit = async () => {
if (!selectedTemplateId.value || !templateApplyDate.value) {
message.warning('请选择模板和日期');
return;
}
templateSelectLoading.value = true;
try {
await applyScheduleTemplate(selectedTemplateId.value, {
scheduledDate: templateApplyDate.value.format('YYYY-MM-DD'),
});
message.success('应用模板成功');
templateSelectModalVisible.value = false;
templateModalVisible.value = false;
loadTimetable();
} catch (error) {
message.error('应用模板失败');
} finally {
templateSelectLoading.value = false;
}
};
// 新建排课菜单
const handleCreateMenuClick = (e: any) => {
const key = e.key;
if (key === 'single') {
router.push('/school/schedule');
} else if (key === 'batch') {
showBatchModal();
} else if (key === 'template') {
selectedTemplateId.value = undefined;
templateApplyDate.value = dayjs();
templateSelectModalVisible.value = true;
loadTemplates();
}
};
// 详情
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');
};
onMounted(() => {
loadBaseData();
loadTimetable();
});
</script>
<style scoped lang="scss">
.timetable-view {
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h2 {
margin: 0;
}
}
.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;
}
}
.empty-day {
text-align: center;
color: #999;
padding: 20px;
font-size: 12px;
}
}
</style>