2026-02-26 15:22:26 +08:00
|
|
|
|
<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(),
|
2026-03-14 16:50:54 +08:00
|
|
|
|
getTeachers({ pageNum: 1, pageSize: 100 }),
|
2026-02-26 15:22:26 +08:00
|
|
|
|
getSchoolCourses(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
classes.value = classesRes;
|
2026-03-13 13:48:28 +08:00
|
|
|
|
teachers.value = teachersRes.list;
|
2026-02-26 15:22:26 +08:00
|
|
|
|
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>
|